diff --git a/.codex/skills/behavior-driven-development b/.codex/skills/behavior-driven-development deleted file mode 100644 index 31fbb4e7..00000000 --- a/.codex/skills/behavior-driven-development +++ /dev/null @@ -1 +0,0 @@ -C:/proj/Genarrative/.hermes/skills/behavior-driven-development \ No newline at end of file diff --git a/.codex/skills/behavior-driven-development b/.codex/skills/behavior-driven-development new file mode 120000 index 00000000..1db643f5 --- /dev/null +++ b/.codex/skills/behavior-driven-development @@ -0,0 +1 @@ +../../.hermes/skills/behavior-driven-development/ \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..422a9ea0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,36 @@ +.git +.codex-temp +.codex-logs +.codex-runlogs +.idea +.vite +node_modules +target +dist +coverage +logs +tmp +*.log +/*.png +/*.jpg +/*.jpeg +/*.webp + +.env +.env.local +.env.secrets.local +.env.secrets.* +spacetime.local.json +deploy/container/api-server.env + +server-rs/target +server-rs/target-* +server-rs/.data +server-rs/.spacetimedb + +public/generated-* + +scripts/loadtest/data/*.local.json +scripts/loadtest/data/k6-*.log +scripts/loadtest/data/k6-*summary*.md +scripts/loadtest/data/latest-*-prefix.txt diff --git a/.env.local b/.env.local index 34b87a66..311781f6 100644 --- a/.env.local +++ b/.env.local @@ -16,21 +16,10 @@ JWT_EXPIRES_IN="7d" SMS_AUTH_ENABLED="true" SMS_AUTH_PROVIDER="aliyun" -ALIYUN_SMS_ACCESS_KEY_ID="LTAI5tM6VjoixveLUNQ7x6z9" -ALIYUN_SMS_ACCESS_KEY_SECRET="w8Z8JlQKI1juGPSeirWwlvJfHp9frD" -ALIYUN_SMS_ENDPOINT="dypnsapi.aliyuncs.com" -ALIYUN_SMS_SIGN_NAME="速通互联验证码" -ALIYUN_SMS_TEMPLATE_CODE="100001" +ALIYUN_SMS_ENDPOINT="dysmsapi.aliyuncs.com" +ALIYUN_SMS_SIGN_NAME="北京亓盒网络科技" +ALIYUN_SMS_TEMPLATE_CODE="SMS_506245486" ALIYUN_SMS_TEMPLATE_PARAM_KEY="code" -ALIYUN_SMS_COUNTRY_CODE="86" -ALIYUN_SMS_SCHEME_NAME="" -ALIYUN_SMS_CODE_LENGTH="6" -ALIYUN_SMS_CODE_TYPE="1" -ALIYUN_SMS_VALID_TIME_SECONDS="300" -ALIYUN_SMS_INTERVAL_SECONDS="60" -ALIYUN_SMS_DUPLICATE_POLICY="1" -ALIYUN_SMS_CASE_AUTH_POLICY="1" -ALIYUN_SMS_RETURN_VERIFY_CODE="false" VITE_AUTH_ALLOW_DEV_GUEST="false" @@ -70,3 +59,9 @@ GENARRATIVE_SPACETIME_TOKEN="" GENARRATIVE_ADMIN_USERNAME=admin GENARRATIVE_ADMIN_PASSWORD=123456 ADMIN_API_TARGET=http://127.0.0.1:3100 + +# OTLP +GENARRATIVE_OTEL_ENABLED=true +OTEL_SERVICE_NAME=genarrative-api +OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 +OTEL_RESOURCE_ATTRIBUTES=deployment.environment=local,service.namespace=genarrative diff --git a/.gitignore b/.gitignore index 6f27c449..11a83c89 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ temp*build*/ .worktrees/ .env.secrets.local spacetime.local.json +deploy/container/api-server.env # Local load-test data extracted from private migration files scripts/loadtest/data/*.local.json diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index c8893c4a..d6c9d221 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,71 @@ --- + +## 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.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。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + +## 2026-05-18 Windows Jenkins PowerShell 统一改为显式 powershell.exe 启动 + +- 背景:`Genarrative-Stdb-Module-Build` 在 Windows Jenkins 本地环境里调用裸 `powershell` step 时触发 `CreateProcess error=5, 拒绝访问`,而 `powershell.exe` 本体与 workspace ACL 都正常。 +- 决策:Windows Jenkins 上凡是需要执行 PowerShell 逻辑的流水线,优先通过 `bat` 显式调用 `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ...`,不要再依赖 Jenkins `powershell` step 的隐式启动器。 +- 追加决策:`Genarrative-Stdb-Module-Build` 的 Checkout 逻辑应复用 Jenkins GitSCM 已完成的工作区状态。`COMMIT_HASH` 为空或已与当前 `HEAD` 一致时,不再额外执行 `git clean` / `git checkout`;只有需要切到指定且不同的 commit 时才补 fetch、校验和切换,避免在 Windows workspace 里二次清理触发权限拒绝。 +- 影响范围:`jenkins/Jenkinsfile.production-stdb-module-build` 及后续所有同类 Windows 构建流水线。 +- 验证方式:Jenkins 日志中应能看到 `[jenkins-powershell] user:` 和 `[jenkins-powershell] exe:`,Checkout 阶段会打印当前 `HEAD` 与请求 commit,并在 `COMMIT_HASH` 为空或一致时直接继续;不再停在 `PipelineNodeTreeScanner... Cannot run program "powershell"` 或重复 `git clean` 的退出码 5。 +- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`.hermes/shared-memory/pitfalls.md`。 + +## 2026-05-17 容器化方案只作为隔离压测与预发模拟路径 + +- 背景:Windows 本机直连极高 VU 压测会放大本地连接与发送缓冲行为,和线上 Linux + Nginx + systemd 拓扑不一致;需要一个更接近生产网络层的模拟方案,但不能扰动当前生产发布链路。 +- 决策:新增 `deploy/container/` 容器化方案,使用 Docker Compose 组合 Linux release `api-server`、容器 Nginx、`otelcol-contrib` debug exporter 和可选 k6。该方案只用于本机或预发压测模拟,不替换当前生产 `systemd + Nginx + Jenkins` 路径。 +- 隔离边界:容器方案使用独立 `deploy/container/api-server.env`、独立 Nginx 配置、独立 compose 命令和默认 `18080` 端口;真实 token 不进入镜像、不提交 Git;生产 systemd 单元、Jenkins 发布脚本和 `deploy/nginx/` 模板仍是正式线上来源。 +- 影响范围:`deploy/container/`、`scripts/container-compose.mjs`、`package.json` 容器命令、开发运维文档和容器 build context 排除规则。 +- 验证方式:执行 `npm run container:config` 展开 compose 配置;需要真实运行时再执行 `npm run container:build`、`npm run container:up`、`npm run container:k6`,并结合容器 Nginx log 与 OTLP debug exporter 判断瓶颈。 +- 关联文档:`deploy/container/README.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + +## 2026-05-16 公开作品列表短期由 BFF 订阅读模型缓存 + +- 背景:作品列表压测和实时性讨论中,曾考虑让浏览器前端直接订阅公开作品列表,减少 HTTP 拉取和 BFF 压力。 +- 决策:本轮不直接把作品列表整体交给前端订阅。短期继续由 `api-server` / BFF 通过 `spacetime-client` 长期订阅 SpacetimeDB 公开 read model 并读取本地 cache,维持首屏、排序、字段归一、权限降级和 HTTP fallback。中期可以新增或统一稳定的专用公开作品列表 read model,例如 `public_work_gallery_entry`,作为前端可选直连订阅对象。 +- 边界:未来前端直订阅只允许面向稳定、低基数、公开的专用 read model。前端不得直接订阅 `puzzle_work_profile`、`custom_world_profile` 等领域源表,也不得在前端自行 join、聚合或执行公开权限逻辑;这些逻辑必须先沉到后端投影 / read model。 +- 后续准入:若要落地前端直订阅,必须先完成并验收权限边界、字段契约、排序 / 分页、埋点和 BFF 回退策略;缺任一项时继续走 `api-server` / BFF 订阅缓存方案。 +- 影响范围:发现页、推荐流、各玩法公开广场、`api-server` 公开列表缓存、SpacetimeDB public view / public 读模型设计。 +- 验证方式:新增公开作品列表订阅能力时,检查前端只消费专用 public read model 或 BFF HTTP DTO;检查源表 row shape、权限判断和跨玩法聚合没有下沉到前端页面。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + +## 2026-05-16 api-server OpenTelemetry 统一补齐 traces metrics logs + +- 背景:压测与运行观测需要把 HTTP、SpacetimeDB 调用和应用日志串起来,同时保留本地 `journalctl` / 文件日志做故障排障。 +- 决策:`api-server` 通过 OTLP HTTP base endpoint 发送 traces、metrics 和 logs;Collector 统一用 `otelcol-contrib`,`npm run otel:debug` 负责 debug 采集,`npm run otel:rider` 负责转发到 Rider;Rider 只是接收与可视化端,不直接替代 Collector。 +- 日志口径:Rider Logs 面板只展示 log event 自身字段,请求完成日志需要直接携带 `request_id`、HTTP method、规范化 route、scheme、path、status、status_class、latency 和 slow_request;更完整的 request attributes 仍以 trace/span 为准。 +- 影响范围:`server-rs/crates/shared-logging`、`server-rs/crates/api-server`、`scripts/run-otelcol.mjs`、压测与运维文档。 +- 验证方式:`cargo test -p shared-logging --manifest-path server-rs/Cargo.toml generic_otlp_http_endpoint_expands_to_signal_paths`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml observability_route_keeps_metrics_labels_low_cardinality`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml resolve_request_scheme_uses_forwarded_proto_first_value`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`。 +- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`scripts/loadtest/README.md`。 + ## 2026-05-14 创作页图像输入统一封装为图像组件 - 背景:拼图创作页已经具备“画面描述生图 / 多参考图生图 / 上传主图后 AI 重绘 / 上传主图后不重绘”四条路径,抓大鹅封面和后续创作页也会复用同一套交互;继续在页面内复制会导致参考图、预览、删除确认和重绘开关漂移。 @@ -133,7 +198,8 @@ ## 2026-05-12 拼图 UI 背景图复用 levels_json 持久化 - 背景:拼图草稿结果页需要像抓大鹅一样支持 UI 背景生成,但首版只需要作品级/首关背景,不应为图片生成结果新增 SpacetimeDB 表结构。 -- 决策:拼图 UI 背景字段存入首关 `levels_json`,字段为 `uiBackgroundPrompt`、`uiBackgroundImageSrc`、`uiBackgroundImageObjectKey`;`compile_puzzle_draft` 草稿编译阶段在首图完成后自动生成首关 UI 背景,自动草稿阶段必须拿到 `uiBackgroundImageSrc` 或 `uiBackgroundImageObjectKey` 才能返回成功;结果页新增 `UI` Tab,可编辑提示词并触发 `generate_puzzle_ui_background`,手动生成失败只展示在当前面板。`api-server` 读取 `public/ui-previews/puzzle-image-compact-ui-2026-05-08.png` 作为非拼图 UI 参考图,调用 VectorEngine `gpt-image-2-all` 生成 9:16 背景并要求中央正方形拼图区与外部 UI 背景边界清晰。SpacetimeDB 只保存结果,不做外部 I/O。 +- 决策:拼图 UI 背景字段存入首关 `levels_json`,字段为 `uiBackgroundPrompt`、`uiBackgroundImageSrc`、`uiBackgroundImageObjectKey`;`compile_puzzle_draft` 草稿编译阶段自动生成首关 UI 背景,自动草稿阶段必须拿到 `uiBackgroundImageSrc` 或 `uiBackgroundImageObjectKey` 才能返回成功;结果页新增 `UI` Tab,可编辑提示词并触发 `generate_puzzle_ui_background`,手动生成失败只展示在当前面板。`api-server` 读取 `public/ui-previews/puzzle-image-compact-ui-2026-05-08.png` 作为非拼图 UI 参考图,调用 VectorEngine `gpt-image-2-all` 生成 9:16 背景并要求中央正方形拼图区与外部 UI 背景边界清晰。SpacetimeDB 只保存结果,不做外部 I/O。 +- 2026-05-18 追加:为缩短首版草稿等待,`compile_puzzle_draft` 在首关命名和 `uiBackgroundPrompt` 稳定后并行启动首关关卡图生成与 UI 背景生成;上传主图且关闭 AI 重绘时,并行执行上传图持久化与 UI 背景生成。生成页预计完成时间按 5 分钟展示。 - 影响范围:拼图结果页、拼图运行态背景渲染、拼图 agent action、`module-puzzle` / `spacetime-module` / `spacetime-client` 的拼图关卡 JSON 映射、拼图流程技术文档。 - 验证方式:执行 `npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx`、`cargo test -p api-server puzzle_ui_background --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md`。 @@ -172,7 +238,7 @@ ## 2026-05-12 宝贝识物创作同时生成玩法视觉主题包 - 背景:`宝贝识物` 创作原本只根据两个关键词生成物品透明图,运行态背景、UI、礼物盒和篮子仍使用固定 CSS 绘本风,无法根据“小猪佩琪 / 奥特曼”或“苹果 / 橘子”等创作者提示词做主题化包装。 -- 决策:`POST /api/creation/edutainment/baby-object-match/assets` 同一次 image-2 / VectorEngine 调用链返回两个物品图和 `visualPackage`。视觉包包含 `background`、`ui-frame`、`gift-box`、`basket`、`smoke-puff` 五类资源;总风格保持寓教于乐明亮卡通绘本插画风,主题按两个物品关键词匹配,水果偏果园自然,动漫角色 / 玩具偏动漫玩具。物品图和礼物盒 / 篮子 / UI / 烟雾特效资源走透明 PNG 后处理,背景为清爽不遮挡玩法区的环境图;运行态中礼物盒按约 2 倍视觉尺寸展示、篮子按约 1.5 倍展示,礼物盒打开时使用 `smoke-puff` 弹出中央物品并移除礼盒。前端草稿保存该包,运行态消费该包;旧草稿以 `visualPackage = null` 继续使用 CSS 兜底。 +- 决策:`POST /api/creation/edutainment/baby-object-match/assets` 同一次 image-2 / VectorEngine 调用链返回两个物品图和 `visualPackage`。为降低调用成本,新链路只生成一张 `1024x1024` 的 `2x2` 素材 sheet 和一张 `1536x1024` 场景背景图;`2x2` sheet 固定左上物品 A、右上物品 B、左下篮子、右下礼物盒,服务端按格切图并把物品、篮子和礼物盒转透明 PNG。视觉包必需资源为 `background`、`gift-box`、`basket`;总风格保持寓教于乐明亮卡通绘本插画风,主题按两个物品关键词匹配。左右手位置指示器是运行态默认静态素材,使用项目内置第一人称半抓握手,不再随每次创作生成。运行态中礼物盒按约 2 倍视觉尺寸展示、篮子按约 1.5 倍展示,中央物品 UI 与篮子物品图标使用固定正方形槽位并等比 `contain` 缩放,礼物盒打开烟雾特效由 CSS 兜底;历史草稿中的 `ui-frame` / `smoke-puff` / `left-hand` / `right-hand` 仅兼容读取或忽略。前端草稿保存该包,运行态消费该包;旧草稿以 `visualPackage = null` 继续使用 CSS 兜底。 - 影响范围:`packages/shared/src/contracts/edutainmentBabyObject.ts`、`server-rs/crates/api-server/src/edutainment_baby_object.rs`、`src/services/edutainment-baby-object/babyObjectMatchClient.ts`、`src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx`、`src/index.css`、宝贝识物 PRD 与技术方案。 - 验证方式:执行宝贝识物 service / runtime 定向测试、`cargo test -p api-server edutainment_baby_object --manifest-path server-rs/Cargo.toml`、相关 ESLint 与编码检查;真实生图需配置 `VECTOR_ENGINE_BASE_URL` 与 `VECTOR_ENGINE_API_KEY`。 - 关联文档:`docs/prd/BABY_OBJECT_MATCH_EDUTAINMENT_TEMPLATE_PRD_2026-05-11.md`、`docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`。 @@ -197,7 +263,7 @@ ## 2026-05-10 儿童动作 Demo 视觉资产统一为绘本草地舞台 - 背景:儿童动作 Demo 需要从暗色科技风切换到更适合儿童互动的卡通绘本草地风格,并且要让背景、地面、UI、地面指示环和用户轮廓使用同一套 image-2 资源口径。 -- 决策:热身舞台及后续儿童动作 Demo 场景、物品、UI 资源统一采用明亮卡通绘本草地视觉语言。真实资源默认输出到 `public/child-motion-demo/`。背景沿用 `picture-book-grass-stage.png`;地面、指示环、角色轮廓和 UI 已拆分为 v2 用途专属资源:`picture-book-foreground-grass-v2.png`、`picture-book-ground-ring-v2.png`、`picture-book-character-outline-v2.png`、`picture-book-hud-strip-v2.png`、`picture-book-calibration-strip-v2.png`、`picture-book-start-panel-v2.png` 和 `picture-book-ui-button-v2.png`。生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 调用 VectorEngine `gpt-image-2-all`;透明资源使用品红底生成后本地去背,中间源图仅保存在 `tmp/child-motion-demo-assets/`。在缺少 `VECTOR_ENGINE_BASE_URL` 或 `VECTOR_ENGINE_API_KEY` 时,只允许 dry-run 和 CSS 兜底,不伪造 live 生图结果。 +- 决策:热身舞台及后续儿童动作 Demo 场景、物品、UI 资源统一采用明亮卡通绘本草地视觉语言。真实资源默认输出到 `public/child-motion-demo/`。背景沿用 `picture-book-grass-stage.png`;地面、指示环、角色指示器和 UI 已拆分为用途专属资源:`picture-book-foreground-grass-v2.png`、`picture-book-ground-ring-v3.png`、`picture-book-character-outline-v4.png`、`picture-book-hud-strip-v2.png`、`picture-book-calibration-strip-v2.png`、`picture-book-start-panel-v2.png` 和 `picture-book-ui-button-v2.png`。其中角色指示器 v4 基于 v2 本地后处理为更细的白色描边样式,内部透明,耳朵、手指、脚趾等细节已弱化,页面显示尺寸相对上一版放大 50%。生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 调用 VectorEngine `gpt-image-2-all`;透明资源使用品红底生成后本地去背,中间源图仅保存在 `tmp/child-motion-demo-assets/`。在缺少 `VECTOR_ENGINE_BASE_URL` 或 `VECTOR_ENGINE_API_KEY` 时,只允许 dry-run 和 CSS 兜底,不伪造 live 生图结果。 - 影响范围:`src/index.css`、`src/components/child-motion-demo/ChildMotionWarmupDemo.tsx` 的舞台视觉层、儿童动作 Demo 技术文档、后续 image-2 资产生成流程。 - 验证方式:检查 `/child-motion-demo` 舞台是否在未生成资产时仍有可用草地绘本兜底;补齐 VectorEngine 私密配置后运行 `npm run assets:child-motion-demo -- --live` 或 `--live --only ` 应能写出对应 PNG,并确认页面静态资源返回 `image/png`。若只调整透明去背、裁切或品红边缘,可运行 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only ` 复用源图后处理。页面接入时必须按资源原始比例等比使用,不得把方形软纸面板拉伸成 HUD、状态条或底部草坪。 - 关联文档:`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`、`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`。 diff --git a/.hermes/shared-memory/development-workflow.md b/.hermes/shared-memory/development-workflow.md index 49bff2ef..f1268ce4 100644 --- a/.hermes/shared-memory/development-workflow.md +++ b/.hermes/shared-memory/development-workflow.md @@ -195,6 +195,13 @@ npm run check:server-rs-ddd - `docs/technical/SPACETIMEDB_TABLE_CATALOG.md` - `docs/technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md` +## 生产压测与观测默认口径 + +- 作品列表 50 HTTP req/s 压测使用 `scripts/loadtest/README.md` 中的 K6 命令;当前脚本一次 iteration 请求两个公开列表接口,因此目标 50 HTTP req/s 对应 `PEAK_RPS=25`。 +- 生产 `api-server` 默认 backlog、worker threads、HTTP 并发背压、systemd 限制、Nginx upstream timing log 和 OTLP 开关以 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` 为准。 +- OpenTelemetry 现阶段可选发送 traces / metrics / logs,但不会取代本地 `journalctl -u genarrative-api.service`、`logs/api-server/` 与 `/var/log/nginx/genarrative.*.log`。 +- 指标 label 不写 raw URI、userId、profileId 或 request_id;request_id 只用于 trace/log 串联。 + ## 前端相关默认验证 前端修改后,应根据修改范围选择: diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index f80c0903..c62d83a2 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 兼容 @@ -83,6 +83,54 @@ - 验证:运行仓库已有编码检查;人工抽查修改文件中的中文内容。 - 关联:`AGENTS.md`、`npm run check:encoding`。 +## SpacetimeDB 运行态查询不要绕过已有索引或用 procedure JSON 回传 + +- 现象:运行态接口看起来只查当前用户、作品或任务,却在 `spacetime-module` 中使用 `ctx.db.().iter().filter(...)` 整表遍历;或者 procedure result 返回 `items_json/run_json/work_json` 等 JSON 字符串,`spacetime-client` mapper 再反序列化成旧兼容结构。 +- 原因:新增索引或 typed snapshot 后,没有同步清理旧 mapper / 测试兼容层,也没有用静态检查拦截回退写法。 +- 处理:表上已有主键、unique 或 `#[index]` 覆盖查询前缀时,先用对应 accessor `.find(...)` / `.filter(...)`,只对索引无法覆盖的条件做内存残余过滤;procedure result 返回 typed snapshot / typed value,不再跨层传 `*_json: Option` 作为 payload。 +- 验证:执行 `npm run check:spacetime-runtime-access`、`npm run check:server-rs-ddd`,涉及绑定变化时先执行 `npm run spacetime:generate` 和 `npm run check:spacetime-schema`。 +- 关联:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`scripts/check-spacetime-runtime-access.mjs`、`server-rs/crates/spacetime-module/src/*`、`server-rs/crates/spacetime-client/src/mapper.rs`。 + +## 拼图广场列表不要每次 HTTP 请求调用 SpacetimeDB procedure + +- 现象:`/api/runtime/puzzle/gallery` 每个请求都走 `spacetime-client.list_puzzle_gallery()` 调用 SpacetimeDB procedure,导致 SpacetimeDB WASM 侧重复组装全量列表,客户端再映射一遍;历史实现还出现过 procedure JSON 字符串往返。 +- 原因:`api-server` 的服务器端 `spacetime-client` 没有订阅可公开读取的 gallery 投影,虽然 SDK 支持 client cache,但请求路径仍把列表读取当作 procedure 调用。 +- 处理:`spacetime-module` 中用 public view `puzzle_gallery_card_view` 暴露已发布拼图作品的列表卡片字段,不携带 `levels` / `anchor_pack` 等详情级载荷;`spacetime-client` 建连接后订阅 `SELECT * FROM puzzle_gallery_card_view` 和 `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle'` 并等待 `on_applied`。HTTP gallery 通过 `PuzzleGalleryCache` 缓存最终 `PuzzleGalleryResponse` DTO:`items` 返回前 10 个完整卡片,`previewRefs` 返回后 10 个作品号引用,cache miss / TTL 过期时单飞重建,后台 cleanup task 周期清理旧响应。旧 `list_puzzle_gallery` procedure 只作兼容,不再作为 HTTP gallery 主路径。 +- 验证:搜索 `server-rs/crates/spacetime-client/src/puzzle.rs` 不应再出现 gallery 主路径调用 `list_puzzle_gallery_then`;搜索 `server-rs/crates/spacetime-client/src/lib.rs` 应订阅 `puzzle_gallery_card_view`;执行 `npm run spacetime:generate`、`cargo check --manifest-path server-rs/Cargo.toml -p spacetime-client`、`cargo check --manifest-path server-rs/Cargo.toml -p api-server` 和 schema/runtime access 检查。 +- 关联:`server-rs/crates/spacetime-module/src/puzzle.rs`、`server-rs/crates/spacetime-client/src/lib.rs`、`server-rs/crates/spacetime-client/src/puzzle.rs`、`server-rs/crates/api-server/src/puzzle_gallery_cache.rs`、`/api/runtime/puzzle/gallery`。 + +## Windows 本地直连高 VU 压测不要误判成业务内存泄漏 + +- 现象:本地 Windows release `api-server` 直连 K6 压测时,250 RPS、`PREALLOCATED_VUS=300` 能把进程 private memory 瞬时推到约 7GB;同样配置打 `/healthz` 小响应也能复现,压测结束后回落到 100MB 级。 +- 原因:高水位主要来自本机直连的 K6 VU / 长连接 / Hyper 发送链路和 Windows 连接缓冲,不是 SpacetimeDB procedure、拼图 JSON 缓存或 OTEL exporter。降低到接近真实并发的 VU 后,同样 250 RPS 拼图广场 p95 约 9ms,峰值约 600MB。 +- 处理:本地容量判断时让 `PREALLOCATED_VUS` / `MAX_VUS` 接近真实并发,不要把过高 VU 预分配当作默认吞吐测试;同时观察 `process.memory.*`、`process.windows.handle.count`、`genarrative.http.server.response_bodies.in_flight`、`genarrative.http.server.request_permits.available`、`genarrative.puzzle_gallery.cache.*` 和 `genarrative.spacetime.read.*`。如果内存高但 body in-flight、背压 permit、cache rebuild 和 SpacetimeDB read 都不显示积压,优先按连接 / 发送链路高水位处理。 +- 验证:对照打 `/api/runtime/puzzle/gallery` 与 `/healthz`;对比 `PREALLOCATED_VUS=300 MAX_VUS=800` 和 `PREALLOCATED_VUS=20 MAX_VUS=40`;压测结束后继续采样 10 秒确认 private memory 回落。 +- 关联:`scripts/loadtest/README.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`server-rs/crates/api-server/src/process_metrics.rs`、`server-rs/crates/api-server/src/telemetry.rs`。 + +## 多玩法公开广场列表优先订阅 public view / read model + +- 现象:抓大鹅、方洞挑战、视觉小说、大鱼吃小鱼等公开列表如果沿用 `list_*_works` procedure,即使只读已发布作品,也会在每个 HTTP 请求里回到 SpacetimeDB WASM 侧扫描、反序列化配置并组装列表,50RPS 以上容易变成热点。 +- 原因:个人作品列表和公开广场列表复用了同一套 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.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 + +- 现象:`/api/runtime/custom-world-gallery` 每次请求调用 `list_custom_world_gallery_entries` procedure;入口熔断中间件每个玩法请求调用 `get_creation_entry_config` procedure,50RPS 以上会把 SpacetimeDB procedure 调用变成热点。 +- 原因:`custom_world_gallery_entry`、`creation_entry_config` 和 `creation_entry_type_config` 已经是可订阅读模型或配置表,但 HTTP 路径仍按“请求到来再查 procedure”处理。 +- 处理:`spacetime-client` 长连接订阅 `custom_world_gallery_entry`、`public_work_play_daily_stat` 的 `custom-world` 桶、`creation_entry_config` 和 `creation_entry_type_config`;custom-world gallery 从本地 cache 排序并聚合 7 日播放数;入口配置优先读订阅 cache,cache 缺失时用最近一次成功内存快照,再兜底调用 `get_creation_entry_config` 完成旧库兼容。旧 `list_custom_world_gallery_entries` procedure 只允许作为旧库缺少 gallery 行时的一次性同步兜底。 +- 验证:搜索 `server-rs/crates/spacetime-client/src/custom_world.rs`,gallery 主路径应是 `read_after_connect` 读取 `custom_world_gallery_entry()`;搜索 `server-rs/crates/spacetime-client/src/runtime.rs`,`get_creation_entry_config` 应优先读取 `creation_entry_config()` 和 `creation_entry_type_config()`。执行 `cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`。 +- 关联:`server-rs/crates/spacetime-client/src/lib.rs`、`server-rs/crates/spacetime-client/src/custom_world.rs`、`server-rs/crates/spacetime-client/src/runtime.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + +## 陶泥儿 logo 生图慢请求先缩短 prompt 并单张串行 + +- 现象:使用 VectorEngine `gpt-image-2-all` 生成陶泥儿 logo 概念图时,部分 prompt 会超过 10 分钟仍无响应,或返回 `429` / `当前分组上游负载已饱和`;同一批次里后续图片会被前面的慢请求拖住。 +- 原因:复杂抽象 logo prompt 同时包含品牌解释、禁用元素、中文结构和多重隐喻时,上游排队与生成时长不稳定;并发或批量运行会放大单条慢请求的影响。 +- 处理:先 `--dry-run` 看请求体;真实生成时优先短 prompt、单一造型、单张串行或小批量。失败后不要反复重试同一长 prompt,先压缩到“一个主体 + 一个负形 + 颜色 + 禁用文字/播放键/聊天气泡”再跑。联系表中的中文标签不要通过 PowerShell 管道内联 Python 写入,容易因编码链路显示为问号,可改用英文标签或脚本文件方式。 +- 验证:生成文件落在 `public/branding/taonier-logo-*/`,用 Pillow 检查图片尺寸和非空;执行 `node --check scripts/generate-taonier-logo-concepts.mjs`、`npm run check:encoding`、`git diff --check`。 +- 关联:`scripts/generate-taonier-logo-concepts.mjs`、`docs/design/TAONIER_BRAND_LOGO_CONCEPTS_2026-05-13.md`。 + ## 忘记密码后仍提示手机号或密码错误先查认证快照同步 - 现象:用户通过“忘记密码”重设密码后,接口返回成功或页面进入登录态,但再次使用新密码登录仍提示“手机号或密码错误”;重启后还可能出现 `Bearer JWT 版本已失效`,日志里的 token version 与本地快照不一致。 @@ -144,10 +192,10 @@ ## 宝贝识物选篮误触发先查多套判定和残余轨迹 - 现象:`宝贝识物` 运行态打开礼物盒或反馈结束后,当前物品被连续送入左侧或右侧篮子,或硬件动作名偶发命中导致未做明确横移动作也触发选篮。 -- 原因:选篮如果同时消费 `wave_left_hand` / `wave_right_hand` / `wave` 动作名和手部轨迹,或在 `correct` / `wrong` 反馈阶段继续累计手部路径,会把抓握、反馈期间残留移动或未知侧别手部误算成下一次选篮。 -- 处理:宝贝识物选篮只使用明确 `leftHand` / `rightHand` 的连续横向轨迹阈值;侧别为 `unknown` 的手部轨迹不参与选篮;反馈阶段清空轨迹,不在非 `active` 阶段累计路径。进入关卡和每次正确反馈结束后自动弹出物品,不再用 `open_palm -> grab` 抓握序列激活礼物盒。 -- 补充:当前本地 mocap 的 handedness 是摄像头视角,宝贝识物选篮前需要换算为用户身体视角;`rightHand` 轨迹代表玩家左手并进入左篮,`leftHand` 轨迹代表玩家右手并进入右篮。键鼠调试不走该换算,仍保持鼠标左键=左篮、右键=右篮。 -- 验证:运行 `npm run test -- src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx src/services/useMocapInput.test.ts`,确认动作名负向测试、未知侧别负向测试和左右手横向轨迹测试通过。 +- 原因:选篮如果同时消费 `wave_left_hand` / `wave_right_hand` / `wave` 动作名、连续横向轨迹和左右手固定篮子规则,或在 `correct` / `wrong` 反馈阶段继续累计手部状态,会把反馈期间残留移动或未知侧别手部误算成下一次选篮。 +- 处理:宝贝识物当前选篮只允许“手先触碰中央物品 UI,物品绑定到该手,随后拖入左侧或右侧篮子区域”这一套路径;侧别为 `unknown` 的手部不参与抓取或选篮;反馈阶段清空持有状态,不在非 `active` 阶段累计输入。进入关卡和每次正确反馈结束后自动弹出物品,不再用 `open_palm -> grab` 抓握序列激活礼物盒。 +- 补充:当前本地 mocap 的 handedness 是摄像头视角,宝贝识物仍需换算为用户身体视角以展示左右手:`rightHand` 坐标代表玩家左手,`leftHand` 坐标代表玩家右手。换算不再决定只能选择哪侧篮子;任意一只手都可以拖物品到任意篮子。键鼠调试保持鼠标左键=左手位置、右键=右手位置,也必须先触碰中央物品再拖入篮子。 +- 验证:运行 `npm run test -- src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx src/services/useMocapInput.test.ts`,确认动作名负向测试、未知侧别负向测试、触碰前不能选篮和任意手拖入任意篮子用例通过。 - 关联:`src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx`、`docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`。 ## 宝贝爱画左右手反了先查 mocap 摄像头视角换算 @@ -161,11 +209,27 @@ ## 宝贝识物创作卡在准备结果页先查长耗时 image-2 请求 - 现象:`/creation/baby-object-match` 创作生成停在“准备结果页”,约 3 分钟后显示“生成失败 / 请求超时”;后端日志可能出现同一路由 `status=502 latency_ms=231291`,或前端已失败但后端稍后返回 200。 -- 原因:宝贝识物一次创作会生成 2 张物品图和 `background`、`ui-frame`、`gift-box`、`basket`、`smoke-puff` 5 张视觉包装图。旧前端只等待 180 秒并对长耗时 POST 自动重试,容易在 VectorEngine 仍在生成时先 abort,再重复发起第二次生成;上游某张图超过后端 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 或返回 5xx 时会表现为 502。 -- 处理:`babyObjectMatchClient` 对 `/api/creation/edutainment/baby-object-match/assets` 使用 10 分钟超时并取消自动重试;后端并发启动物品图和视觉主题包生成,并把该路由的 VectorEngine 单图请求等待预算提升到至少 8 分钟,按资源类别输出开始、完成和耗时日志。 -- 验证:运行 `npm run test -- src/services/edutainment-baby-object/babyObjectMatchClient.test.ts src/services/miniGameDraftGenerationProgress.test.ts`、`cargo test -p api-server edutainment_baby_object --manifest-path server-rs/Cargo.toml` 和编码检查;真实联调时查看 `宝贝识物 image-2 资源生成完成` 耗时是否小于前端超时,若仍 502 再看 `VectorEngine 图片生成上游错误` 的 `upstreamStatus/raw_excerpt`。 +- 原因:宝贝识物创作属于长耗时 image-2 链路。旧前端只等待 180 秒并对长耗时 POST 自动重试,容易在 VectorEngine 仍在生成时先 abort,再重复发起第二次生成;上游某张图超过后端 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 或返回 5xx 时会表现为 502。2026-05-14 后,新链路已从“2 张物品图 + 5 张视觉包装图”收敛为“1 张 `2x2` 素材 sheet + 1 张场景背景图”,左右手位置指示器改为运行态默认静态素材,不再每次创作生成,但仍需要按长耗时链路排查。 +- 处理:`babyObjectMatchClient` 对 `/api/creation/edutainment/baby-object-match/assets` 使用 10 分钟超时并取消自动重试;后端并发启动 `2x2` 素材 sheet 和场景背景生成,并把该路由的 VectorEngine 单图请求等待预算提升到至少 8 分钟,按资源类别输出开始、完成和耗时日志。`2x2` sheet 固定包含物品 A、物品 B、篮子和礼物盒,服务端按格切图并转透明 PNG;`ui-frame` / `smoke-puff` / `left-hand` / `right-hand` 不再作为新生成必需资源。 +- 验证:运行 `npm run test -- src/services/edutainment-baby-object/babyObjectMatchClient.test.ts src/services/miniGameDraftGenerationProgress.test.ts`、`cargo test -p api-server edutainment_baby_object --manifest-path server-rs/Cargo.toml` 和编码检查;真实联调时查看 `宝贝识物 image-2 2x2 素材 sheet 生成完成`、`宝贝识物 image-2 场景资源生成完成` 和整体 `宝贝识物 image-2 资源生成完成` 耗时是否小于前端超时,若仍 502 再看 `VectorEngine 图片生成上游错误` 的 `upstreamStatus/raw_excerpt`。 - 关联:`src/services/edutainment-baby-object/babyObjectMatchClient.ts`、`src/services/miniGameDraftGenerationProgress.ts`、`server-rs/crates/api-server/src/edutainment_baby_object.rs`、`docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`。 +## 宝贝识物篮子手柄白底先查 sheet 切图后处理 + +- 现象:`宝贝识物` 新生成的主题篮子在左右手柄、篮口镂空或边缘处仍出现白底块或白色毛边,尤其是 2x2 sheet 背景被抠透明后,封闭镂空区域可能没有被通用边缘连通抠图清理掉。 +- 原因:宝贝识物为了降低 image-2 成本,把物品 A、物品 B、篮子和礼物盒放在同一张 `2x2` sheet。通用背景透明处理主要从单格边缘连通背景开始,封闭在篮子手柄内部的近白区域不一定与边缘连通,因此会残留;如果把强力近白清理应用到物品格,又可能误伤白色物品主体。 +- 处理:后端 `slice_baby_object_match_sheet` 只在 `BabyObjectMatchSheetSlot::Basket` 编码前执行近白、低饱和 matte 清理;物品格和礼物盒格继续只走通用背景透明处理。sheet prompt 同步要求篮子手柄和篮口镂空处不要留下白底描边或毛边。运行态左右篮子的物品图标和名称 UI 以篮子中心线对齐,避免素材放大后看起来偏移。 +- 验证:运行 `cargo test -p api-server edutainment_baby_object --manifest-path server-rs/Cargo.toml` 与 `npm run test -- src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx`;真实联调需要重新生成宝贝识物资源,旧草稿中已保存的 base64 篮子图不会自动被新后处理改写。 +- 关联:`server-rs/crates/api-server/src/edutainment_baby_object.rs`、`src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx`、`src/index.css`、`docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`。 + +## 宝贝识物物品框被长条素材拉伸先查固定槽位 + +- 现象:用户用手机、筷子等长条关键词生成素材后,中央物品 UI 或篮子上方物品图标看起来被拉成长框,圆形 UI 失去固定比例。 +- 原因:运行态如果让图片固有宽高或外层自适应内容,就会把长条透明 PNG 的主体比例传导到 UI 容器。 +- 处理:中央物品 UI 和篮子物品图标都必须使用固定正方形槽位,外层尺寸由 CSS 变量控制;生成素材图片只在槽位内 `object-fit: contain` 等比缩放,不改变外层圆形 UI 框尺寸。 +- 验证:用长条物品草稿进入宝贝识物运行态,中央物品框和篮子图标框仍为正圆,长条主体在框内缩小显示。 +- 关联:`src/index.css`、`src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx`、`docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`。 + ## 寓教于乐作品和宝贝识物模板同时消失先查入口种子 - 现象:发现页“寓教于乐”分类下已发布的宝贝识物作品突然消失,同时创作界面模板选项中也看不到或无法正常展示 `宝贝识物`。 @@ -186,10 +250,18 @@ - 现象:`/child-motion-demo` 背景风格正确,但底部草坪被拉成厚色块、顶部 HUD 或右下状态条像方形面板被横向拉伸,或旧 `picture-book-ui-panel.png` 与新资源叠在一起。 - 原因:早期资源中 `picture-book-ui-panel.png` 是接近方形画布,`picture-book-grass-floor.png` 也含大量透明边界;若 CSS 用 `background-size: 100% 100%` 把同一资源强行铺成 HUD、状态条、开始面板或底部地板,就会出现变形和层叠观感。 -- 处理:使用 v2 用途专属资源:`picture-book-foreground-grass-v2.png`、`picture-book-ground-ring-v2.png`、`picture-book-character-outline-v2.png`、`picture-book-hud-strip-v2.png`、`picture-book-calibration-strip-v2.png`、`picture-book-start-panel-v2.png`、`picture-book-ui-button-v2.png`;CSS 按资源比例等比缩放,底部草坪只覆盖下沿,HUD / 状态条 / 开始托盘分别引用各自资源。若只需修透明裁切或品红边,运行 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only `,不重新请求 image-2。 +- 处理:使用用途专属资源:`picture-book-foreground-grass-v2.png`、`picture-book-ground-ring-v3.png`、`picture-book-character-outline-v4.png`、`picture-book-hud-strip-v2.png`、`picture-book-calibration-strip-v2.png`、`picture-book-start-panel-v2.png`、`picture-book-ui-button-v2.png`;CSS 按资源比例等比缩放,底部草坪只覆盖下沿,HUD / 状态条 / 开始托盘分别引用各自资源。角色指示器使用 v4 更细白色描边资源,内部透明且显示尺寸相对上一版放大 50%;若只需修透明裁切、品红边或纯描边后处理,运行 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only `,不重新请求 image-2。 - 验证:用横屏截图检查没有新旧资源叠加、没有方形面板拉成长条、角色和地面指示环不被前景草坪埋住;同时运行 `npm run check:encoding`。 - 关联:`scripts/generate-child-motion-demo-assets.mjs`、`src/index.css`、`public/child-motion-demo/`、`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`。 +## 儿童动作 Demo 猫咪挥手拆件错位先查动画父级和肩部挂点 + +- 现象:`/child-motion-demo` 打个招呼阶段的猫咪图和风格正确,但挥手时左右手臂像漂浮在身体旁边,视频里能看到肢体没有稳定接在肩膀上。 +- 原因:猫咪身体和手臂如果分别做上下浮动,或手臂使用透明方形画布的默认中心/底部旋转轴,就会在摆动极值时放大肩点偏差;镜像左臂还需要把资源内部连接点换算到镜像后的坐标。 +- 处理:`.child-motion-gesture-guide__wave-cat` 父级统一承接 bob 动画,身体层保持静态贴底且层级低于手臂;左右手臂作为同一父级下的兄弟层,只做旋转动画并显示在身体前方。身体使用去掉左右小圆点的 `picture-book-wave-cat-body-guide-v7.png`;手臂 v7 资源当前按身体外缘摆放,圆猫爪掌面朝向玩家;左右侧距为 `12%`,左臂使用原图层与 `60% 78%` 旋转轴,右臂使用镜像图层与 `40% 78%` 旋转轴,动画周期为 `0.47s`,左右手臂不设置错峰延迟;不要把 `scaleX(...)` 和 rotate 放在同一个手臂 wrapper 上。 +- 验证:用用户录屏关键帧或离线合成预览检查摆动两端的手臂根部仍贴住肩点;再运行儿童动作 Demo 定向组件测试、ESLint 和 `npm run check:encoding`。 +- 关联:`src/index.css`、`public/child-motion-demo/picture-book-wave-cat-body-guide-v7.png`、`public/child-motion-demo/picture-book-wave-cat-arm-guide-v7.png`、`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`。 + ## GPT-image-2 不再读 APIMart 图片配置 - 现象:配置了 `APIMART_BASE_URL` / `APIMART_API_KEY` 后,RPG、拼图或方洞的 GPT-image-2 生图仍返回缺配置,或请求体里还出现 `official_fallback` / `image_urls`。 @@ -358,6 +430,14 @@ - 验证:请求返回 JSON,相关页面不再出现 HTML parse 错误。 - 关联:`docs/technical/PROFILE_MAIN_ROUTE_VITE_PROXY_FIX_2026-05-02.md`。 +## `npm run build` 因 Vite warning 被 build-gate 判失败 + +- 现象:主站或后台 Vite 已经输出 `built in ...`,但根命令最后仍失败并打印 `Build gate failed because warnings were emitted`。 +- 原因:`scripts/build-gate.mjs` 会收集 stdout / stderr 中的 warning 行并作为硬失败;常见触发是产物 chunk 超过 `vite.config.ts` 或 `apps/admin-web/vite.config.ts` 的 `chunkSizeWarningLimit`。 +- 处理:先看 warning 原文确认来源。若是合理的入口级 chunk 体积增长,调整对应 Vite 配置阈值或做真实拆包;不要把这类失败按 Rust / SpacetimeDB 编译错误排查。 +- 验证:重新执行 `npm run build`,主站与后台均构建完成且没有 build-gate warning 汇总。 +- 关联:`scripts/build-gate.mjs`、`vite.config.ts`、`apps/admin-web/vite.config.ts`。 + ## 反馈页清空 file input 前必须先拷贝 FileList - 现象:点击上传凭证会打开文件选择框,但选择图片后页面没有展示预览,提交时也没有携带图片凭证。 @@ -378,8 +458,8 @@ - 现象:`POST /api/assets/hyper3d/text-to-model` 在本地返回 503,详情里提示 `HYPER3D_API_KEY 未配置`,但开发者明明已经在本地私密文件里写了 key。 - 原因:`scripts/dev-utils.mjs` 之前按 `.env.secrets.local → .env.local → .env` 合并,结果仓库里的 `.env` 空示例值会把前面已经设置好的私密 key 覆盖掉。 -- 处理:`npm run dev:api-server` / `npm run dev:spacetime` / `npm run dev` 统一按“外层 shell 变量优先,其后 `.env`、`.env.local`、`.env.secrets.local` 逐层覆盖”的顺序加载;真实密钥优先放 `.env.secrets.local`。 -- 验证:本地加入临时测试后,`HYPER3D_API_KEY` 应能被 `.env.secrets.local` 覆盖,且 shell 变量仍然最高优先级。 +- 处理:`npm run dev:api-server` / `npm run dev:spacetime` / `npm run dev` 统一按“外层 shell 变量优先,其后 `.env`、`.env.local`、`.env.secrets.local` 逐层覆盖”的顺序加载;真实密钥优先放 `.env.secrets.local`。本地认证开关例外:`SMS_AUTH_ENABLED`、`SMS_AUTH_PROVIDER` 等以本地 env 文件为准,避免父进程继承的旧开关值长期压过 `.env.local`。 +- 验证:本地加入临时测试后,`HYPER3D_API_KEY` 应能被 `.env.secrets.local` 覆盖,真实密钥 shell 变量仍然最高优先级;`mergeApiServerEnv(..., { SMS_AUTH_ENABLED: "false" })` 在 `.env.local` 写 `SMS_AUTH_ENABLED=true` 时应返回 true。 - 关联:`scripts/dev-utils.mjs`、`server-rs/crates/api-server/src/hyper3d_generation.rs`、`docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md`。 ## OSS 密钥键名不要把字母 O 写成数字 0 @@ -408,28 +488,28 @@ ## 本地短信登录页签突然消失 - 现象:登录弹窗只剩密码登录,短信登录页签看起来像被删掉,但 `LoginScreen` 中手机号验证码表单仍存在。 -- 原因:前端根据 `GET /api/auth/login-options` 返回的 `availableLoginMethods` 渲染页签;常见根因有两类: +- 原因:历史实现曾根据 `GET /api/auth/login-options` 返回的 `availableLoginMethods` 渲染页签;接口返回空、失败或只返回 `["password"]` 时,`AuthGate` 会降级成只显示密码。 - 本地启动脚本没有让 `.env.local` 覆盖 `.env`,`SMS_AUTH_ENABLED=true` 不生效,后端只返回 `["password"]`。 - Rust API 直连已返回 `["phone","password"]`,但 Vite 代理目标指向未监听端口,导致 3000 域名下的 `login-options` 返回 `500`,`AuthGate` 降级成 `["password"]`。 - 3000 端口被旧 `dev:web` 占用后,新的完整栈 Vite 自动漂移到 3001/3002;浏览器仍打开旧 3000 页面,旧页面继续代理到已经下线的端口。 - 生成页 UI 改动看起来“完全没变化”时,也要先确认当前浏览器打开的 Vite 进程正在返回最新源码;例如直接请求 `http://127.0.0.1:3000/src/components/CustomWorldGenerationView.tsx` 检查是否包含本次新增类名或关键字。 - 单独 `npm run dev:web` 启动瞬间另一个临时 API 端口可用,脚本若自动切过去,之后临时 API 停掉也会让 3000 继续代理到空端口。 -- 处理:优先用 `npm run dev:api-server`、`npm run dev:spacetime` 或 `npm run dev` 启动,这些入口应保持 shell 环境变量最高优先级,并允许 `.env.local` 覆盖 `.env`;完整栈启动时还要确保脚本计算出的 `RUST_SERVER_TARGET` 不被 `.env.local` 里的旧值覆盖。排查时先请求 3000 域名下的 `/api/auth/login-options`,再直连 Rust API 目标,并核对 `.env.local` 的 `SMS_AUTH_ENABLED` 与代理端口;若 3001/3002 才返回正确结果,说明当前 3000 是旧前端进程,应清理旧进程后重启。 -- 验证:`http://127.0.0.1:3000/api/auth/login-options` 返回至少 `{"availableLoginMethods":["phone","password"]}` 后,登录弹窗会恢复短信登录页签和“获取验证码”按钮。 -- 关联:`scripts/dev-utils.mjs`、`scripts/dev.mjs`、`docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md`。 +- 处理:当前口径是登录弹窗永远展示 `短信登录` 与 `密码登录` 两个核心入口;`login-options` 只补充微信等环境相关入口,不能隐藏短信或密码页签。如果“获取验证码”点击后失败,再按短信 provider / API 代理问题排查:优先用 `npm run dev:api-server`、`npm run dev:spacetime` 或 `npm run dev` 启动,确认 `.env.local` 覆盖 `.env`、`RUST_SERVER_TARGET` 没有指向旧端口,并分别请求 3000 域名和 Rust API 目标。 +- 验证:即使 `/api/auth/login-options` 返回空、失败或只返回 `["password"]`,登录弹窗也应同时显示 `短信登录`、`密码登录`、`验证码` 输入和“获取验证码”按钮;短信发送真实可用性再通过 `POST /api/auth/phone/send-code` 验证。 +- 关联:`src/components/auth/AuthGate.tsx`、`src/components/auth/LoginScreen.tsx`、`src/components/auth/AuthGate.test.tsx`、`scripts/dev-utils.mjs`、`scripts/dev.mjs`。 ## 本地短信收不到验证码先查 provider - 现象:登录弹窗可以进入短信页签,但点击“获取验证码”后,手机没有收到短信。 -- 原因:本地 `.env.local` 里如果是 `SMS_AUTH_PROVIDER="mock"`,后端不会发真实短信,只会返回固定 mock 验证码;另外 `npm run dev:api-server` 过去曾让 `.env` 覆盖 `.env.local`,导致本地真实短信配置被错误压回默认值。 -- 处理:真实短信联调时把 `.env.local` 的 `SMS_AUTH_PROVIDER` 显式设为 `aliyun`,然后重启 `api-server`;如果只想验证 UI 和账号链路,则保留 `mock` 并使用 `SMS_AUTH_MOCK_VERIFY_CODE`。 -- 验证:`GET /api/auth/login-options` 返回 `["phone","password"]`,`api-server` 日志里 `provider=aliyun` 才说明真实短信链路已生效。 -- 关联:`scripts/dev-utils.mjs`、`docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md`、`docs/technical/PHONE_SMS_REAL_PROVIDER_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md`。 +- 原因:本地 `.env.local` 里如果是 `SMS_AUTH_PROVIDER="mock"`,后端不会发真实短信,只会返回固定 mock 验证码;真实阿里云链路已经改为普通短信 `SendSms`,验证码由当前 `api-server` 进程本地生成、哈希存储和校验,旧 `SendSmsVerifyCode` / `CheckSmsVerifyCode` 托管验证码参数不再参与真实校验。若接口直接返回“手机号登录暂未启用”,说明当前运行中的 `api-server` 进程内 `sms_auth_enabled=false`:常见原因是修改 `.env.local` 后没有重启后端,或外层 shell 已经设置了非空 `SMS_AUTH_ENABLED` 导致 dotenv 不覆盖。历史上 cmd 里 `set SMS_AUTH_ENABLED="true"` 会把引号也传进进程,Rust bool 解析失败后保持默认 false。 +- 处理:真实短信联调时把 `.env.local` 的 `SMS_AUTH_ENABLED=true`、`SMS_AUTH_PROVIDER=aliyun` 显式打开,并确认 `ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com`、`ALIYUN_SMS_SIGN_NAME=北京亓盒网络科技`、`ALIYUN_SMS_TEMPLATE_CODE=SMS_506245486`、`ALIYUN_SMS_TEMPLATE_PARAM_KEY=code` 后重启 `api-server`;如果只想验证 UI 和账号链路,则保留 `mock` 并使用 `SMS_AUTH_MOCK_VERIFY_CODE`。Shell 临时覆盖时 PowerShell 用 `$env:SMS_AUTH_ENABLED="true"`,cmd 用 `set SMS_AUTH_ENABLED=true`,不要把引号作为值的一部分。`api-server` 重启会清掉未校验的本地验证码。 +- 验证:分别请求浏览器域名和 Rust API 直连的 `/api/auth/login-options`,都应返回 `["phone","password"]`;`api-server` 日志里 `provider=aliyun` 才说明真实短信链路已生效。需要直接确认平台层真实调用阿里云时,配置 `ALIYUN_SMS_ACCESS_KEY_ID`、`ALIYUN_SMS_ACCESS_KEY_SECRET` 和 `ALIYUN_SMS_REAL_TEST_PHONE_NUMBER` 后手动执行 `cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms_real_provider_sends_verify_code -- --ignored --nocapture`。 +- 关联:`server-rs/crates/api-server/src/config.rs`、`scripts/dev-utils.mjs`、`docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md`、`docs/technical/PHONE_SMS_REAL_PROVIDER_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md`。 ## 手机验证码登录 500 先查短信 provider 语义 - 现象:登录弹窗手机号验证码登录失败,浏览器看到 `POST /api/auth/phone/login 500`,后端日志里同时出现阿里云短信 `UNKNOWN`、`biz.FREQUENCY` 或 `check frequency failed`。 -- 原因:真实短信 provider 的配置错误或上游失败曾被 `module-auth` 折叠成 `PhoneAuthError::Store`,HTTP 层只能按内部错误返回 `500`,掩盖了 provider 失败。 +- 原因:真实短信 provider 的配置错误或上游失败曾被 `module-auth` 折叠成 `PhoneAuthError::Store`,HTTP 层只能按内部错误返回 `500`,掩盖了 provider 失败。当前验证码校验已经改成本地哈希校验,登录阶段的验证码错误不会再调用阿里云校验接口;若登录前的发送阶段失败,应优先看 `SendSms` 返回的 `Code/Message`。 - 处理:保留 provider 错误语义,配置错误映射 `503 Service Unavailable`,上游短信失败映射 `502 Bad Gateway`;本地只验证 UI/账号链路时可用 shell 临时覆盖 `SMS_AUTH_PROVIDER=mock` 后启动 `npm run dev:api-server`。 - 验证:`cargo test -p api-server phone_auth_sms_provider_errors_keep_upstream_http_semantics --manifest-path server-rs/Cargo.toml`,真实 provider 频控时接口不再返回 `500`。 - 关联:`server-rs/crates/module-auth/src/errors.rs`、`server-rs/crates/api-server/src/phone_auth.rs`、`docs/technical/PHONE_SMS_PROVIDER_ERROR_HTTP_MAPPING_FIX_2026-05-08.md`。 @@ -766,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素材` 当编号剥掉 @@ -863,3 +943,27 @@ - 处理:导入 / 导出流水线在调用迁移脚本前先 `source scripts/jenkins-prepare-toolchain-env.sh`;该脚本会把 `GENARRATIVE_JENKINS_TOOL_PATHS`、`/var/lib/jenkins/.nvm/versions/node/v22.22.2/bin`、`/var/lib/jenkins/.cargo/bin`、`/var/lib/jenkins/.local/bin` 和系统 PATH 前缀统一补齐,并在缺少 `node` 时尽早报错。 - 验证:重新跑 `Genarrative-Database-Import` 或 `Genarrative-Database-Export`,日志应先打印 `jenkins-toolchain` 的 `node=...` 解析结果,而不是在迁移中途报 `node: command not found`。 - 关联:`scripts/jenkins-prepare-toolchain-env.sh`、`jenkins/Jenkinsfile.production-database-import`、`jenkins/Jenkinsfile.production-database-export`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + +## Windows Jenkins `powershell` step 在 Stdb module 构建里曾触发 CreateProcess error=5 + +- 现象:`Genarrative-Stdb-Module-Build` 在 Windows Jenkins 节点上报 `java.io.IOException: Cannot run program "powershell" (in directory "C:\\Users\\DSK\\.jenkins-local\\workspace\\Genarrative-Stdb-Module-Build"): CreateProcess error=5, 拒绝访问。`;日志里能看到 `durable-task` 已写出 `powershellWrapper.ps1`,但在真正启动裸 `powershell` 子进程时失败。 +- 原因:Jenkins durable-task 的 `powershell` step 依赖一个隐式命令解析/启动路径,在这台 Windows 本地 Jenkins 环境里会被拒绝。`powershell.exe` 本体和 workspace ACL 都是正常的,问题出在 Jenkins step 的启动方式,而不是 PowerShell 脚本内容。修复后若日志能打印 `[jenkins-powershell] exe:`,但随后仅报 `拒绝访问` / `script returned exit code 5`,通常已经不是 PowerShell 启动失败,而是 Checkout 脚本内部命令在 Windows workspace 里触发权限拒绝。若 `.jenkins-*.ps1` 里中文 `throw '[stdb-build] ...'` 报 `MissingArrayIndexExpression`,则是 Windows PowerShell 5.1 用 `-File` 解析无 BOM UTF-8 脚本时按本地 ANSI 误解码。 +- 处理:把 `jenkins/Jenkinsfile.production-stdb-module-build` 的 `Checkout` 和 `Build Stdb Module` 两处 `powershell` step 收口成 `runWindowsPowerShell(...)` helper,先用 `writeFile` 写出临时 `.ps1`,再用显式 `powershell.exe` 把脚本重写成 UTF-8 with BOM,最后通过 `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ...` 执行。这个 helper 写在 Groovy GString 里时,PowerShell 的 `$path` / `$text` / `$true` 必须写成 `\$path` / `\$text` / `\$true`,否则 Jenkinsfile 会在 Groovy 编译阶段报 `unexpected token: true`。Checkout 阶段优先复用 Jenkins GitSCM 已完成的工作区结果;`COMMIT_HASH` 为空或已经等于当前 `HEAD` 时不再重复 `git fetch` / `git checkout` / `git clean`,只有确实要切到另一个指定 commit 时才补 fetch、归属校验和 checkout。 +- 验证:检查 Jenkins build log 中是否出现 `[jenkins-powershell] user:` 和 `[jenkins-powershell] exe:`,以及 `[stdb-checkout] current HEAD:`。上游 Full Build 传下来的 `COMMIT_HASH` 若已等于当前 GitSCM checkout,日志应显示 `requested commit already matches Jenkins GitSCM checkout` 并继续进入构建阶段;同时确认 `builds//log` 不再停在 `PipelineNodeTreeScanner... Cannot run program "powershell"` 或 Checkout 内部 exit code 5。 +- 关联:`jenkins/Jenkinsfile.production-stdb-module-build`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + +## QQ 浏览器发现页推荐封面全不显示先查 aspect-ratio 兜底 + +- 现象:发现页的“推荐”子频道作品卡标题、作者和数据正常,但所有封面图不显示,常见于 QQ 浏览器 / X5 等旧移动内核。 +- 原因:公开作品卡封面内部图片是绝对铺满,容器原本主要依赖 Tailwind `aspect-video` / CSS `aspect-ratio` 撑高;旧内核不支持或实现异常时封面容器高度会坍缩为 0。若封面还是 `/generated-*` 私有资源,换签失败后没有玩法参考图兜底时会进一步表现成黑卡。 +- 处理:`.platform-public-work-card__cover::before` 使用 `padding-top: 56.25%` 保留 16:9 高度,沉浸式卡片单独覆盖比例;公开作品卡通过 `resolvePlatformWorldFallbackCoverImage(...)` 给 `ResolvedAssetImage` 传入玩法参考图兜底,签名失败或图片加载失败时仍有可见封面。 +- 验证:`npm run test -- src/components/rpg-entry/rpgEntryWorldPresentation.test.ts src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`。 +- 关联:`src/index.css`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/rpg-entry/rpgEntryWorldPresentation.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 生成中草稿刷新后不要只恢复作品架遮罩 + +- 现象:拼图或抓大鹅草稿生成中刷新网页后,作品架卡片能显示等待遮罩,但点击卡片会走普通草稿恢复,可能进入空白结果页或未完成工作区。 +- 原因:前端只把内存 notice 当作“生成中点击恢复”的判断条件,没有把后端摘要里的 `generationStatus=generating` 纳入同一路径。 +- 处理:打开草稿时把持久化 `generationStatus=generating` 等同于生成中 notice,恢复对应玩法生成进度页;恢复计时使用作品摘要 `updatedAt` 推导 `startedAtMs`。 +- 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating"`。 +- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 diff --git a/.hermes/todos/【后端架构】前端直订阅公开作品列表准入待办-2026-05-16.md b/.hermes/todos/【后端架构】前端直订阅公开作品列表准入待办-2026-05-16.md new file mode 100644 index 00000000..943d90e3 --- /dev/null +++ b/.hermes/todos/【后端架构】前端直订阅公开作品列表准入待办-2026-05-16.md @@ -0,0 +1,27 @@ +# 前端直订阅公开作品列表准入待办 + +## 背景 + +未来可以考虑让前端直接订阅公开作品列表,以减少列表读取链路中的 HTTP 往返,并复用 SpacetimeDB 的实时同步能力。 + +## 当前结论 + +短期仍由 `api-server` / BFF 订阅 SpacetimeDB public read model,并从本地 cache 读取后对外提供 HTTP 列表接口。前端不直接订阅作品源表,也不把正式列表排序、分页、权限裁剪逻辑下放到 UI。 + +## 落地前置条件 + +- 建立专用、稳定、低基数的 public read model,例如 `public_work_gallery_entry`。 +- 明确权限边界,只暴露公开列表所需字段,不泄露作者私有信息、审核内部状态或运营字段。 +- 固化字段契约,明确字段含义、默认值、兼容策略和生成绑定更新流程。 +- 明确排序与分页语义,避免依赖自增 ID 顺序,优先使用时间戳或显式排序字段。 +- 补齐埋点方案,能区分直订阅首屏、增量更新、分页加载和 fallback 命中。 +- 保留 BFF HTTP fallback,用于低版本客户端、订阅失败、权限策略调整和灰度回滚。 +- 禁止前端订阅 `puzzle_work_profile`、`custom_world_profile` 等作品源表。 + +## 建议验收 + +- 文档确认直订阅只面向专用 public read model,不绕过 BFF 读取源表。 +- schema、绑定、字段契约、排序分页和权限说明同步更新。 +- 前端具备订阅失败后的 BFF HTTP fallback。 +- 自动测试覆盖公开字段裁剪、排序分页稳定性和 fallback 路径。 +- 监控可观察直订阅成功率、首屏耗时、增量更新延迟和 fallback 比例。 diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index f6906f2e..00000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -# 默认忽略的文件 -/shelf/ -/workspace.xml -# 基于编辑器的 HTTP 客户端请求 -/httpRequests/ -# 已忽略包含查询文件的默认文件夹 -/queries/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/.name b/.idea/.name deleted file mode 100644 index cf8f80f7..00000000 --- a/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -mod.rs \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml deleted file mode 100644 index 932f7d1b..00000000 --- a/.idea/codeStyles/Project.xml +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml deleted file mode 100644 index 79ee123c..00000000 --- a/.idea/codeStyles/codeStyleConfig.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/editor.xml b/.idea/editor.xml deleted file mode 100644 index ead1d8a3..00000000 --- a/.idea/editor.xml +++ /dev/null @@ -1,248 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 03d9549e..00000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 315bbf8a..00000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/prettier.xml b/.idea/prettier.xml deleted file mode 100644 index b0c1c68f..00000000 --- a/.idea/prettier.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1ddf..00000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/deploy/container/README.md b/deploy/container/README.md new file mode 100644 index 00000000..c9eb84c5 --- /dev/null +++ b/deploy/container/README.md @@ -0,0 +1,132 @@ +# Genarrative 容器化压测与隔离部署方案 + +本目录只服务本机或预发的容器化模拟压测,不替换当前生产 `systemd + Nginx + Jenkins` 发布路径。生产服务器仍以 `deploy/systemd/`、`deploy/nginx/`、`scripts/jenkins-*.sh` 和 `scripts/deploy/production-api-deploy.sh` 为准。 + +## 拓扑 + +```text +Docker Compose +├─ nginx :80 -> api-server:8082,负责静态站点、/admin/、/api/ 反代、upstream timing log、连接限制 +├─ api-server :8082,Linux release 构建,连接外部 SpacetimeDB +├─ otelcol :4317/4318,debug exporter,接收 traces / metrics / logs +└─ k6 profile=loadtest 时临时启动,在 compose 网络内压 nginx +``` + +默认 host 端口: + +- `http://127.0.0.1:18080`:容器 Nginx。 +- `127.0.0.1:4317` / `127.0.0.1:4318`:容器 Collector OTLP gRPC / HTTP。 + +如端口冲突,可设置: + +```powershell +$env:GENARRATIVE_CONTAINER_HTTP_PORT="18081" +$env:GENARRATIVE_CONTAINER_OTLP_HTTP_PORT="14318" +$env:GENARRATIVE_CONTAINER_OTLP_GRPC_PORT="14317" +``` + +## 初始化 + +```bash +npm run container:init +``` + +该命令会从 `deploy/container/api-server.env.example` 生成本地 `deploy/container/api-server.env`。真实 token、库名和外部服务密钥只写本地 env 文件,不提交 Git。 + +Docker Desktop 下默认通过 `host.docker.internal:3101` 连接宿主机上 `npm run dev` 启动的 SpacetimeDB: + +```env +GENARRATIVE_SPACETIME_SERVER_URL=http://host.docker.internal:3101 +GENARRATIVE_SPACETIME_DATABASE=genarrative-loadtest +GENARRATIVE_SPACETIME_TOKEN= +``` + +Linux Docker Engine 如果不能解析 `host.docker.internal`,Compose 已配置 `host-gateway`;仍不通时把 `GENARRATIVE_SPACETIME_SERVER_URL` 改成宿主机网关 IP 或同网络内的 SpacetimeDB 地址。 + +## 启动与验证 + +```bash +npm run container:config +npm run container:build +npm run container:up +npm run container:ps +curl -sS http://127.0.0.1:18080/api/runtime/puzzle/gallery +``` + +查看日志: + +```bash +npm run container:logs -- nginx +npm run container:logs -- api-server +npm run container:logs -- otelcol +``` + +`npm run container:config` 默认只校验配置,不打印完整 env。排查 compose 展开结果时可临时使用: + +```bash +npm run container:config -- --print +``` + +如果 `deploy/container/api-server.env` 已写入真实 token,不要把完整展开结果贴到公开渠道。 + +停止: + +```bash +npm run container:down +``` + +如需同时清理容器卷: + +```bash +npm run container:down -- -v +``` + +## 压测 + +k6 在 compose 网络内访问 `http://nginx`,避免 Windows 本机直连连接模型干扰 Linux 容器结果: + +```bash +npm run container:k6 +``` + +作品列表脚本一次 iteration 默认请求两个公开列表接口,因此目标 500 HTTP req/s 对应 `PEAK_RPS=250`: + +```powershell +$env:SCENARIO="spike" +$env:START_RPS="25" +$env:PEAK_RPS="250" +$env:HOLD="60s" +$env:END_RPS="25" +$env:PREALLOCATED_VUS="100" +$env:MAX_VUS="500" +$env:DETAIL_RATIO="0" +npm run container:k6 +``` + +如果要压 1000 HTTP req/s,把 `PEAK_RPS` 调到 `500`;如果要压 5000 HTTP req/s,把 `PEAK_RPS` 调到 `2500`,并同时提高 `PREALLOCATED_VUS` / `MAX_VUS`,观察是否先被带宽、Nginx `limit_conn` 或 api-server 背压限制。 + +## OTLP + +容器内 `otelcol` 默认使用 debug exporter。开启 api-server OTEL: + +```env +GENARRATIVE_OTEL_ENABLED=true +OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4318 +``` + +然后重建或重启容器: + +```bash +npm run container:up +npm run container:logs -- otelcol +``` + +Collector 日志会输出 traces / metrics / logs。接 Rider、Jaeger、Tempo、Prometheus、Grafana 或托管平台时,另建独立 Collector 配置,不直接改生产 systemd 或 Nginx 模板。 + +## 隔离边界 + +- 不改生产 systemd 单元。 +- 不改 Jenkins 发布主流程。 +- 不要求真实 HTTPS 证书。 +- 不把真实 `.env`、`.env.local`、`.env.secrets.local` 或 `deploy/container/api-server.env` 放入 Docker build context。 +- 不在容器镜像里内置 SpacetimeDB 数据或 token。 diff --git a/deploy/container/api-server.Dockerfile b/deploy/container/api-server.Dockerfile new file mode 100644 index 00000000..5385b719 --- /dev/null +++ b/deploy/container/api-server.Dockerfile @@ -0,0 +1,49 @@ +FROM rust:1.88-bookworm AS rust-builder +WORKDIR /workspace + +COPY server-rs ./server-rs +RUN cargo build --release -p api-server --manifest-path server-rs/Cargo.toml && \ + cp server-rs/target/release/api-server /tmp/api-server + +FROM debian:bookworm-slim AS api-runtime +WORKDIR /srv/genarrative + +RUN apt-get update && \ + apt-get install -y --no-install-recommends ca-certificates curl && \ + rm -rf /var/lib/apt/lists/* && \ + useradd --system --create-home --home-dir /srv/genarrative --shell /usr/sbin/nologin genarrative + +COPY --from=rust-builder /tmp/api-server /usr/local/bin/api-server + +RUN mkdir -p /var/lib/genarrative/auth && \ + chown -R genarrative:genarrative /srv/genarrative /var/lib/genarrative + +USER genarrative +EXPOSE 8082 + +ENV GENARRATIVE_ENV=container \ + GENARRATIVE_API_HOST=0.0.0.0 \ + GENARRATIVE_API_PORT=8082 \ + GENARRATIVE_AUTH_STORE_PATH=/var/lib/genarrative/auth/auth-store.json + +CMD ["api-server"] + +FROM node:22-bookworm-slim AS web-builder +WORKDIR /workspace + +COPY package.json package-lock.json ./ +COPY apps/admin-web/package.json ./apps/admin-web/package.json +RUN npm ci + +COPY index.html metadata.json tsconfig.json vite.config.ts ./ +COPY src ./src +COPY public ./public +COPY media ./media +COPY packages ./packages +COPY apps/admin-web ./apps/admin-web +RUN npm run build:raw && npm run admin-web:build + +FROM nginx:1.27-alpine AS nginx-runtime +COPY --from=web-builder /workspace/dist /srv/genarrative/web +COPY --from=web-builder /workspace/apps/admin-web/dist /srv/genarrative/web/admin +COPY deploy/container/nginx.conf /etc/nginx/nginx.conf diff --git a/deploy/container/api-server.env.example b/deploy/container/api-server.env.example new file mode 100644 index 00000000..ad4ff549 --- /dev/null +++ b/deploy/container/api-server.env.example @@ -0,0 +1,35 @@ +# 复制为 deploy/container/api-server.env 后填入本机或预发值。 +# 该文件只用于容器隔离方案,不参与 systemd/Jenkins 生产部署。 +# 不要在这里写真实 token 后提交 Git。 + +GENARRATIVE_ENV=container +GENARRATIVE_API_HOST=0.0.0.0 +GENARRATIVE_API_PORT=8082 +GENARRATIVE_API_LOG=info,tower_http=info +GENARRATIVE_API_LISTEN_BACKLOG=1024 +GENARRATIVE_API_WORKER_THREADS=4 +GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512 + +GENARRATIVE_OTEL_ENABLED=false +OTEL_SERVICE_NAME=genarrative-api +OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4318 +OTEL_RESOURCE_ATTRIBUTES=deployment.environment=container,service.namespace=genarrative + +GENARRATIVE_INTERNAL_API_SECRET=CHANGE_ME_FOR_CONTAINER +GENARRATIVE_JWT_ISSUER=genarrative-container +GENARRATIVE_JWT_SECRET=CHANGE_ME_FOR_CONTAINER +AUTH_REFRESH_COOKIE_SECURE=false +GENARRATIVE_AUTH_STORE_PATH=/var/lib/genarrative/auth/auth-store.json + +# Docker Desktop 下连接宿主机 npm run dev 启动的 SpacetimeDB。 +# Linux Docker Engine 可改成宿主机网关 IP,或在 compose 里接入同一网络内的 SpacetimeDB。 +GENARRATIVE_SPACETIME_SERVER_URL=http://host.docker.internal:3101 +GENARRATIVE_SPACETIME_DATABASE=genarrative-loadtest +GENARRATIVE_SPACETIME_TOKEN= +GENARRATIVE_SPACETIME_POOL_SIZE=8 +GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS=45 + +GENARRATIVE_LLM_PROVIDER=openai-compatible +GENARRATIVE_LLM_BASE_URL= +GENARRATIVE_LLM_API_KEY= +GENARRATIVE_LLM_MODEL= diff --git a/deploy/container/docker-compose.loadtest.yml b/deploy/container/docker-compose.loadtest.yml new file mode 100644 index 00000000..2450e6ec --- /dev/null +++ b/deploy/container/docker-compose.loadtest.yml @@ -0,0 +1,85 @@ +name: genarrative-container-loadtest + +services: + api-server: + build: + context: ../.. + dockerfile: deploy/container/api-server.Dockerfile + target: api-runtime + env_file: + - ./api-server.env + environment: + GENARRATIVE_API_HOST: 0.0.0.0 + GENARRATIVE_API_PORT: 8082 + OTEL_EXPORTER_OTLP_ENDPOINT: http://otelcol:4318 + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - api-auth-store:/var/lib/genarrative/auth + depends_on: + otelcol: + condition: service_started + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8082/healthz"] + interval: 10s + timeout: 3s + retries: 12 + start_period: 20s + + nginx: + build: + context: ../.. + dockerfile: deploy/container/api-server.Dockerfile + target: nginx-runtime + depends_on: + api-server: + condition: service_healthy + ports: + - "${GENARRATIVE_CONTAINER_HTTP_PORT:-18080}:80" + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - nginx-logs:/var/log/nginx + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1/api/runtime/puzzle/gallery"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + + otelcol: + image: otel/opentelemetry-collector-contrib:0.125.0 + command: ["--config=/etc/otelcol/config.yaml"] + volumes: + - ./otelcol.yaml:/etc/otelcol/config.yaml:ro + ports: + - "${GENARRATIVE_CONTAINER_OTLP_GRPC_PORT:-4317}:4317" + - "${GENARRATIVE_CONTAINER_OTLP_HTTP_PORT:-4318}:4318" + + k6: + image: grafana/k6:0.52.0 + profiles: ["loadtest"] + depends_on: + nginx: + condition: service_healthy + environment: + BASE_URL: http://nginx + WORKS_DATA: data/works-list.sample.json + SCENARIO: ${SCENARIO:-spike} + START_RPS: ${START_RPS:-5} + PEAK_RPS: ${PEAK_RPS:-250} + HOLD: ${HOLD:-60s} + END_RPS: ${END_RPS:-5} + PREALLOCATED_VUS: ${PREALLOCATED_VUS:-100} + MAX_VUS: ${MAX_VUS:-500} + DETAIL_RATIO: ${DETAIL_RATIO:-0} + SLEEP_MIN_SECONDS: ${SLEEP_MIN_SECONDS:-0} + SLEEP_MAX_SECONDS: ${SLEEP_MAX_SECONDS:-0} + volumes: + - ../../scripts/loadtest:/scripts/loadtest:ro + working_dir: /scripts/loadtest + command: ["run", "k6-works-list.js"] + +volumes: + api-auth-store: + nginx-logs: diff --git a/deploy/container/nginx.conf b/deploy/container/nginx.conf new file mode 100644 index 00000000..ae274c96 --- /dev/null +++ b/deploy/container/nginx.conf @@ -0,0 +1,133 @@ +worker_processes auto; + +events { + worker_connections 4096; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format genarrative_upstream + '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" "$http_user_agent" ' + 'request_time=$request_time upstream_connect_time=$upstream_connect_time ' + 'upstream_header_time=$upstream_header_time upstream_response_time=$upstream_response_time ' + 'upstream_status=$upstream_status request_id=$request_id'; + + upstream genarrative_api { + server api-server:8082; + keepalive 64; + } + + limit_conn_zone $binary_remote_addr zone=genarrative_api_conn:10m; + + sendfile on; + keepalive_timeout 65; + + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 5; + gzip_min_length 1024; + gzip_types + text/plain + text/css + text/javascript + application/javascript + application/json + application/xml + application/xml+rss + image/svg+xml; + + server { + listen 80; + server_name _; + + access_log /var/log/nginx/genarrative.access.log genarrative_upstream; + error_log /var/log/nginx/genarrative.error.log warn; + limit_conn_status 429; + limit_conn_log_level warn; + + root /srv/genarrative/web; + index index.html; + + location ^~ /admin/api/ { + default_type application/json; + limit_conn genarrative_api_conn 64; + + proxy_pass http://genarrative_api/admin/api/; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Request-Id $request_id; + } + + location = /admin { + return 301 /admin/; + } + + location ^~ /admin/assets/ { + try_files $uri =404; + } + + location ^~ /admin/ { + try_files $uri $uri/ /admin/index.html; + } + + location ^~ /assets/ { + try_files $uri =404; + } + + location ~ ^/api(?:/|$) { + default_type application/json; + limit_conn genarrative_api_conn 64; + + proxy_pass http://genarrative_api; + proxy_http_version 1.1; + proxy_buffering off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + add_header X-Accel-Buffering no always; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Request-Id $request_id; + } + + location ~ ^/(generated-|healthz) { + return 404; + } + + location ~ ^/v1/database/[^/]+/subscribe$ { + proxy_pass http://host.docker.internal:3101; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 3600s; + } + + location ^~ /v1/identity { + proxy_pass http://host.docker.internal:3101; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + } + + location ^~ /v1/ { + return 404; + } + + location / { + try_files $uri $uri/ /index.html; + } + } +} diff --git a/deploy/container/otelcol.yaml b/deploy/container/otelcol.yaml new file mode 100644 index 00000000..f86d0155 --- /dev/null +++ b/deploy/container/otelcol.yaml @@ -0,0 +1,23 @@ +receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + +exporters: + debug: + verbosity: detailed + +service: + pipelines: + traces: + receivers: [otlp] + exporters: [debug] + metrics: + receivers: [otlp] + exporters: [debug] + logs: + receivers: [otlp] + exporters: [debug] diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example index 7420d6c9..c0c4763e 100644 --- a/deploy/env/api-server.env.example +++ b/deploy/env/api-server.env.example @@ -5,6 +5,13 @@ GENARRATIVE_ENV=production GENARRATIVE_API_HOST=127.0.0.1 GENARRATIVE_API_PORT=8082 GENARRATIVE_API_LOG=info,tower_http=info +GENARRATIVE_API_LISTEN_BACKLOG=1024 +GENARRATIVE_API_WORKER_THREADS=4 +GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512 +GENARRATIVE_OTEL_ENABLED=false +OTEL_SERVICE_NAME=genarrative-api +OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 +OTEL_RESOURCE_ATTRIBUTES=deployment.environment=production,service.namespace=genarrative GENARRATIVE_ADMIN_USERNAME= GENARRATIVE_ADMIN_PASSWORD= @@ -79,9 +86,9 @@ SMS_AUTH_ENABLED=false SMS_AUTH_PROVIDER=aliyun ALIYUN_SMS_ACCESS_KEY_ID= ALIYUN_SMS_ACCESS_KEY_SECRET= -ALIYUN_SMS_ENDPOINT=dypnsapi.aliyuncs.com -ALIYUN_SMS_SIGN_NAME= -ALIYUN_SMS_TEMPLATE_CODE= +ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com +ALIYUN_SMS_SIGN_NAME=北京亓盒网络科技 +ALIYUN_SMS_TEMPLATE_CODE=SMS_506245486 ALIYUN_SMS_TEMPLATE_PARAM_KEY=code ALIYUN_SMS_COUNTRY_CODE=86 diff --git a/deploy/nginx/genarrative-dev-http.conf b/deploy/nginx/genarrative-dev-http.conf index 824a8f5a..6c9bede4 100644 --- a/deploy/nginx/genarrative-dev-http.conf +++ b/deploy/nginx/genarrative-dev-http.conf @@ -1,9 +1,27 @@ # 开发服无域名时使用的 HTTP 入口,只允许用于 DEPLOY_TARGET=development。 # 没有域名时,将 SERVER_NAME 填为开发机 IP 或临时主机名。 # 生产 release 仍必须使用 genarrative.conf 的 HTTPS 配置。 +log_format genarrative_upstream + '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" "$http_user_agent" ' + 'request_time=$request_time upstream_connect_time=$upstream_connect_time ' + 'upstream_header_time=$upstream_header_time upstream_response_time=$upstream_response_time ' + 'upstream_status=$upstream_status request_id=$request_id'; + +upstream genarrative_api { + server 127.0.0.1:8082; + keepalive 64; +} + +limit_conn_zone $binary_remote_addr zone=genarrative_api_conn:10m; + server { listen 80; server_name genarrative.example.com; + access_log /var/log/nginx/genarrative.access.log genarrative_upstream; + error_log /var/log/nginx/genarrative.error.log warn; + limit_conn_status 429; + limit_conn_log_level warn; gzip on; gzip_vary on; @@ -29,13 +47,15 @@ server { location ^~ /admin/api/ { default_type application/json; + limit_conn genarrative_api_conn 64; if ($genarrative_maintenance) { return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; } - proxy_pass http://127.0.0.1:8082/admin/api/; + proxy_pass http://genarrative_api/admin/api/; proxy_http_version 1.1; + proxy_set_header Connection ""; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -68,17 +88,19 @@ server { # 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。 location ~ ^/api(?:/|$) { default_type application/json; + limit_conn genarrative_api_conn 64; if ($genarrative_maintenance) { return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; } - proxy_pass http://127.0.0.1:8082; + proxy_pass http://genarrative_api; proxy_http_version 1.1; proxy_buffering off; proxy_read_timeout 3600s; proxy_send_timeout 3600s; add_header X-Accel-Buffering no always; + proxy_set_header Connection ""; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/deploy/nginx/genarrative.conf b/deploy/nginx/genarrative.conf index 06a3bf86..984dd130 100644 --- a/deploy/nginx/genarrative.conf +++ b/deploy/nginx/genarrative.conf @@ -1,7 +1,25 @@ # 生产域名需要在部署前替换为真实域名,并由 certbot 或等价流程写入 HTTPS 证书配置。 +log_format genarrative_upstream + '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" "$http_user_agent" ' + 'request_time=$request_time upstream_connect_time=$upstream_connect_time ' + 'upstream_header_time=$upstream_header_time upstream_response_time=$upstream_response_time ' + 'upstream_status=$upstream_status request_id=$request_id'; + +upstream genarrative_api { + server 127.0.0.1:8082; + keepalive 64; +} + +limit_conn_zone $binary_remote_addr zone=genarrative_api_conn:10m; + server { listen 80; server_name genarrative.example.com; + access_log /var/log/nginx/genarrative.access.log genarrative_upstream; + error_log /var/log/nginx/genarrative.error.log warn; + limit_conn_status 429; + limit_conn_log_level warn; location /.well-known/acme-challenge/ { root /var/www/html; @@ -15,6 +33,8 @@ server { server { listen 443 ssl http2; server_name genarrative.example.com; + access_log /var/log/nginx/genarrative.access.log genarrative_upstream; + error_log /var/log/nginx/genarrative.error.log warn; gzip on; gzip_vary on; @@ -43,13 +63,15 @@ server { location ^~ /admin/api/ { default_type application/json; + limit_conn genarrative_api_conn 64; if ($genarrative_maintenance) { return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; } - proxy_pass http://127.0.0.1:8082/admin/api/; + proxy_pass http://genarrative_api/admin/api/; proxy_http_version 1.1; + proxy_set_header Connection ""; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -82,17 +104,19 @@ server { # 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。 location ~ ^/api(?:/|$) { default_type application/json; + limit_conn genarrative_api_conn 64; if ($genarrative_maintenance) { return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; } - proxy_pass http://127.0.0.1:8082; + proxy_pass http://genarrative_api; proxy_http_version 1.1; proxy_buffering off; proxy_read_timeout 3600s; proxy_send_timeout 3600s; add_header X-Accel-Buffering no always; + proxy_set_header Connection ""; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/deploy/systemd/genarrative-api.service b/deploy/systemd/genarrative-api.service index 1a22b75d..bba53a79 100644 --- a/deploy/systemd/genarrative-api.service +++ b/deploy/systemd/genarrative-api.service @@ -15,6 +15,8 @@ Restart=always RestartSec=5 KillSignal=SIGINT TimeoutStopSec=30 +LimitNOFILE=65535 +TasksMax=2048 # api-server 只读发布目录,运行态写入必须显式落到环境变量指定的服务端私有目录。 NoNewPrivileges=true diff --git a/docs/design/CHILD_MOTION_DEMO_WARMUP_LEVEL_DESIGN_2026-05-09.md b/docs/design/CHILD_MOTION_DEMO_WARMUP_LEVEL_DESIGN_2026-05-09.md new file mode 100644 index 00000000..759ba781 --- /dev/null +++ b/docs/design/CHILD_MOTION_DEMO_WARMUP_LEVEL_DESIGN_2026-05-09.md @@ -0,0 +1,505 @@ +# 儿童动作识别互动玩法 Demo 热身关开发文档 + +> 日期:2026-05-09 +> 适用范围:儿童动作识别互动玩法 Demo 的固定启动热身关 +> 文档性质:玩法 Demo 开发设计文档 +> 说明:本文整理当前已确认的热身关内容、体验、流程和热身数据记录要求。 + +## 1. 热身关定位 + +热身关是 Demo 启动后的固定流程,用于在正式进入后续趣味学习关前完成以下事项: + +- 调用摄像头; +- 识别用户和环境; +- 引导用户来到建议互动位置; +- 教学基础交互方式; +- 确认用户可在互动空间内完成左右移动和挥手; +- 记录用户左右移动距离和挥动手臂空间,作为后续关卡的空间边界与行为坐标; +- 完成后进入关卡选择。 + +热身关不接入创作模块,不作为可配置玩法模板提供给创作者。 + +## 2. 屏幕与设备适配 + +本产品适用于电视屏幕、电脑屏幕等环境。 + +热身关制作表达使用横屏比例。 + +## 3. 画面基础表现 + +用户进入热身关后,摄像头被调用,并开始识别用户和环境。 + +画面基础表现如下: + +1. 在屏幕中央位置的地面生成预设的绿色圆环,作为建议位置的指引。 +2. 将用户的实际位置生成为更细的白色描边小人指示器,作为用户在画面中的标识。 +3. 只对摄像头背景做虚化处理,用于表达对用户隐私的保护、屏蔽周围环境干扰,并营造空间感。 + +## 4. 通用检测与引导规则 + +### 4.1 不允许跳过 + +热身关每个步骤都必须由用户完成,不允许跳过,也不允许系统自动进入下一步。 + +### 4.2 引导动画播放规则 + +每个动作等待 3 秒后可以播放引导动画。 + +当前不设置最长等待时间。 + +### 4.3 绿色圆环完成规则 + +用户到达绿色圆环后,绿色圆环进入 2 秒选中状态。 + +用户需要在绿色圆环内保持停留 2 秒,才算完成该圆环位置检测。 + +### 4.4 左右距离映射规则 + +“约半米”的左右移动距离,技术上以角色剪影移动距离为准。 + +该距离后续会根据实际体验继续调校。 + +### 4.5 手势区分规则 + +招手 / 摆手、挥动左手、挥动右手三类动作需要有动作区分。 + +手势检测仅对肢体进行区分,不对手部细节进行区分。 + +### 4.6 手势引导规则 + +挥动哪只手,就使用对应手的引导。 + +## 5. 热身关完整流程 + +### 5.1 进入热身关 + +#### 画面表现 + +- 摄像头被调用。 +- 系统识别用户和环境。 +- 屏幕中央位置的地面出现预设绿色圆环。 +- 用户实际位置以更细的白色描边小人指示器形式显示。 +- 只对摄像头背景做虚化处理,保留空间感。 + +#### 屏幕文字与语音 + +屏幕中上方浮现文字,同时语音播报: + +```text +欢迎你,小朋友,见到你真开心 +``` + +随后继续播报: + +```text +来圆圈这里和我打个招呼吧 +``` + +首句展示完成后停顿 2 秒,再展示第二句。该步骤不展示“来到圆圈这里”大标题。 + +#### 检测逻辑 + +系统检测用户是否到达屏幕中央绿色圆环位置。 + +用户到达圆环后,绿色圆环进入 2 秒选中状态。用户保持停留 2 秒后,该步骤完成。 + +#### 完成反馈 + +用户完成中央圆环位置检测后: + +- 播放圆圈消失特效; +- 进入招手手势教学步骤。 + +--- + +### 5.2 招手教学 + +#### 画面表现 + +播放招手的手势引导,引导猫咪整体位于上半屏幕、字幕 UI 下方。 + +若用户进入该步骤后 3 秒仍未完成动作,可以播放引导动画。 + +#### 检测逻辑 + +系统检测用户是否完成招手 / 摆手手势。 + +该动作与后续挥动左手、挥动右手需要有动作区分,但仅对肢体进行区分,不对手部细节进行区分。 + +#### 完成反馈 + +用户完成招手 / 摆手手势后,进入下一步。 + +--- + +### 5.3 热身说明 + +#### 屏幕文字与语音 + +```text +你好呀小朋友,为了你玩的安全和开心,先来和我一起热个身吧 +``` + +播放完成后进入左右移动热身步骤。 + +--- + +### 5.4 向左一步 + +#### 屏幕文字与语音 + +```text +向左一步 +``` + +#### 画面表现 + +屏幕中心向左一个身位,约半米的地面位置,出现新的绿色圆圈。 + +“约半米”技术上以角色剪影移动距离为准,后续根据体验调校。 + +#### 检测逻辑 + +系统检测用户是否到达该绿色圆圈位置。 + +用户到达圆环后,绿色圆环进入 2 秒选中状态。用户保持停留 2 秒后,该步骤完成。 + +#### 完成反馈 + +用户完成后播放鼓励语: + +```text +真棒 +``` + +同时记录本次向左移动距离,作为后续关卡中的左侧空间边界参考。 + +完成后进入“回到中间来”。 + +--- + +### 5.5 回到中间来(一) + +#### 屏幕文字与语音 + +```text +回到中间来 +``` + +#### 画面表现 + +场地中心位置出现绿色圆圈。 + +#### 检测逻辑 + +系统检测用户是否到达场地中心绿色圆圈位置。 + +用户到达圆环后,绿色圆环进入 2 秒选中状态。用户保持停留 2 秒后,该步骤完成。 + +#### 完成反馈 + +用户完成后播放鼓励语: + +```text +真棒 +``` + +完成后进入“向右一步”。 + +--- + +### 5.6 向右一步 + +#### 屏幕文字与语音 + +```text +向右一步 +``` + +#### 画面表现 + +屏幕中心向右一个身位,约半米的地面位置,出现新的绿色圆圈。 + +“约半米”技术上以角色剪影移动距离为准,后续根据体验调校。 + +#### 检测逻辑 + +系统检测用户是否到达该绿色圆圈位置。 + +用户到达圆环后,绿色圆环进入 2 秒选中状态。用户保持停留 2 秒后,该步骤完成。 + +#### 完成反馈 + +用户完成后播放鼓励语: + +```text +真棒 +``` + +同时记录本次向右移动距离,作为后续关卡中的右侧空间边界参考。 + +完成后进入“回到中间来”。 + +--- + +### 5.7 回到中间来(二) + +#### 屏幕文字与语音 + +```text +回到中间来 +``` + +#### 画面表现 + +场地中心位置出现绿色圆圈。 + +#### 检测逻辑 + +系统检测用户是否到达场地中心绿色圆圈位置。 + +用户到达圆环后,绿色圆环进入 2 秒选中状态。用户保持停留 2 秒后,该步骤完成。 + +#### 完成反馈 + +用户完成后播放鼓励语: + +```text +真棒 +``` + +完成后进入左手挥动教学。 + +--- + +### 5.8 挥动左手 + +#### 屏幕文字与语音 + +```text +挥动左手 +``` + +#### 画面表现 + +播放伸展手臂挥动左手的手势引导。 + +若用户进入该步骤后 3 秒仍未完成动作,可以播放引导动画。 + +#### 检测逻辑 + +系统检测用户是否完成挥动左手手势。 + +该手势检测仅对肢体进行区分,不对手部细节进行区分。 + +#### 完成反馈 + +用户完成后播放鼓励语: + +```text +真棒 +``` + +同时记录用户挥动左手的空间,保存为该用户对应的行为坐标。 + +完成后进入右手挥动教学。 + +--- + +### 5.9 挥动右手 + +#### 屏幕文字与语音 + +```text +挥动右手 +``` + +#### 画面表现 + +播放伸展手臂挥动右手的手势引导。 + +若用户进入该步骤后 3 秒仍未完成动作,可以播放引导动画。 + +#### 检测逻辑 + +系统检测用户是否完成挥动右手手势。 + +该手势检测仅对肢体进行区分,不对手部细节进行区分。 + +#### 完成反馈 + +用户完成后播放鼓励语: + +```text +真棒 +``` + +同时记录用户挥动右手的空间,保存为该用户对应的行为坐标。 + +完成后进入热身结束。 + +--- + +### 5.10 热身结束 + +#### 进入条件 + +用户完成挥动右手后,直接进入热身结束阶段。 + +#### 完成反馈 + +播放热身结束特效、上浮字幕和语音: + +```text +真厉害,你是我见过最聪明的小朋友 +``` + +随后继续播放: + +```text +别走开,现在开始我们的游戏吧 +``` + +热身关结束,进入关卡选择。 + +## 6. 流程状态表 + +| 顺序 | 步骤 | 屏幕文字 / 语音 | 画面表现 | 检测目标 | 完成后反馈 | +|---:|---|---|---|---|---| +| 1 | 进入热身关 | 欢迎你,小朋友,见到你真开心;来圆圈这里和我打个招呼吧 | 中央地面绿色圆环;用户更细白色描边小人指示器;摄像头背景虚化 | 用户到达中央圆环并保持 2 秒 | 圆圈消失特效 | +| 2 | 招手教学 | 同上流程延续 | 招手手势引导;等待 3 秒可播放引导动画 | 招手 / 摆手 | 进入下一步 | +| 3 | 热身说明 | 你好呀小朋友,为了你玩的安全和开心,先来和我一起热个身吧 | 保持热身引导状态 | 无新增动作检测 | 进入移动热身 | +| 4 | 向左一步 | 向左一步 | 左侧约半米处绿色圆圈 | 用户到达左侧圆环并保持 2 秒 | 真棒;记录左侧空间边界 | +| 5 | 回到中间来 | 回到中间来 | 中心位置绿色圆圈 | 用户到达中心圆环并保持 2 秒 | 真棒 | +| 6 | 向右一步 | 向右一步 | 右侧约半米处绿色圆圈 | 用户到达右侧圆环并保持 2 秒 | 真棒;记录右侧空间边界 | +| 7 | 回到中间来 | 回到中间来 | 中心位置绿色圆圈 | 用户到达中心圆环并保持 2 秒 | 真棒 | +| 8 | 挥动左手 | 挥动左手 | 伸展手臂挥动左手手势引导;等待 3 秒可播放引导动画 | 挥动左手 | 真棒;记录左手挥动空间 | +| 9 | 挥动右手 | 挥动右手 | 伸展手臂挥动右手手势引导;等待 3 秒可播放引导动画 | 挥动右手 | 真棒;记录右手挥动空间;进入热身结束 | +| 10 | 热身结束 | 真厉害,你是我见过最聪明的小朋友;别走开,现在开始我们的游戏吧 | 热身结束特效 | 无新增动作检测 | 进入关卡选择 | + +## 7. 固定文案与语音清单 + +以下文案需要作为屏幕中上方浮现文字,并同步语音播报。 + +```text +欢迎你,小朋友,见到你真开心 +来圆圈这里和我打个招呼吧 +你好呀小朋友,为了你玩的安全和开心,先来和我一起热个身吧 +向左一步 +真棒 +回到中间来 +真棒 +向右一步 +真棒 +回到中间来 +真棒 +挥动左手 +真棒 +挥动右手 +真厉害,你是我见过最聪明的小朋友 +别走开,现在开始我们的游戏吧 +``` + +## 8. 需要开发支持的识别能力 + +热身关当前流程需要支持以下识别能力: + +1. 摄像头调用; +2. 用户识别; +3. 环境识别; +4. 用户实际位置识别; +5. 用户是否到达中央绿色圆环位置; +6. 用户是否在绿色圆环内持续保持 2 秒; +7. 用户是否到达左侧约半米绿色圆环位置; +8. 用户是否到达右侧约半米绿色圆环位置; +9. 招手 / 摆手手势识别; +10. 挥动左手识别; +11. 挥动右手识别; +12. 用户左右移动距离记录; +13. 用户挥动手臂空间记录。 + +## 9. 需要开发支持的表现能力 + +热身关当前流程需要支持以下表现能力: + +1. 横屏比例显示; +2. 摄像头背景虚化; +3. 用户位置生成更细的白色描边小人指示器; +4. 屏幕中央地面绿色圆环; +5. 左侧约半米地面绿色圆环; +6. 右侧约半米地面绿色圆环; +7. 绿色圆环 2 秒选中状态; +8. 圆圈消失特效; +9. 招手手势引导; +10. 伸展手臂挥动左手手势引导; +11. 伸展手臂挥动右手手势引导; +12. 热身结束特效; +13. 上浮字幕; +14. 语音播报。 + +## 10. 热身数据记录要求 + +热身关需要记录以下数据,用于后续关卡的空间边界和行为坐标判断。 + +### 10.1 左右空间边界 + +用户完成向左一步后,记录该移动距离,作为后续关卡中的左侧空间边界。 + +用户完成向右一步后,记录该移动距离,作为后续关卡中的右侧空间边界。 + +后续关卡中,当用户身体主体覆盖安全边界线时,对应侧屏幕边缘出现虚影提醒。 + +后续关卡中,当用户身体主体超出安全边界线时: + +1. 关卡内容暂停; +2. 屏幕虚化; +3. 屏幕中央地面出现绿色圆圈; +4. 屏幕提示文案: + +```text +小朋友,要注意安全哦 +``` + +5. 用户需要回到中心绿色圆圈并保持 2 秒后,才能继续游戏内容。 + +### 10.2 手臂挥动空间 + +用户完成挥动左手后,记录用户挥动左手的空间,保存为该用户对应的行为坐标。 + +用户完成挥动右手后,记录用户挥动右手的空间,保存为该用户对应的行为坐标。 + +## 11. 热身关完成条件 + +热身关完成条件为用户按顺序完成以下流程: + +1. 到达中央圆环位置并保持 2 秒; +2. 完成招手 / 摆手手势; +3. 到达左侧约半米圆环位置并保持 2 秒; +4. 记录左侧空间边界; +5. 回到中央圆环位置并保持 2 秒; +6. 到达右侧约半米圆环位置并保持 2 秒; +7. 记录右侧空间边界; +8. 回到中央圆环位置并保持 2 秒; +9. 完成挥动左手; +10. 记录左手挥动空间; +11. 完成挥动右手; +12. 记录右手挥动空间; +13. 播放热身结束特效和结束语音; +14. 进入关卡选择。 + +## 12. 数据保存方式 + +左右空间边界和手臂挥动空间仅在当前 Demo 体验会话内保存。 + +这里的“当前 Demo 体验会话”指用户本次打开并体验 Demo 的过程。当用户关闭 Demo、刷新页面、退出当前体验流程、重新进入 Demo,或更换设备后,系统不再沿用上一次热身记录的数据,需要重新完成热身关并重新记录。 + +采用仅当前 Demo 体验会话内保存的原因: + +1. 每名用户的身高、体型、动作幅度不同,安全边界和行为坐标会发生变化。 +2. 当前 Demo 不做特定用户识别,无法确认下一次体验的是否仍是同一名用户。 +3. 用户所处的体验环境可能变化,包括房间大小、摄像头位置、屏幕位置和站立距离。 +4. 为保证安全,每次体验都需要重新对环境和距离进行安全检查。 + +## 13. 后续待确认事项 + +当前暂无待确认事项。 diff --git a/docs/design/TAONIER_BRAND_LOGO_CONCEPTS_2026-05-13.md b/docs/design/TAONIER_BRAND_LOGO_CONCEPTS_2026-05-13.md new file mode 100644 index 00000000..d2be0b32 --- /dev/null +++ b/docs/design/TAONIER_BRAND_LOGO_CONCEPTS_2026-05-13.md @@ -0,0 +1,1680 @@ +# 陶泥儿品牌 Logo 概念稿 + +> 本稿是围绕候选产品名“陶泥儿”的品牌视觉探索,不替代当前已冻结的“百梦”正式命名口径。若后续确认更名,需要另起产品命名、前后端文案和商标检索落地方案。 + +## 1. 品牌定位归纳 + +“陶泥儿”适合承接的不是传统陶艺或儿童黏土,而是“把灵感塑形成可玩内容”的 AI 创作平台隐喻。 + +核心关键词: + +- 精品:作品不是随手糊出来,而是经过 AI 辅助打磨、可被消费和传播的轻精品内容。 +- UGC:用户是主要造物者,平台降低创作门槛。 +- 创作:从一句脑洞、一个梗、一张图,生成小游戏、互动作品或可分享内容。 +- 裂变与梗:名字要支持“开捏”“捏个梗”“捏个小游戏”这类用户语言。 +- 轻度休闲:体验应松弛、即时、好玩,不走硬核生产工具气质。 +- AI:AI 是塑形能力,不是冷冰冰的技术标签。 + +推荐品牌主张: + +```text +把脑洞捏成小游戏 +``` + +备选表达: + +```text +捏个脑洞,马上开玩 +AI 开捏,人人会创作 +随手造梗,随心开玩 +``` + +## 2. 生成原则 + +本稿包含多轮 Logo 概念:早期批次使用仓库 GPT-image-2 / VectorEngine 工作流生成无文字图标,锚点底座与抽象泥胚批次使用确定性矢量脚本生成 SVG / PNG。当前新主线已切换为“抽象泥胚角色”:保留陶泥人 / 陶泥手办 / 吉祥物的生命感和 IP 延展性,但不再直接画人体,而是把它压缩成一个可被记住的几何陶泥主标。 + +原因: + +- AI 生图直接生成中文品牌字容易出现笔画错误,不适合作为正式字标。 +- 当前阶段更适合先确定图形符号方向,再由设计师或前端继续做矢量化、字标搭配和多尺寸适配。 +- 当前主线已停止此前软泥合拍、旋涡、糖果粉绿、锚点底座和具象小人方向,改以“非人形抽象陶泥角色 / 泥胚手办符号”为原型,强调可记住、可延展、可做 IP 的品牌主标。 +- 图标需要优先服务 App icon、平台左上角品牌、分享卡片和加载页,而不是一次性海报图。 + +生成文件: + +```text +public/branding/taonier-logo-v3-concepts/ +├─ taonier-logo-v3-contact-sheet.png +├─ taonier-v3-finger-spark.png +├─ taonier-v3-seed-pop.png +├─ taonier-v3-magic-dot.png +├─ taonier-v3-work-gem.png +└─ taonier-v3-soft-t.png + +public/branding/taonier-logo-magic-dot-concepts/ +├─ taonier-logo-magic-dot-contact-sheet.png +├─ taonier-magic-dot-orbit.png +├─ taonier-magic-dot-seal.png +├─ taonier-magic-dot-squish.png +├─ taonier-magic-dot-mold.png +└─ taonier-magic-dot-bloom.png + +public/branding/taonier-logo-anchor-concepts/ +├─ taonier-logo-anchor-contact-sheet.png +├─ taonier-anchor-core.svg +├─ taonier-anchor-core.png +├─ taonier-anchor-soft-slab.svg +├─ taonier-anchor-soft-slab.png +├─ taonier-anchor-work-stack.svg +├─ taonier-anchor-work-stack.png +├─ taonier-anchor-clay-drop.svg +├─ taonier-anchor-clay-drop.png +├─ taonier-anchor-creation-base.svg +├─ taonier-anchor-creation-base.png +├─ taonier-anchor-app-token.svg +└─ taonier-anchor-app-token.png + +public/branding/taonier-logo-clay-mascot-concepts/ +├─ taonier-logo-clay-mascot-contact-sheet.png +├─ taonier-clay-mascot-little-maker.png +├─ taonier-clay-mascot-figurine-token.png +├─ taonier-clay-mascot-soft-doll.png +├─ taonier-clay-mascot-creator-totem.png +├─ taonier-clay-mascot-idol-mask.png +└─ taonier-clay-mascot-pocket-figure.png + +public/branding/taonier-logo-geometric-concepts/ +├─ taonier-logo-geometric-contact-sheet.png +├─ taonier-geometric-offset-core.svg +├─ taonier-geometric-offset-core.png +├─ taonier-geometric-mold-chip.svg +├─ taonier-geometric-mold-chip.png +├─ taonier-geometric-pinched-tile.svg +├─ taonier-geometric-pinched-tile.png +├─ taonier-geometric-dual-plate.svg +├─ taonier-geometric-dual-plate.png +├─ taonier-geometric-dot-gate.svg +├─ taonier-geometric-dot-gate.png +├─ taonier-geometric-work-knot.svg +└─ taonier-geometric-work-knot.png + +public/branding/taonier-logo-hands-concepts/ +├─ taonier-logo-hands-contact-sheet.png +├─ taonier-hands-v2-cradle.png +├─ taonier-hands-v2-clap.png +├─ taonier-hands-v2-bowl.png +├─ taonier-hands-v2-seal.png +└─ taonier-hands-v2-pop.png + +public/branding/taonier-logo-squish-concepts/ +├─ taonier-logo-squish-contact-sheet.png +├─ taonier-squish-v2-pulse.png +├─ taonier-squish-v2-bounce.png +├─ taonier-squish-v2-spark-gap.png +├─ taonier-squish-v2-comet.png +└─ taonier-squish-v2-token.png + +public/branding/taonier-logo-spiral-reference-concepts/ +├─ taonier-logo-spiral-reference-contact-sheet.png +├─ taonier-spiral-reference.jpg +├─ taonier-spiral-soft-squish.png +├─ taonier-spiral-candy-roll.png +├─ taonier-spiral-star-core.png +├─ taonier-spiral-bouncy-clay.png +├─ taonier-spiral-creation-whirl.png +└─ taonier-spiral-soft-token.png + +public/branding/taonier-logo-broad-concepts/ +├─ taonier-logo-broad-contact-sheet.png +├─ taonier-broad-soft-portal.png +├─ taonier-broad-work-embryo.png +├─ taonier-broad-game-mold.png +├─ taonier-broad-soft-totem.png +└─ taonier-broad-creation-spark.png + +public/branding/taonier-logo-fresh-concepts/ +├─ taonier-logo-fresh-contact-sheet.png +├─ taonier-fresh-wheel-imprint.png +├─ taonier-fresh-mold-window.png +├─ taonier-fresh-dot-dice.png +├─ taonier-fresh-pocket-world.png +├─ taonier-fresh-stage-window.png +└─ taonier-fresh-punch-hole.png + +public/branding/taonier-logo-punch-hole-concepts/ +├─ taonier-logo-punch-hole-contact-sheet.png +├─ taonier-punch-locked-shape.png +├─ taonier-punch-stable-icon.png +├─ taonier-punch-hole-balance.png +├─ taonier-punch-color-inlay.png +├─ taonier-punch-mono-test.png +└─ taonier-punch-app-token.png + +public/branding/taonier-logo-punch04-color-concepts/ +├─ taonier-logo-punch04-color-contact-sheet.png +├─ taonier-punch04-warm-ink-core.png +├─ taonier-punch04-navy-game-core.png +├─ taonier-punch04-cream-window.png +├─ taonier-punch04-clay-gradient-flat.png +├─ taonier-punch04-mint-shadow.png +└─ taonier-punch04-negative-tile.png + +public/branding/taonier-logo-ref04-locked-color-concepts/ +├─ taonier-logo-ref04-locked-color-contact-sheet.png +├─ taonier-ref04-locked-warm-ink.png +├─ taonier-ref04-locked-blue-ink.png +├─ taonier-ref04-locked-plum-ink.png +├─ taonier-ref04-locked-green-ink.png +├─ taonier-ref04-locked-shrink-core.png +└─ taonier-ref04-locked-soft-charcoal.png + +public/branding/taonier-logo-ref04-warm-star-concepts/ +├─ taonier-logo-ref04-warm-star-contact-sheet.png +├─ taonier-ref04-warm-star-terracotta.png +├─ taonier-ref04-warm-star-caramel.png +├─ taonier-ref04-warm-star-cocoa.png +├─ taonier-ref04-warm-star-rust.png +├─ taonier-ref04-warm-star-olive.png +└─ taonier-ref04-warm-star-plum.png + +public/branding/taonier-logo-ref04-warm-sparkle-v2-concepts/ +├─ taonier-logo-ref04-warm-sparkle-v2-contact-sheet.png +├─ taonier-ref04-warm-sparkle-terracotta.png +├─ taonier-ref04-warm-sparkle-rust.png +├─ taonier-ref04-warm-sparkle-caramel.png +├─ taonier-ref04-warm-sparkle-cocoa.png +├─ taonier-ref04-warm-sparkle-clay-quiet.png +└─ taonier-ref04-warm-sparkle-plum.png + +public/branding/taonier-logo-ref04-palette-transfer/ +├─ taonier-logo-ref04-palette-transfer-contact-sheet.png +└─ taonier-ref04-palette-transfer-warm-yellow-sparkle.png + +public/branding/taonier-logo-abstract-mascot-concepts/ +├─ taonier-logo-abstract-mascot-contact-sheet.png +├─ taonier-abstract-mascot-clay-bean.svg +├─ taonier-abstract-mascot-clay-bean.png +├─ taonier-abstract-mascot-mold-baby.svg +├─ taonier-abstract-mascot-mold-baby.png +├─ taonier-abstract-mascot-dot-face.svg +├─ taonier-abstract-mascot-dot-face.png +├─ taonier-abstract-mascot-soft-totem.svg +├─ taonier-abstract-mascot-soft-totem.png +├─ taonier-abstract-mascot-clay-seed.svg +├─ taonier-abstract-mascot-clay-seed.png +├─ taonier-abstract-mascot-work-puppet.svg +└─ taonier-abstract-mascot-work-puppet.png + +public/branding/taonier-logo-abstract-mascot-v2-concepts/ +├─ taonier-logo-abstract-mascot-v2-contact-sheet.png +├─ taonier-abstract-mascot-v2-clay-sprite.svg +├─ taonier-abstract-mascot-v2-clay-sprite.png +├─ taonier-abstract-mascot-v2-pinch-orbit.svg +├─ taonier-abstract-mascot-v2-pinch-orbit.png +├─ taonier-abstract-mascot-v2-seed-totem.svg +├─ taonier-abstract-mascot-v2-seed-totem.png +├─ taonier-abstract-mascot-v2-soft-mold.svg +├─ taonier-abstract-mascot-v2-soft-mold.png +├─ taonier-abstract-mascot-v2-clay-orb.svg +├─ taonier-abstract-mascot-v2-clay-orb.png +├─ taonier-abstract-mascot-v2-work-glyph.svg +└─ taonier-abstract-mascot-v2-work-glyph.png + +public/branding/taonier-logo-abstract-mascot-image2-concepts/ +├─ taonier-logo-abstract-mascot-image2-contact-sheet.png +├─ taonier-image2-clay-spirit-glyph.png +├─ taonier-image2-pinched-seed-mascot.png +├─ taonier-image2-soft-totem-creature.png +├─ taonier-image2-clay-pocket-token.png +├─ taonier-image2-work-core-puppet.png +└─ taonier-image2-mold-blob-companion.png + +public/branding/taonier-logo-abstract-mascot-minimal-concepts/ +├─ taonier-logo-abstract-mascot-minimal-contact-sheet.png +├─ taonier-minimal-clay-core.png +├─ taonier-minimal-clay-token.png +├─ taonier-minimal-seed-glyph.png +└─ taonier-minimal-mold-bud.png + +public/branding/taonier-logo-flat-concepts/ +├─ taonier-logo-flat-contact-sheet.png +├─ taonier-flat-play-clay.png +├─ taonier-flat-spark-clay.png +├─ taonier-flat-meme-smile.png +├─ taonier-flat-loop-mold.png +└─ taonier-flat-seal-blocks.png + +public/branding/taonier-logo-concepts/ +├─ taonier-logo-contact-sheet.png +├─ taonier-clay-spark.png +├─ taonier-play-mold.png +├─ taonier-meme-bubble.png +├─ taonier-creation-loop.png +└─ taonier-premium-seal.png +``` + +生成脚本: + +```text +scripts/generate-taonier-logo-concepts.mjs +scripts/generate-taonier-hands-logo-concepts.mjs +scripts/generate-taonier-squish-logo-concepts.mjs +scripts/generate-taonier-spiral-logo-concepts.mjs +scripts/generate-taonier-spiral-contact-sheet.py +scripts/generate-taonier-anchor-logo-concepts.py +scripts/generate-taonier-clay-mascot-logo-concepts.mjs +scripts/generate-taonier-clay-mascot-contact-sheet.py +scripts/generate-taonier-geometric-logo-concepts.py +scripts/generate-taonier-abstract-mascot-logo-concepts.py +scripts/generate-taonier-abstract-mascot-v2-logo-concepts.py +scripts/generate-taonier-abstract-mascot-image2-logo-concepts.mjs +scripts/generate-taonier-abstract-mascot-image2-contact-sheet.py +scripts/generate-taonier-abstract-mascot-minimal-logo-concepts.mjs +scripts/generate-taonier-abstract-mascot-minimal-contact-sheet.py +``` + +## 当前主线:抽象泥胚角色 + +用户最新反馈明确:仍然以陶泥人、陶泥手办、抽象角色 / 吉祥物为主方向,但设计时不一定使用人体形象,造型要更简单、更几何、更扁平、更有创意。因此本轮把方向校正为“抽象泥胚角色”:不是完整小人,也不是纯几何系统图标,而是一枚像有生命的陶泥作品主标。 + +设计判断: + +- 保留:陶泥手办的亲和力、可爱度、IP 延展、被捏出的生命感。 +- 削弱:头身四肢、复杂五官、头像感、儿童黏土课和插画感。 +- 强化:单一剪影、偏心孔洞、星核、捏痕、少色、扁平矢量和小尺寸识别。 + +### A. 极简抽象泥胚批次 + +这一批使用 VectorEngine `gpt-image-2-all` 生成,prompt 明确约束“不要人形、不要脸、不要手脚”,只保留一个主轮廓、一个孔洞 / 作品核和一个小星点,用于寻找更像主 Logo 的自由轮廓。 + +![陶泥儿 Logo 极简抽象泥胚总览](../../public/branding/taonier-logo-abstract-mascot-minimal-concepts/taonier-logo-abstract-mascot-minimal-contact-sheet.png) + +本批次结论: + +```text +首选:01 泥芯主标 +强备选:03 泥种图符 +可爱但偏通用:02 泥标小偶 +不建议主标:04 模胚小芽 +``` + +`01 泥芯主标` 的优势是陶泥容器感、偏心黑孔和小星点比较集中,既有手捏陶泥的名字联想,也不像头像或人形。风险是口沿让它略像陶罐,后续人工矢量化时应压低“罐口”形态,让外轮廓更像被捏出的泥胚。 + +`03 泥种图符` 更接近“会呼吸的陶泥种子”,白色主体、黑色偏心孔和底部陶土色关系稳定,适合作为主标第二方向。后续应减少渐变和阴影,保留白泥主体、偏心孔、星核和底部陶土捏痕。 + +### B. image-2 抽象角色自由稿 + +这一批继续走 VectorEngine `gpt-image-2-all`,目标是找更有灵气的“非人形陶泥角色”轮廓。它不作为最终矢量稿,而是给后续人工重绘提供轮廓和气质参考。 + +![陶泥儿 Logo image-2 抽象角色总览](../../public/branding/taonier-logo-abstract-mascot-image2-concepts/taonier-logo-abstract-mascot-image2-contact-sheet.png) + +本批次结论: + +```text +灵气参考:01 泥灵符号 +造型参考:04 口袋泥符 +不建议主标:02 捏胚小偶、03 软陶图灵、05 作品泥偶、06 模团伙伴 +``` + +`01 泥灵符号` 最有“被捏出生命感”的味道,卷角和星核有记忆点,但黑色 App icon 底、角标和渐变需要重绘压平。它适合提炼成“软泥主体 + 星核 + 两个陶土捏点”的辅助参考。 + +`04 口袋泥符` 轮廓足够简单,中心星核清楚,但橙色主体过大,陶泥儿的亲和力偏向通用贴纸。它可以作为色彩和 Q 感参考,不建议直接定稿。 + +### C. 确定性矢量抽象泥偶批次 + +这一批由本地脚本直接生成 SVG / PNG,优点是结构可控、可进入后续矢量微调;缺点是灵气弱于 image-2 自由稿。 + +![陶泥儿 Logo 抽象泥偶 V2 总览](../../public/branding/taonier-logo-abstract-mascot-v2-concepts/taonier-logo-abstract-mascot-v2-contact-sheet.png) + +本批次结论: + +```text +可矢量化基准:01 陶泥小灵 +结构备选:04 软模团子 +成熟符号参考:05 泥芯圆偶 +暂不优先:02 捏孔泥偶、03 星胚图腾、06 作品泥符 +``` + +`01 陶泥小灵` 是当前最接近“非人形小陶泥角色”的可控矢量基准:单体轮廓、偏心陶土捏痕、星核和底部压扁站姿都成立。后续应删除单眼或将其改成更抽象的泥点,避免重新回到头像方向。 + +`04 软模团子` 更像一个被捏过的模具符号,品牌主标感强,但角色感稍弱。它适合和 `01 泥芯主标` 结合:保留模具切口和中心作品核,减少底部横条。 + +### D. 第一轮抽象泥偶批次 + +![陶泥儿 Logo 抽象泥偶总览](../../public/branding/taonier-logo-abstract-mascot-concepts/taonier-logo-abstract-mascot-contact-sheet.png) + +这一批验证了“抽象角色”方向,但多数方案仍偏头像、面具或机器人。仅 `01 陶泥豆偶` 和 `06 作品泥灵` 的轮廓关系可保留为参考,其余不建议继续。 + +## 3. 几何抽象历史探索 + +用户一度反馈“陶泥人 / 手办”方向不喜欢,不一定要人形,希望更简单、更几何、更有创意。因此本轮曾转向确定性几何符号:少元素、强轮廓、可注册感、适合 App icon,同时保留“陶泥、捏痕、泥点、模具、作品核”的隐喻。后续用户澄清并不是要放弃陶泥人 / 手办 / 吉祥物精神,而是不要直接画人体;因此本节降级为历史探索和辅助符号库,不再作为当前主线。 + +![陶泥儿 Logo 几何抽象总览](../../public/branding/taonier-logo-geometric-concepts/taonier-logo-geometric-contact-sheet.png) + +生成脚本: + +```text +scripts/generate-taonier-geometric-logo-concepts.py +``` + +生成文件: + +```text +public/branding/taonier-logo-geometric-concepts/ +├─ taonier-geometric-offset-core.svg +├─ taonier-geometric-offset-core.png +├─ taonier-geometric-mold-chip.svg +├─ taonier-geometric-mold-chip.png +├─ taonier-geometric-pinched-tile.svg +├─ taonier-geometric-pinched-tile.png +├─ taonier-geometric-dual-plate.svg +├─ taonier-geometric-dual-plate.png +├─ taonier-geometric-dot-gate.svg +├─ taonier-geometric-dot-gate.png +├─ taonier-geometric-work-knot.svg +├─ taonier-geometric-work-knot.png +└─ taonier-logo-geometric-contact-sheet.png +``` + +### 3.1 偏心泥孔 + +![偏心泥孔](../../public/branding/taonier-logo-geometric-concepts/taonier-geometric-offset-core.png) + +定位:当前几何主线首选。 + +这个方向像一块被冲孔和按压过的软陶牌,偏心大孔和小泥点形成记忆点,整体足够简单,也不像人形、手办或插画。它能表达“作品核 / 泥点 / 可塑形模具”,适合作为成熟 App 主标继续打磨。 + +建议用途:主 Logo 首选、App icon、favicon。 + +### 3.2 模芯切片 + +![模芯切片](../../public/branding/taonier-logo-geometric-concepts/taonier-geometric-mold-chip.png) + +定位:更硬朗的平台符号。 + +这个方向有“模具芯片 / 作品切片”的感觉,平台和工具属性更强,但陶泥软感弱一些。适合做技术感更强的备选。 + +建议用途:平台入口、创作工具标识、主 Logo 备选。 + +### 3.3 捏痕方标 + +![捏痕方标](../../public/branding/taonier-logo-geometric-concepts/taonier-geometric-pinched-tile.png) + +定位:最贴合“被捏过”的几何符号。 + +这个方向两侧被挤压出缺口,中间作品核清楚,和“陶泥被捏塑”关联最强。风险是整体稍像 UI 控件或票券,需要后续让外轮廓更独特。 + +建议用途:主 Logo 强备选、生成按钮、品牌辅助图形。 + +### 3.4 双片合模 + +![双片合模](../../public/branding/taonier-logo-geometric-concepts/taonier-geometric-dual-plate.png) + +定位:表达两片材料合模成型。 + +这个方向动作感强,能看出上下两片材料夹出中心作品核。但红绿双条偏 UI 化,后续需要减少按钮感。 + +建议用途:生成动效、创作成功态,不建议直接做主 Logo。 + +### 3.5 泥点入口 + +![泥点入口](../../public/branding/taonier-logo-geometric-concepts/taonier-geometric-dot-gate.png) + +定位:入口 / 生成门方向。 + +这个方向有“泥点落入入口,作品生成”的隐喻,但略像锁、包或门。适合作为创作入口图标,不建议作为唯一主标。 + +### 3.6 作品结点 + +![作品结点](../../public/branding/taonier-logo-geometric-concepts/taonier-geometric-work-knot.png) + +定位:作品网络和多点共创。 + +这个方向几何清楚,但更像系统模块图标,陶泥儿名字关联弱。适合作为作品关系、模板组合、共创系统的辅助标识。 + +## 4. 陶泥人 / 手办角色历史探索 + +用户明确要求停止此前锚点底座方向,改以“陶泥人、陶泥手办、抽象角色 / 吉祥物”为主线重新设计。新方向的核心不是做复杂 IP 插画,而是把一个小陶泥角色压缩成可当 Logo 使用的品牌符号:轮廓简单、有陶泥手捏感、有一点灵感 / 作品星核,并能继续延展成表情、动效和 IP。 + +![陶泥儿 Logo 陶泥人角色总览](../../public/branding/taonier-logo-clay-mascot-concepts/taonier-logo-clay-mascot-contact-sheet.png) + +> 2026-05-14 用户反馈该批不喜欢,不一定要人形,造型需要更简单和几何;本节降级为历史探索,不再作为当前主线。 + +生成脚本: + +```text +scripts/generate-taonier-clay-mascot-logo-concepts.mjs +scripts/generate-taonier-clay-mascot-contact-sheet.py +``` + +生成文件: + +```text +public/branding/taonier-logo-clay-mascot-concepts/ +├─ taonier-clay-mascot-little-maker.png +├─ taonier-clay-mascot-figurine-token.png +├─ taonier-clay-mascot-soft-doll.png +├─ taonier-clay-mascot-creator-totem.png +├─ taonier-clay-mascot-idol-mask.png +├─ taonier-clay-mascot-pocket-figure.png +└─ taonier-logo-clay-mascot-contact-sheet.png +``` + +### 4.1 陶泥小人 + +![陶泥小人](../../public/branding/taonier-logo-clay-mascot-concepts/taonier-clay-mascot-little-maker.png) + +定位:最直观的陶泥人方向。 + +这个方向轮廓极简、记忆点强,胸前星点能承接“脑洞成型”。风险是剪影略像通用姜饼人,需要进一步把头身比例和手臂做得更像“被捏出的软陶角色”。 + +建议用途:主 Logo 备选、IP 原型、表情包和启动动效参考。 + +### 4.2 陶泥手办 + +![陶泥手办](../../public/branding/taonier-logo-clay-mascot-concepts/taonier-clay-mascot-figurine-token.png) + +定位:最接近完整吉祥物手办。 + +这个方向亲和、可爱、手办感强,但作为主 Logo 略像完整角色插画,细节和体积感偏多。后续若沿它继续,应大幅压缩五官、手臂和底座。 + +建议用途:IP 形象、运营视觉、品牌吉祥物,不建议未经简化直接做主 Logo。 + +### 4.3 软陶团子 + +![软陶团子](../../public/branding/taonier-logo-clay-mascot-concepts/taonier-clay-mascot-soft-doll.png) + +定位:软萌治愈方向。 + +这个方向更像一团软陶团子,亲和感强,但主标识别点偏弱,容易进入普通治愈头像或玩具形象。中心泥点可以保留,外轮廓需要更独特。 + +建议用途:新手引导、空状态、IP 辅助形象。 + +### 4.4 造物泥偶 + +![造物泥偶](../../public/branding/taonier-logo-clay-mascot-concepts/taonier-clay-mascot-creator-totem.png) + +定位:当前角色主线的主 Logo 首选。 + +这个方向最接近“角色 + 品牌图腾”的平衡:剪影简单、黑底识别强、中心星核明确,既有小陶泥人的亲和感,也不会过度像插画或头像。 + +优点: + +- 图腾化程度最高,适合继续矢量化。 +- 中央星核能承接 AI 生成、作品成型和精品内容。 +- 角色感存在,但没有复杂表情和具象服饰。 + +风险: + +- 头部和身体连接处仍需人工优化,让它更像陶泥手捏而不是普通小人。 +- 周围小星点应在正式主标中删减,只保留一个核心星核。 + +建议用途:当前主 Logo 首选、App icon、启动动效、IP 主体基础。 + +### 4.5 陶泥面偶 + +![陶泥面偶](../../public/branding/taonier-logo-clay-mascot-concepts/taonier-clay-mascot-idol-mask.png) + +定位:面偶 / 头像化方向。 + +这个方向做出了陶泥面偶的收藏感,但人物感、服饰感和头像感都偏强,容易变成具体角色头像,而不是平台主标。 + +建议用途:IP 角色探索,不建议作为主 Logo 主线。 + +### 4.6 口袋泥人 + +![口袋泥人](../../public/branding/taonier-logo-clay-mascot-concepts/taonier-clay-mascot-pocket-figure.png) + +定位:最适合 App icon 的小泥人方向。 + +这个方向造型简洁,黑底白形识别快,旁边金色泥点和星点能强化“泥点 / 灵感”记忆。它比 04 更轻快,比 01 更不像普通姜饼人。 + +建议用途:App icon 强备选、移动端启动图标、品牌小形象。 + +## 5. 锚点底座参考图历史探索 + +用户明确要求停止此前软泥合拍、旋涡、糖果粉绿等方向,改以新的黑底白标参考图为原型重做。新参考图的核心不是“可爱软泥”,而是“一个泥点落到作品底座上”:上方圆点代表泥点 / 灵感,中间竖线代表落点 / 生成锚点,下方叠层代表作品、小游戏或创作底座。 + +这一组使用确定性矢量方式生成,优先保留黑底白标的克制感和成熟 App icon 气质,再少量测试金色泥点作为品牌识别。 + +> 2026-05-14 用户已要求停止锚点底座方向,本节降级为历史探索,不再作为当前主线。 + +![陶泥儿 Logo 锚点底座参考图总览](../../public/branding/taonier-logo-anchor-concepts/taonier-logo-anchor-contact-sheet.png) + +生成脚本: + +```text +scripts/generate-taonier-anchor-logo-concepts.py +``` + +生成文件: + +```text +public/branding/taonier-logo-anchor-concepts/ +├─ taonier-anchor-core.svg +├─ taonier-anchor-core.png +├─ taonier-anchor-soft-slab.svg +├─ taonier-anchor-soft-slab.png +├─ taonier-anchor-work-stack.svg +├─ taonier-anchor-work-stack.png +├─ taonier-anchor-clay-drop.svg +├─ taonier-anchor-clay-drop.png +├─ taonier-anchor-creation-base.svg +├─ taonier-anchor-creation-base.png +├─ taonier-anchor-app-token.svg +├─ taonier-anchor-app-token.png +└─ taonier-logo-anchor-contact-sheet.png +``` + +### 5.1 泥点锚标 + +![泥点锚标](../../public/branding/taonier-logo-anchor-concepts/taonier-anchor-core.png) + +定位:最贴近新参考图的主 Logo 首选。 + +这个方向保留“圆点 + 竖向落点 + 菱形作品底座 + 下层托底”的核心结构,整体克制、稳、识别快。它已经不再依赖软萌插画,而是更像一个可长期使用的产品符号。 + +优点: + +- 与新参考图原型一致性最高。 +- 黑底白标小尺寸识别强,适合 App icon、favicon、启动页。 +- “泥点落到作品底座”能承接泥点、AI 生成、作品成型三层语义。 + +风险: + +- 当前底座菱形偏硬,后续可让转角更像被压出的软泥层。 +- 左侧小点需要确认是品牌特征还是噪音,正式版可以保留或删除。 + +建议用途:当前主 Logo 第一候选、品牌顶栏、App icon。 + +### 5.2 软泥层台 + +![软泥层台](../../public/branding/taonier-logo-anchor-concepts/taonier-anchor-soft-slab.png) + +定位:更柔和的底座版本。 + +这个方向把底座做成略带弧度的软泥层,更贴近“陶泥儿”的名字,但轮廓相对没 01 清楚。适合继续测试“更软,但不回到旧软萌路线”的平衡。 + +建议用途:主 Logo 备选,适合做品牌温度版。 + +### 5.3 作品叠层 + +![作品叠层](../../public/branding/taonier-logo-anchor-concepts/taonier-anchor-work-stack.png) + +定位:最强调 UGC 作品库和多玩法承载。 + +这个方向比 01 多一层底座,能表达“一个灵感生成多个作品 / 多个小游戏层”。优势是平台感强,风险是线条略多,小尺寸时比 01 更拥挤。 + +建议用途:平台主标强备选、作品库 / 创作中心辅助标识。 + +### 5.4 泥点落印 + +![泥点落印](../../public/branding/taonier-logo-anchor-concepts/taonier-anchor-clay-drop.png) + +定位:加入品牌色的泥点版本。 + +这个方向把上方泥点和中心落点改成暖黄色,品牌记忆更强,也更贴合“泥点”货币 / 消费单位。但如果主标追求极简高级,金色可能需要降饱和或只保留上方圆点。 + +建议用途:App icon 彩色版、泥点体系标识、会员 / 奖励场景。 + +### 5.5 创作底座 + +![创作底座](../../public/branding/taonier-logo-anchor-concepts/taonier-anchor-creation-base.png) + +定位:更抽象、更像生成台的版本。 + +这个方向弱化了封闭菱形,强调开放式底座和生成落点。它更像“创作工具 / 生成器”符号,但少了 01 的完整徽标感。 + +建议用途:生成入口、创作按钮、工作台辅助图形。 + +### 5.6 泥点应用标 + +![泥点应用标](../../public/branding/taonier-logo-anchor-concepts/taonier-anchor-app-token.png) + +定位:更接近正式 App icon 的双色版本。 + +这个方向在黑蓝底上使用奶白主形和金色泥点,记忆点更强,也更像可直接落地的应用图标。后续需要测试金色小点在 24px / 32px 下是否仍然清楚。 + +建议用途:App icon 彩色候选、移动端启动图标。 + +## 6. V3-03 软泥合拍 v2 + +这一组回到用户认可的“软泥合拍”参考,不再追求具体手感。核心保留上下两团软泥、中央星点、轻快合拍的生动感,同时明确避开手、眼睛、聊天气泡和表情包。 + +![陶泥儿 Logo 软泥合拍 v2 总览](../../public/branding/taonier-logo-squish-concepts/taonier-logo-squish-contact-sheet.png) + +> 2026-05-14 用户已要求停止此前方向,本节及后续软泥合拍、旋涡、V3、V2、V1 均降级为历史探索,不再作为当前主线。 + +### 6.1 软泥合拍 + +![软泥合拍](../../public/branding/taonier-logo-squish-concepts/taonier-squish-v2-pulse.png) + +定位:当前最贴近参考方向的主 Logo 首选。 + +这个方向保留了上下两团软泥和中央星点,整体干净、生动、年轻,没有手的老气问题,也没有播放器或聊天工具联想。 + +优点: + +- 与用户认可的原始参考最接近。 +- 抽象但不冷,亲和且容易做动效。 +- 元素少,小尺寸可继续优化。 + +风险: + +- 形体还可以更有专属轮廓,避免变成通用软块组合。 + +建议用途:主 Logo 优先打磨方向、启动动效、生成按钮。 + +### 6.2 弹力成型 + +![弹力成型](../../public/branding/taonier-logo-squish-concepts/taonier-squish-v2-bounce.png) + +定位:动感和弹性更强的版本。 + +这个方向有轻快的挤压感,但线条和高光更接近动态图标或运营图,正式主 Logo 需要进一步去掉装饰线。 + +建议用途:生成动效、交互反馈、品牌运动语言。 + +### 6.3 星隙合拍 + +![星隙合拍](../../public/branding/taonier-logo-squish-concepts/taonier-squish-v2-spark-gap.png) + +定位:品牌记忆点最强的主标备选。 + +这个方向用中间负形星建立强记忆点,比 01 更像一个可注册的标志。它的优势是“符号性强”,但白色星隙较大,后续需要优化比例,让上下软泥更像合拍而不是被星形切开。 + +建议用途:主 Logo 强备选,适合继续做专业矢量微调。 + +### 6.4 合拍星流 + +![合拍星流](../../public/branding/taonier-logo-squish-concepts/taonier-squish-v2-comet.png) + +定位:传播和裂变感。 + +这个方向的星流表达“梗 / 作品被传播出去”,但短线让画面稍碎,更适合运营动效而不是静态主标。 + +建议用途:分享成功、生成完成、活动视觉。 + +### 6.5 成型软标 + +![成型软标](../../public/branding/taonier-logo-squish-concepts/taonier-squish-v2-token.png) + +定位:成熟 App icon 方向。 + +这个方向最像完整的 App icon,收束度高、成熟度好。但它有一点眼睛 / 观察 / 球形标识的风险,需要通过中心点、上下轮廓和色彩微调降低误读。 + +建议用途:App icon 备选,不建议未经微调直接定稿。 + +## 7. V3-03 上下手感延展 + +这一组顺着用户反馈中“03 更像上下两只手”的方向继续打磨。目标是把“托住灵感、合掌成型”的感觉做成品牌符号,而不是具体手掌插画。 + +用户进一步评审后认为 01 “掌心星核”太具象,手感明显、手形难看且老气。该方向整体降级为历史探索,不建议继续作为主 Logo 主线。 + +![陶泥儿 Logo 上下手感延展总览](../../public/branding/taonier-logo-hands-concepts/taonier-logo-hands-contact-sheet.png) + +### 7.1 掌心星核 + +![掌心星核](../../public/branding/taonier-logo-hands-concepts/taonier-hands-v2-cradle.png) + +定位:上下手感方向的主 Logo 首选。 + +这个方向最直接地保留了“上下两只手托住灵感”的感觉,同时仍是抽象软形。中央星核建立视觉焦点,能够承接“用户把脑洞交给 AI,一起捏成作品”的产品隐喻。 + +优点: + +- “托住 / 合捏 / 成型”的动作最明确。 +- 亲和力强,远离播放器、聊天和表情包联想。 +- 适合做轻微动效:上下软掌合拢,星核亮起。 + +风险: + +- 左侧线条有一点真实手指感,正式矢量化时应减少指节暗示,让它更像软泥托形。 + +建议用途:主 Logo 备选首选、生成按钮、启动动效、AI 共创标识。 + +### 7.2 合掌成型 + +![合掌成型](../../public/branding/taonier-logo-hands-concepts/taonier-hands-v2-clap.png) + +定位:最简洁、最主流的上下手感方案。 + +这个方向用上下两片大软形和中心圆点表达“合掌成型”,小尺寸识别会比 01 更稳。它的问题是整体有一点“眼睛 / 观察”联想,后续需要调整中心圆点和上下弧线比例。 + +建议用途:主 Logo 强备选,适合继续做专业矢量微调。 + +### 7.3 软掌托碗 + +![软掌托碗](../../public/branding/taonier-logo-hands-concepts/taonier-hands-v2-bowl.png) + +定位:创作容器和生成氛围。 + +这个方向更有场景感,像从掌心托出一个创意容器。它亲和、丰富,但装饰星点和喷溅线稍多,主 Logo 使用前需要大幅精简。 + +建议用途:创作页空状态、生成中插画、运营视觉。 + +### 7.4 双掌印记 + +![双掌印记](../../public/branding/taonier-logo-hands-concepts/taonier-hands-v2-seal.png) + +定位:完整、稳重、有泥印感的主标备选。 + +这个方向把上下软形合成一个圆润印记,中间负形有“被捏出”的感觉。它比 01 更不像真实手,也比 02 更有陶泥儿的“印记 / 成型”心智。 + +建议用途:主 Logo 备选;若后续想降低“手”的直观性,可以优先打磨这一版。 + +### 7.5 掌心开捏 + +![掌心开捏](../../public/branding/taonier-logo-hands-concepts/taonier-hands-v2-pop.png) + +定位:传播感和动效感。 + +这个方向更像“掌心弹出灵感”,年轻、轻松,适合做动效,但静态 Logo 里短线和星点稍碎。 + +建议用途:生成成功动效、活动视觉、引导页,不建议直接做主 Logo。 + +## 8. 横向发散造型补充 + +这一组在既有 V3 / 上下手感 / 一捏成型之外继续横向发散,刻意避开上一轮已经降级的播放键、聊天气泡、褐色陶土主色和碎元素。它更关注“平台入口、作品胚、游戏模芯、软体图腾、合捏火花”等造型。 + +![陶泥儿 Logo 横向发散总览](../../public/branding/taonier-logo-broad-concepts/taonier-logo-broad-contact-sheet.png) + +### 8.1 软泥入口 + +![软泥入口](../../public/branding/taonier-logo-broad-concepts/taonier-broad-soft-portal.png) + +定位:最接近“AI 创作入口”的平台符号。 + +这个方向像一枚被捏开的柔软门洞,中心星核留白明确,能承接“打开陶泥儿,进入创作”的心智。它保留了软泥和托举感,但没有真实手指、播放键或聊天气泡联想。 + +建议用途:主 Logo 强备选、创作首页入口、启动页核心动效。 + +### 8.2 作品胚芽 + +![作品胚芽](../../public/branding/taonier-logo-broad-concepts/taonier-broad-work-embryo.png) + +定位:精品作品和内容生长方向。 + +这个方向更像一颗正在成型的作品核,色彩偏青绿,整体高级、温和。它的产品语义更偏“作品孵化 / 创意生长”,和轻休闲小游戏的即时感弱一些。 + +建议用途:作品库、精选内容、创作者中心辅助标识;不建议优先做主 Logo。 + +### 8.3 游戏模芯 + +![游戏模芯](../../public/branding/taonier-logo-broad-concepts/taonier-broad-game-mold.png) + +定位:最强调“可玩”和小游戏平台属性。 + +这个方向把软泥主形和极简方向键 / 方块负形结合,能快速传达“生成出来的是可玩的互动作品”。深色底和强对比适合 App icon,但图形中的游戏控件联想较强,后续需要避免变成泛游戏平台图标。 + +建议用途:App icon 备选、玩法入口、游戏运行态品牌露出。 + +### 8.4 软体图腾 + +![软体图腾](../../public/branding/taonier-logo-broad-concepts/taonier-broad-soft-totem.png) + +定位:Taonier / 陶泥儿首字母感探索。 + +这个方向试图从软体纵向图腾里建立长期品牌记忆。它比普通字母标更柔软,也有捏塑感,但当前轮廓仍略像抽象数字或符号,正式使用前需要人工矢量重构。 + +建议用途:英文辅助标、favicon 探索、品牌图腾备选。 + +### 8.5 开捏火花 + +![开捏火花](../../public/branding/taonier-logo-broad-concepts/taonier-broad-creation-spark.png) + +定位:最稳定的“合捏生成”抽象主标备选。 + +这个方向把上下 / 左右的软形收成一个完整外轮廓,中心星核简洁,既能表达“开捏”,又比原始括号式结构更完整。它是本轮横向发散里最值得继续打磨的方向。 + +建议用途:主 Logo 强备选、生成按钮、AI 成功态和启动动效。 + +### 8.6 本轮未稳定产出的方向 + +`泥点皇冠`、`陶字负形` 和 `作品星轨` 的提示在本地 VectorEngine `gpt-image-2-all` 上多次超过 10 分钟仍未返回。后续若继续探索这些方向,建议先把 prompt 压短到单一造型,再单张运行,不要与批量任务混跑。 + +## 9. V3-03 一捏成型延展 + +这一组专门沿 V3 “一捏成型”继续打磨。目标是保留“两个软形触点 + 中央作品核”的成型瞬间,同时降低括号感、碰撞特效感和功能按钮感。 + +![陶泥儿 Logo 一捏成型延展总览](../../public/branding/taonier-logo-magic-dot-concepts/taonier-logo-magic-dot-contact-sheet.png) + +### 9.1 捏合星核 + +![捏合星核](../../public/branding/taonier-logo-magic-dot-concepts/taonier-magic-dot-orbit.png) + +定位:一捏成型方向的主标首选。 + +这个方向最稳地保留了“左右合拢、中央成型”的核心动作,中心青绿色星核形成了明确焦点,整体比原 V3-03 更完整,也没有明显播放器、聊天或表情联想。 + +优点: + +- 结构清楚,第一眼能看出“合拢生成”。 +- 元素少,小尺寸适配潜力好。 +- 中央星核可以做加载、生成成功、发布完成等动效延展。 + +风险: + +- 左右软形仍有一点括号感,后续矢量化可把外轮廓做得更不对称、更像被捏塑的软泥。 + +建议用途:主 Logo 备选首选、AI 生成按钮、启动动效核心符号。 + +### 9.2 成型印记 + +![成型印记](../../public/branding/taonier-logo-magic-dot-concepts/taonier-magic-dot-seal.png) + +定位:完整主标感最强的延展方向。 + +这个方向把左右触点收成一个更完整的软形图腾,减少了“两个括号”的割裂感。视觉上更像独立品牌符号,但也因此少了一点“捏合动作”的即时感。 + +建议用途:主 Logo 强备选;若选择它,后续应去掉背景底色并强化中心负形星点。 + +### 9.3 软泥合拍 + +![软泥合拍](../../public/branding/taonier-logo-magic-dot-concepts/taonier-magic-dot-squish.png) + +定位:轻松、年轻、动效友好。 + +这个方向的上下软形更活泼,适合表达“啪嗒一下成型”。但静态 Logo 中的黄色星点和短线略像特效贴纸,主标使用前需要继续简化。 + +建议用途:生成中动效、运营图、互动反馈,不建议直接定为主 Logo。 + +### 9.4 灵感模口 + +![灵感模口](../../public/branding/taonier-logo-magic-dot-concepts/taonier-magic-dot-mold.png) + +定位:最有“模口 / 造物容器”意味。 + +这个方向图形独特,和“从软泥模口里生成作品”的隐喻贴合。但外形复杂度比 01、02 更高,边缘细节在小尺寸下可能损失。 + +建议用途:主 Logo 备选探索,适合继续做专业矢量简化。 + +### 9.5 捏开灵感 + +![捏开灵感](../../public/branding/taonier-logo-magic-dot-concepts/taonier-magic-dot-bloom.png) + +定位:温和、包裹、生成容器。 + +这个方向亲和、平衡,但整体像眼睛 / 容器 / 开合结构,陶泥儿的“捏”动作弱一些。 + +建议用途:AI 生成入口、等待态、创作容器辅助图形。 + +## 10. 软泥合拍换色微调 + +这一轮不再改结构,也不改角度,只基于用户最认可的 `03 软泥合拍` 原图做配色和 Q 感微调。目标是保住“上下两团软泥 + 中央星点”的记忆点,只通过更甜、更轻、更亮的色彩关系,让它更像一个主流、亲和、容易记住的品牌符号。 + +![陶泥儿 Logo 软泥合拍换色总览](../../public/branding/taonier-logo-squish-recolor-variants/taonier-squish-recolor-contact-sheet.png) + +生成脚本: + +```text +scripts/generate-taonier-squish-recolor-variants.py +``` + +生成文件: + +```text +public/branding/taonier-logo-squish-recolor-variants/ +├─ taonier-squish-recolor-original-plus.png +├─ taonier-squish-recolor-candy-mint.png +├─ taonier-squish-recolor-peach-jelly.png +├─ taonier-squish-recolor-pop-bright.png +├─ taonier-squish-recolor-coral-soda.png +├─ taonier-squish-recolor-bubble-q.png +└─ taonier-squish-recolor-contact-sheet.png +``` + +推荐结论: + +- `02 糖果薄荷`:最均衡,保留原 03 的识别度,同时更轻、更甜。 +- `06 泡泡Q感`:最软萌,Q 感最强,适合做年轻化主视觉。 +- `04 亮彩出圈`:对比更强,适合传播场景和更醒目的入口图标。 +- `01 原版提亮`:最稳,几乎不改结构,适合作为保守版基线。 + +这一轮的结论是:如果后续只允许做“颜色修改或轻微可爱化”,优先沿原 03 继续做色彩细化,而不是重做造型。这样最能保住用户已经认可的那一下“软泥合拍”感觉。 + +## 11. 螺旋参考图延展 + +这一轮引入一张粗圆头黑白螺旋参考图,提取的是“向心旋转、包裹、揉合”的动势,而不是复刻黑白旋涡本身。生成时继续沿用前面认可的粉红、薄荷青、暖黄星点和软泥合拍语言,避免变成加载图、太极、棒棒糖或通用旋涡。 + +![陶泥儿 Logo 螺旋参考图延展总览](../../public/branding/taonier-logo-spiral-reference-concepts/taonier-logo-spiral-reference-contact-sheet.png) + +生成脚本: + +```text +scripts/generate-taonier-spiral-logo-concepts.mjs +scripts/generate-taonier-spiral-contact-sheet.py +``` + +生成文件: + +```text +public/branding/taonier-logo-spiral-reference-concepts/ +├─ taonier-spiral-reference.jpg +├─ taonier-spiral-soft-squish.png +├─ taonier-spiral-candy-roll.png +├─ taonier-spiral-star-core.png +├─ taonier-spiral-bouncy-clay.png +├─ taonier-spiral-creation-whirl.png +├─ taonier-spiral-soft-token.png +└─ taonier-logo-spiral-reference-contact-sheet.png +``` + +推荐结论: + +- `06 旋合软标`:最接近“原 03 软泥合拍 + 螺旋动势”的融合,整体完整,主标潜力最高。 +- `01 软泥旋合`:保留 Q 感和中心星点,亲和、可爱,适合继续做更扁平的人工矢量化。 +- `04 Q弹泥涡`:软萌感强,风险是中心星点偏贴纸感,适合做运营或启动动效参考。 +- `02 糖果泥卷`、`05 创作星涡`:最贴近参考图,但也最容易被误读成加载图、糖果卷或通用旋涡,主 Logo 优先级低于 01 / 06。 + +这一轮的结论是:螺旋参考图可以增强“AI 把灵感揉合成作品”的生成感,但主标不宜过分旋涡化。后续如果沿这条线继续,应以 `06 旋合软标` 为基准,把螺旋缝隙和上下软泥关系做得更像陶泥儿专属符号。 + +## 12. V3 抽象主标候选 + +V3 根据评审反馈重新避开了五个问题:播放三角、褐色陶土主色、聊天气泡 / 表情包、循环符号,以及过多碎元素。方向转为更抽象、更亮眼、更像长期主 Logo 的符号。 + +![陶泥儿 Logo V3 概念总览](../../public/branding/taonier-logo-v3-concepts/taonier-logo-v3-contact-sheet.png) + +### 12.1 灵感捏痕 + +![灵感捏痕](../../public/branding/taonier-logo-v3-concepts/taonier-v3-finger-spark.png) + +定位:主 Logo 首选。 + +这个方向用醒目的珊瑚红软形、指纹捏痕和星点负形建立记忆点。它不再依赖“陶泥的褐色”,而是用“被捏过的痕迹”表达陶泥儿的核心动作:用户把脑洞捏成作品。 + +优点: + +- 第一眼足够醒目,远离旧版褐色和播放器感。 +- 指纹捏痕有独特性,能承接“人人创作”和“亲手塑形”。 +- 元素少,适合继续矢量化和小尺寸适配。 + +风险: + +- 指纹弧线后续需要进一步简化,避免在 24px 以下变糊。 +- 星点比例要克制,避免变成普通灵感图标。 + +建议用途:主 Logo、App icon、平台顶栏、启动页、生成按钮。 + +### 12.2 脑洞种子 + +![脑洞种子](../../public/branding/taonier-logo-v3-concepts/taonier-v3-seed-pop.png) + +定位:创意生长与新手友好。 + +这个方向从“灵感发芽”切入,比陶泥更偏创造生命力。它亲和、可爱,但容易让用户联想到教育、植物、儿童启蒙或种植类产品。 + +建议用途:新手引导、创作孵化、儿童 / 寓教于乐支线,不建议作为主 Logo。 + +### 12.3 一捏成型 + +![一捏成型](../../public/branding/taonier-logo-v3-concepts/taonier-v3-magic-dot.png) + +定位:AI 把灵感合成为作品的瞬间。 + +这个方向很简洁,用左右两个软形触点和中心星点表达“捏合”。它避开了播放器和聊天气泡,也能做动效,但静态图形目前稍像碰撞特效或括号,需要继续重绘增强独特轮廓。 + +建议用途:生成按钮、AI 施法动效、主 Logo 备选微调方向。 + +### 12.4 作品胶囊 + +![作品胶囊](../../public/branding/taonier-logo-v3-concepts/taonier-v3-work-gem.png) + +定位:精品内容和作品沉淀。 + +这个方向更稳、更精品,青绿色也比褐色更吸睛。但整体像水滴、宝石或通用内容图标,和“捏”这个动作的关系弱。 + +建议用途:精选作品、作品库、创作者中心,不建议优先做主 Logo。 + +### 12.5 软体 T 形 + +![软体 T 形](../../public/branding/taonier-logo-v3-concepts/taonier-v3-soft-t.png) + +定位:英文辅助名 / Taonier 的抽象首字母。 + +这个方向试图做更品牌化的抽象符号,但当前形体还不够自然,也未形成足够强的“陶泥儿”心智。若未来英文名确定为 `Taonier` 或类似形式,可以继续沿这个方向做专业字母标重绘。 + +建议用途:英文标识探索,不作为当前主 Logo 首选。 + +## 13. V2 扁平矢量候选 + +第一批图形偏 3D 和拟物,更适合作为吉祥物、运营图或启动页气氛图,不适合作为长期主 Logo。V2 已把约束收紧为扁平、矢量、少元素、强轮廓和小尺寸可识别。 + +![陶泥儿 Logo 扁平概念总览](../../public/branding/taonier-logo-flat-concepts/taonier-logo-flat-contact-sheet.png) + +### 13.1 扁平开捏 + +![扁平开捏](../../public/branding/taonier-logo-flat-concepts/taonier-flat-play-clay.png) + +定位:最直接的主 Logo 候选。 + +这个方向用一团柔软陶泥承载播放符号,用户一眼能理解“点开玩 / 马上玩”,同时外形保留“捏出来”的不规则软泥感。 + +优点: + +- 识别速度最快,移动端小尺寸也成立。 +- 符合主流 App Logo 语言,亲和、不重、不技术冷。 +- 和“把脑洞捏成小游戏”的主张绑定最强。 + +风险: + +- 播放符号是常见母题,后续矢量化时要通过不规则软泥外轮廓、颜色和字标形成独特资产。 + +建议用途:主 Logo 首选、App icon、平台顶栏、分享卡片角标。 + +### 13.2 灵感泥星 + +![灵感泥星](../../public/branding/taonier-logo-flat-concepts/taonier-flat-spark-clay.png) + +定位:AI 创作与灵感生成。 + +这个方向比“扁平开捏”更品牌化,中心负形星点表达灵感、AI 生成和创意爆发。它没有播放符号那么直白,但更容易和“陶泥儿”的创作平台气质绑定。 + +优点: + +- 图形更简洁,品牌记忆点强。 +- 陶泥心智、AI 灵感和精品感比较平衡。 +- 适合未来扩成字标、启动页和生成态动效。 + +风险: + +- 对“小游戏/马上玩”的表达弱于播放符号。 + +建议用途:主 Logo 强备选、创作首页、AI 生成按钮和品牌主视觉。 + +### 13.3 造梗笑泥 + +![造梗笑泥](../../public/branding/taonier-logo-flat-concepts/taonier-flat-meme-smile.png) + +定位:社交传播和玩梗亲和力。 + +这个方向的气泡与笑脸非常亲和,适合表达“分享快乐”和“造梗”。但它和聊天、社区类产品的通用图形过近,作为主 Logo 可能会让用户误判产品品类。 + +建议用途:社区、评论、分享、活动贴纸,不建议做主 Logo。 + +### 13.4 共创泥环 + +![共创泥环](../../public/branding/taonier-logo-flat-concepts/taonier-flat-loop-mold.png) + +定位:AI 与用户共创闭环。 + +这个方向表达共创与循环,但生成结果带有偏柔和彩虹渐变的视觉倾向,与“陶泥儿”的软泥名称关联不够直观,也不如 01/02 容易记住。 + +建议用途:创作流程、共创能力、生成进度辅助图形。 + +### 13.5 精品泥印 + +![精品泥印](../../public/branding/taonier-logo-flat-concepts/taonier-flat-seal-blocks.png) + +定位:精品作品和内容集合。 + +这个方向像内容平台或作品库入口,能表达图片、用户、游戏等多形态内容。但图形元素较多,主标识别不如 01/02 凝练。 + +建议用途:精选作品、作品集、创作者中心、内容品质标识。 + +## 14. V1 立体探索 + +### 14.1 灵感陶团 + +![灵感陶团](../../public/branding/taonier-logo-concepts/taonier-clay-spark.png) + +定位:AI 共创与灵感造物。 + +这个方向把“陶泥”作为主视觉,内部用发光火花和节点表达 AI 赋能。它最贴“陶泥儿”名字本身,也能说明平台不是普通小游戏集合,而是从灵感生成作品的创作容器。 + +优点: + +- 与“陶泥儿”的名称绑定最强。 +- 有 AI、创作、造物的综合含义。 +- 适合启动页、品牌介绍、创作首页空状态。 + +风险: + +- 小尺寸下细节偏多,需要后续矢量化时压缩节点和纹理。 +- 如果色彩处理不当,会回到手工陶艺联想。 + +建议用途:品牌主视觉备选、官网/启动页、创作入口图形。 + +### 14.2 开玩模具 + +![开玩模具](../../public/branding/taonier-logo-concepts/taonier-play-mold.png) + +定位:把脑洞捏成小游戏。 + +这个方向用软陶捏出播放符号,最直接地连接“创作”和“马上玩”。它比单纯陶泥团更有产品动作,也更适合轻休闲、小游戏、短内容传播。 + +优点: + +- 识别强,小尺寸也清楚。 +- 与轻度休闲小游戏的关系最直接。 +- 适合作为 App icon 和主 Logo 图形。 + +风险: + +- 播放符号相对常见,需要后续在外轮廓、捏痕和色彩上做独特性。 +- 如果三角形过硬,会削弱“陶泥儿”的柔软感。 + +建议用途:主 Logo 首选、App icon、分享卡片角标、加载态图形。 + +### 14.3 造梗气泡 + +![造梗气泡](../../public/branding/taonier-logo-concepts/taonier-meme-bubble.png) + +定位:社交传播、玩梗、裂变。 + +这个方向把陶泥变形成聊天气泡和表情,强调“梗”和“传播”。它最有社交平台感,也适合表情包、活动贴纸和运营视觉。 + +优点: + +- 传播感强,年轻、轻松、容易做 IP 化。 +- 能承接社区、评论、分享和玩梗场景。 +- 比较容易延展成贴纸和表情包。 + +风险: + +- 偏软萌,可能削弱“精品 AI 创作平台”的质感。 +- 作为主 Logo 容易显得像聊天或表情产品。 + +建议用途:社区模块、活动运营、IP 辅助形象,不建议作为唯一主 Logo。 + +### 14.4 共创回路 + +![共创回路](../../public/branding/taonier-logo-concepts/taonier-creation-loop.png) + +定位:AI 与用户共同迭代生成。 + +这个方向用软陶带形成循环和造物轨迹,表达“灵感 -> AI 塑形 -> 用户修改 -> 作品传播”的闭环。它比其他方向更抽象,也更有平台级和工具级气质。 + +优点: + +- 高级、简洁,避免儿童化。 +- 适合表达 AI 共创、迭代和作品循环。 +- 可用于创作者工作台或生成进度标识。 + +风险: + +- 与“陶泥儿”名称的直观关联较弱。 +- 缺少小游戏和玩梗的即时识别。 + +建议用途:创作流程标识、AI 共创能力图标、品牌辅助图形。 + +### 14.5 精品泥印 + +![精品泥印](../../public/branding/taonier-logo-concepts/taonier-premium-seal.png) + +定位:精品内容、作品认证、创作者成果。 + +这个方向像一个被压印的软陶徽章,中间有方块和火花,比较适合表达“作品被打磨成型”。它的内容平台感强于游戏入口感。 + +优点: + +- 精品感和作品库气质较强。 +- 适合作品认证、精选、创作者徽章。 +- 与“陶泥压印”隐喻相对自然。 + +风险: + +- 细节较多,主 Logo 小尺寸可读性不如“开玩模具”。 +- 徽章感偏静态,轻休闲的即时性稍弱。 + +建议用途:精选作品标识、创作者荣誉、内容品质标签。 + +## 15. 推荐结论 + +优先级建议: + +```text +当前主 Logo 首选:抽象泥胚角色 A-01 泥芯主标 +当前主 Logo 强备选:抽象泥胚角色 A-03 泥种图符 +可控矢量基准:抽象泥偶 V2-01 陶泥小灵 +结构辅助参考:抽象泥偶 V2-04 软模团子 +灵气辅助参考:image-2 B-01 泥灵符号 +历史探索保留:几何抽象、具象陶泥人 / 手办、锚点底座、软泥合拍、螺旋参考图延展、V3 / V2 / V1 批次 +``` + +当前应停止继续推进软泥合拍、旋涡、糖果粉绿、锚点底座和具象小人插画路线。新的主线不是放弃陶泥人 / 手办 / 吉祥物,而是把它们消解成“抽象泥胚角色”:用一个像有生命的陶泥主形、一个偏心孔洞 / 作品核和少量星点,形成更成熟、更可注册、更像主 Logo 的符号。 + +后续优先围绕 `A-01 泥芯主标` 做人工矢量重绘:保留陶泥容器 / 泥胚的温度、偏心黑孔和小星点,但弱化陶罐口沿,避免被误读成传统陶艺品牌。若希望更轻、更像会动的小泥种,则以 `A-03 泥种图符` 为第二主线。 + +## 16. 后续落地建议 + +1. 基于 `A-01 泥芯主标` 做专业矢量重绘:保留一个主形、一个偏心孔、一个小星点,删除渐变和拟物口沿,避免过度像陶罐。 +2. 基于 `A-03 泥种图符` 做第二主线:保留白泥主体、偏心黑孔、底部陶土捏痕和星核,压平成纯矢量色块。 +3. 参考 `V2-01 陶泥小灵` 做可控 SVG 版本:把单眼改成泥点或负形捏痕,确保不是头像。 +4. 同步做 24px / 32px / 64px 小尺寸测试,淘汰小尺寸下像陶罐、表情包、头像、UI 控件或通用系统图标的版本。 +5. 输出黑底彩色、白底彩色、纯黑白、favicon 四套应用版,确认主标能脱离 App icon 底色使用。 +6. 字标不要直接使用生图结果,应单独设计“陶泥儿”中文字标,并准备英文辅助名。 +7. 正式应用前做商标近似检索,重点覆盖第 9、35、38、41、42 类。 +8. 若确认替换“百梦”,再更新现有命名规范文档、前端品牌组件、HTML metadata、后台和后端默认文案。 + +## 17. 纯形状发散新批次 + +上一轮“横向发散”仍然被判定不好看,所以本轮不再沿用软手、星核、入口、胚芽等惯性母题,而是转向更硬、更图形化的 App 标志草案:陶轮、模具窗格、骰面、口袋、舞台窗和印模孔洞。 + +![陶泥儿 Logo 纯形状发散总览](../../public/branding/taonier-logo-fresh-concepts/taonier-logo-fresh-contact-sheet.png) + +### 17.1 陶轮印记 + +![陶轮印记](../../public/branding/taonier-logo-fresh-concepts/taonier-fresh-wheel-imprint.png) + +定位:旋转的创作轮盘。 + +这个方向有速度感,也更像成熟消费级 App 主标。它已经远离软手和星核语言,适合继续测试小尺寸识别。 + +### 17.2 模具窗格 + +![模具窗格](../../public/branding/taonier-logo-fresh-concepts/taonier-fresh-mold-window.png) + +定位:多种作品从同一个模具里生成。 + +这个方向的四宫格负形有平台承载感,也能表达拼图、视觉小说、小游戏等多内容形态。但它更像系统入口图标,主 Logo 使用前需要进一步抽象。 + +### 17.3 泥点骰面 + +![泥点骰面](../../public/branding/taonier-logo-fresh-concepts/taonier-fresh-dot-dice.png) + +定位:泥点、玩法和随机脑洞。 + +这个方向游戏化最强,识别速度快,适合作为玩法或泥点体系衍生图标。主 Logo 风险是会被误读成骰子或桌游。 + +### 17.4 口袋世界 + +![口袋世界](../../public/branding/taonier-logo-fresh-concepts/taonier-fresh-pocket-world.png) + +定位:把脑洞装进口袋随手开玩。 + +这个方向亲和、有随身感,但图形稍像小世界插画,主标凝练度弱于陶轮和印模孔洞。 + +### 17.5 叙事舞台窗 + +![叙事舞台窗](../../public/branding/taonier-logo-fresh-concepts/taonier-fresh-stage-window.png) + +定位:视觉小说、RPG 与互动叙事。 + +这一版垂类内容方向很明确,适合叙事模板或视觉小说品牌分支;作为陶泥儿全平台主标会偏窄。 + +### 17.6 印模孔洞 + +![印模孔洞](../../public/branding/taonier-logo-fresh-concepts/taonier-fresh-punch-hole.png) + +定位:极简印模与作品冲孔。 + +这个方向轮廓最干净,是本批次里最适合继续做专业极简图标化的一张。后续可以把黑色主形、孔洞比例和两块彩色辅形继续压缩。 + +### 17.7 未稳定完成 + +`灵感绳结` 在本地 VectorEngine 上超时;`贴纸折角` 请求返回 429,上游提示当前分组负载饱和。后续如果继续追这组,建议把 prompt 再缩短,并改成单张串行跑。 + +## 18. 06 印模孔洞延展 + +这一组只沿 `12.6 印模孔洞` 继续,不再扩散到新母题。生成时把原 06 作为参考图输入,目标是保住“黑色不规则冲孔 + 中央白洞 + 右上珊瑚 / 左下青蓝”的识别关系,同时测试它能不能成为更稳的品牌主标。 + +![陶泥儿 Logo 06 印模孔洞延展总览](../../public/branding/taonier-logo-punch-hole-concepts/taonier-logo-punch-hole-contact-sheet.png) + +### 18.1 原型锁定微调 + +![原型锁定微调](../../public/branding/taonier-logo-punch-hole-concepts/taonier-punch-locked-shape.png) + +定位:不改变 06 基本造型的保守基准。 + +这一张基本保留原 06 的主轮廓、孔洞、右上红块和左下青块,只把边缘和比例做得更顺。它满足“先别动 06 本体”的要求,也适合作为后续人工矢量化时的基准稿。 + +建议用途:06 方向的锁定版、和原图并排做微调比较。 + +### 18.2 稳定主标 + +![稳定主标](../../public/branding/taonier-logo-punch-hole-concepts/taonier-punch-stable-icon.png) + +定位:本批次最值得继续打磨的主标方向。 + +这一张比原 06 更稳,黑色主形更像完整 App 标志,孔洞比例也更利于小尺寸识别。它仍然保留右上红、左下青的双辅形关系,没有偏离 06 的核心记忆点。 + +建议用途:主 Logo 候选、后续 24px / 32px / 64px 小尺寸测试。 + +### 18.3 孔洞比例 + +![孔洞比例](../../public/branding/taonier-logo-punch-hole-concepts/taonier-punch-hole-balance.png) + +定位:负形节奏测试。 + +这一张把黑色环形做得更圆、更符号化,孔洞也更规整。优点是识别干净,风险是个性少了一点,容易变成通用圆环标。它更适合作为比例参考,不建议直接定稿。 + +建议用途:孔洞比例、黑形厚薄和小尺寸识别测试。 + +### 18.4 彩色嵌合 + +![彩色嵌合](../../public/branding/taonier-logo-punch-hole-concepts/taonier-punch-color-inlay.png) + +定位:更年轻、更有活力的彩色版。 + +这一张的红青辅形和黑色主形嵌合得更自然,也更有“从泥板里取出作品碎片”的感觉。问题是彩色面积偏大,主标定稿前需要压低红青比例,否则会削弱黑色冲孔主形的记忆点。 + +建议用途:品牌活力版、运营场景、后续彩色比例压缩参考。 + +### 18.5 单色测试 + +![单色测试](../../public/branding/taonier-logo-punch-hole-concepts/taonier-punch-mono-test.png) + +定位:商标和极简场景测试。 + +这一张去掉了彩色辅形,只剩黑色冲孔和白色孔洞,证明 06 的核心轮廓在单色下仍然成立。但它也少了陶泥儿年轻、轻游戏的平台气质,不建议作为唯一主标。 + +建议用途:单色商标、印刷、极小尺寸兜底。 + +### 18.6 应用图标 + +![应用图标](../../public/branding/taonier-logo-punch-hole-concepts/taonier-punch-app-token.png) + +定位:App icon 图形强化版。 + +这一张黑色主形更饱满,右上红块比较稳定,但中心孔洞趋向圆,个性弱于 01 / 02。它适合拿来测试 icon 外框和启动页,但主标优先级低于 `13.2 稳定主标`。 + +建议用途:App icon 包装测试、启动页核心图形参考。 + +本批次结论: + +```text +06 延展首选:13.2 稳定主标 +不改基本造型基准:13.1 原型锁定微调 +彩色活力参考:13.4 彩色嵌合 +单色兜底参考:13.5 单色测试 +暂不直接定稿:13.3 孔洞比例、13.6 应用图标 +``` + +## 19. 04 彩色嵌合配色与中孔延展 + +这一组继续沿 `13.4 彩色嵌合` 推进。目标不是重新造型,而是在保持“圆润主环 + 右上珊瑚红 + 左下青蓝 + 中央孔洞”的基本结构下,测试两件事:第一,中间原本偏黑的主形是否可以换成更柔和的深色;第二,中央空心区域能否加入作品核、内窗或拼片内容,让它不只是白洞。 + +![陶泥儿 Logo 04 彩色嵌合配色与中孔延展总览](../../public/branding/taonier-logo-punch04-color-concepts/taonier-logo-punch04-color-contact-sheet.png) + +### 19.1 暖墨填芯 + +![暖墨填芯](../../public/branding/taonier-logo-punch04-color-concepts/taonier-punch04-warm-ink-core.png) + +定位:本批次最稳的保守延展。 + +这一张基本保住了 04 的三块嵌合关系,把纯黑主形降到温暖深灰,中间加入奶油色作品核。它的优势是没有大幅改结构,整体也比原 04 没那么硬。风险是灰色主形的品牌冲击力弱于黑色,需要继续测试更深一点的暖墨色。 + +建议用途:04 结构不变的主标微调基准。 + +### 19.2 靛蓝作品核 + +![靛蓝作品核](../../public/branding/taonier-logo-punch04-color-concepts/taonier-punch04-navy-game-core.png) + +定位:互联网 App 感测试。 + +这一张把主形改成模块化靛蓝环,色彩干净,但它已经明显偏离 04 的有机冲孔形态,更像通用系统图标或应用商店图标。中间作品核成立,但整体不建议继续作为 04 主线。 + +建议用途:色彩参考,不作为造型参考。 + +### 19.3 奶油内窗 + +![奶油内窗](../../public/branding/taonier-logo-punch04-color-concepts/taonier-punch04-cream-window.png) + +定位:柔和亲和版。 + +这一张把黑色主形大幅柔化,中央内窗也更像内容容器。优点是亲和、轻,但主形和红青辅形边界太软,品牌主标的冲击力不足。 + +建议用途:可作为启动页、运营图或浅色主题参考。 + +### 19.4 陶盒彩芯 + +![陶盒彩芯](../../public/branding/taonier-logo-punch04-color-concepts/taonier-punch04-clay-gradient-flat.png) + +定位:彩芯方向的失败参考。 + +这一张虽然有中间彩色泥芯,但外轮廓和色块位置已经明显变成新图形,不再像 04。它说明中孔内容不能做太复杂,也不能让彩色辅形绕到主形四周。 + +建议用途:不继续。 + +### 19.5 薄荷深影 + +![薄荷深影](../../public/branding/taonier-logo-punch04-color-concepts/taonier-punch04-mint-shadow.png) + +定位:清爽配色强备选。 + +这一张保留了 04 的动势,但主形换成深青绿后,气质更轻、更年轻。中央奶油小芯也比较克制。问题是左下青块变大后有一点抢主体,后续若继续,应压缩左下辅形,让主形重新占主导。 + +建议用途:04 配色强备选、年轻化品牌色参考。 + +### 19.6 内嵌拼片 + +![内嵌拼片](../../public/branding/taonier-logo-punch04-color-concepts/taonier-punch04-negative-tile.png) + +定位:中孔内容过度设计的参考。 + +这一张中间拼片语义明确,但外形已经变成对称徽章,失去了 04 的柔软不规则感。它也说明“中间内容设计”不宜出现太多模块,否则会像玩法图标而不是品牌 Logo。 + +建议用途:不继续。 + +本批次结论: + +```text +04 延展优先:14.1 暖墨填芯 +04 配色强备选:14.5 薄荷深影 +亲和浅色参考:14.3 奶油内窗 +只作色彩/反例参考:14.2 靛蓝作品核、14.4 陶盒彩芯、14.6 内嵌拼片 +``` + +下一步如果继续沿 04 打磨,建议以 `14.1 暖墨填芯` 的结构为基准,把主形加深到接近墨黑但保留暖度;中间只放一个非常克制的奶油色作品核,不再加入复杂拼片或多色内容。 + +## 20. REF-04 锁形配色与中孔填充 + +这一组不再使用 image-2 重新生成轮廓,而是直接读取 `13.4 彩色嵌合` 的像素轮廓做锁形换色:外轮廓、右上珊瑚红块、左下青蓝块和中央孔洞边界都保持不变,只替换主形颜色,并在中孔内部加入极简内容。因此这一组更适合判断配色和中孔策略,不适合评估新造型。 + +![陶泥儿 Logo REF-04 锁形配色与中孔填充总览](../../public/branding/taonier-logo-ref04-locked-color-concepts/taonier-logo-ref04-locked-color-contact-sheet.png) + +### 20.1 暖墨作品核 + +![暖墨作品核](../../public/branding/taonier-logo-ref04-locked-color-concepts/taonier-ref04-locked-warm-ink.png) + +定位:最稳的锁形主标基准。 + +这一版把纯黑主形改成暖墨色,中孔加入单个奶油色作品核。它保留了 REF-04 的结构,同时削弱了原黑色的生硬感。当前最适合继续微调,后续可以把暖墨再压深一点,保留柔和但不丢识别。 + +### 20.2 蓝墨作品核 + +![蓝墨作品核](../../public/branding/taonier-logo-ref04-locked-color-concepts/taonier-ref04-locked-blue-ink.png) + +定位:年轻互联网感。 + +深蓝主形比黑色更轻,也和青色辅形更协调。问题是蓝色会把陶泥儿的“软泥 / 手作”心智拉向科技或工具感,需要靠字标和品牌色系统补回温度。 + +### 20.3 梅紫双芯 + +![梅紫双芯](../../public/branding/taonier-logo-ref04-locked-color-concepts/taonier-ref04-locked-plum-ink.png) + +定位:内容生成感测试。 + +梅紫主形有记忆点,中孔双芯能表达多内容生成,但中孔内容稍显图标化。若继续,应把双芯合并成一个更柔软的小作品核。 + +### 20.4 墨绿小芯 + +![墨绿小芯](../../public/branding/taonier-logo-ref04-locked-color-concepts/taonier-ref04-locked-green-ink.png) + +定位:清爽配色测试。 + +墨绿主形让整体更轻、更亲和,但左下青色和主形色相接近,层次不如暖墨和蓝墨清楚。适合作为品牌辅助色参考,不建议优先做主标。 + +### 20.5 缩孔填芯 + +![缩孔填芯](../../public/branding/taonier-logo-ref04-locked-color-concepts/taonier-ref04-locked-shrink-core.png) + +定位:中孔缩小和内容填充测试。 + +这一版在不改孔洞边界的前提下,用奶油色内芯视觉上缩小了空洞,并放入红青两点。它证明中孔可以适当填充,但红青两点会把画面带向功能图标,正式版应更克制。 + +### 20.6 柔炭陶珠 + +![柔炭陶珠](../../public/branding/taonier-logo-ref04-locked-color-concepts/taonier-ref04-locked-soft-charcoal.png) + +定位:温和品牌感。 + +柔炭主形比暖墨更松弛,中孔陶珠也比较亲和。缺点是整体对比变弱,放到 32px 以下可能不如 `15.1 暖墨作品核` 清楚。 + +本批次结论: + +```text +锁形首选:15.1 暖墨作品核 +年轻配色备选:15.2 蓝墨作品核 +中孔缩小参考:15.5 缩孔填芯 +亲和辅助参考:15.6 柔炭陶珠 +暂不优先:15.3 梅紫双芯、15.4 墨绿小芯 +``` + +如果下一轮继续打磨 REF-04,建议保持 `15.1` 的单个奶油作品核,不再增加多点、多拼片或复杂图形;主形颜色在暖墨和蓝墨之间继续找一个更有品牌识别的深色。 + +## 21. REF-04 暖色主形与中孔星星 + +这一组继续锁定 REF-04 原型轮廓,只做两项变化:把中间黑色主形换成温暖色系,并在空心位置绘制一枚星星。右上珊瑚红块、左下青蓝块和整体冲孔结构保持不变。 + +![陶泥儿 Logo REF-04 暖色主形与中孔星星总览](../../public/branding/taonier-logo-ref04-warm-star-concepts/taonier-logo-ref04-warm-star-contact-sheet.png) + +### 21.1 陶土星 + +![陶土星](../../public/branding/taonier-logo-ref04-warm-star-concepts/taonier-ref04-warm-star-terracotta.png) + +定位:最贴近“陶泥儿”名字的暖色版。 + +陶土棕主形保留了足够对比,中心浅金星也比较轻,不会把中孔填得太满。它是本批次最值得继续微调的方向。 + +### 21.2 焦糖星 + +![焦糖星](../../public/branding/taonier-logo-ref04-warm-star-concepts/taonier-ref04-warm-star-caramel.png) + +定位:更亮、更甜的暖色版。 + +焦糖色比陶土棕更年轻,但也更接近食品或糖果联想。若后续品牌想更轻松可继续测试,否则优先级低于 `16.1 陶土星`。 + +### 21.3 可可星章 + +![可可星章](../../public/branding/taonier-logo-ref04-warm-star-concepts/taonier-ref04-warm-star-cocoa.png) + +定位:星星更明确的版本。 + +这一版在星星外加了奶油底托,识别更清楚,但中孔内容稍重,容易像徽章或按钮。后续如果保留底托,应缩小 15% 到 20%。 + +### 21.4 赤陶星 + +![赤陶星](../../public/branding/taonier-logo-ref04-warm-star-concepts/taonier-ref04-warm-star-rust.png) + +定位:暖色强备选。 + +赤陶色比 16.1 更红,和右上珊瑚块的关系更统一。整体温暖、亲和,但主形和红块色差变小,正式版需要略微拉开明度。 + +### 21.5 橄榄星 + +![橄榄星](../../public/branding/taonier-logo-ref04-warm-star-concepts/taonier-ref04-warm-star-olive.png) + +定位:偏自然的配色测试。 + +橄榄色主形与青蓝辅形关系较近,星星用红色后视觉中心偏硬。它更像辅助配色,不建议作为主标优先方向。 + +### 21.6 梅紫星 + +![梅紫星](../../public/branding/taonier-logo-ref04-warm-star-concepts/taonier-ref04-warm-star-plum.png) + +定位:成熟温暖版测试。 + +梅紫主形有记忆点,但和“陶泥儿”的泥感关联弱一些,中心青星也让色彩关系变复杂。适合保留为备选参考,不建议优先推进。 + +本批次结论: + +```text +首选:16.1 陶土星 +强备选:16.4 赤陶星 +更轻甜参考:16.2 焦糖星 +星星底托参考:16.3 可可星章 +暂不优先:16.5 橄榄星、16.6 梅紫星 +``` + +下一轮建议以 `16.1 陶土星` 为基准,保持星星单独存在,不加复杂底托;主形颜色可以在陶土棕和赤陶棕之间继续找更有品牌记忆的暖色。 + +## 17. REF-04 暖色主形与四角闪光星 + +这一组修正上一批的星形:中孔不再使用五角星,而是使用参考图中那种上下左右尖出的四角闪光星,并保留少量短光芒。REF-04 的外轮廓、红青辅形和中孔边界继续锁定不变。 + +![陶泥儿 Logo REF-04 暖色主形与四角闪光星总览](../../public/branding/taonier-logo-ref04-warm-sparkle-v2-concepts/taonier-logo-ref04-warm-sparkle-v2-contact-sheet.png) + +本批次结论: + +```text +首选:17.1 陶土四角闪光 +强备选:17.2 赤陶四角闪光 +轻甜参考:17.3 焦糖四角闪光 +低干扰参考:17.5 安静闪光 +暂不优先:17.4 可可四角闪光、17.6 梅紫四角闪光 +``` + +其中 `17.1` 最接近“陶泥儿”的暖泥感,星形也更接近参考图的四角闪光;`17.5` 去掉了周围小光芒,更安静,但品牌动感弱一点。后续建议在 `17.1` 的基础上继续微调星星大小和主形陶土色深浅。 + +## 18. REF-04 造型锁定与参考配色迁移 + +这一版按用户指定执行单张定向调整:锁定 `15.1 暖墨填芯` 的造型与分区关系,把参考软泥合拍图的粉红、薄荷青和黄色闪光语言迁移过来。原中间黑色主形改为暖黄色,中心空洞使用参考图中的四角闪光星和短光芒填充。 + +![陶泥儿 Logo REF-04 造型锁定与参考配色迁移](../../public/branding/taonier-logo-ref04-palette-transfer/taonier-logo-ref04-palette-transfer-contact-sheet.png) + +生成文件: + +```text +public/branding/taonier-logo-ref04-palette-transfer/ +├─ taonier-logo-ref04-palette-transfer-contact-sheet.png +└─ taonier-ref04-palette-transfer-warm-yellow-sparkle.png +``` + +结论:这版最严格满足“图一造型不变 + 图二配色迁移 + 暖黄色主形 + 四角闪光填充”的要求。后续若继续打磨,建议只微调暖黄色主形的明度和星星大小,不再改变外轮廓。 + +## 22. REF-04 淡暖黄与四角闪光星 v4 + +这一组使用 image-2 继续修正上一版反馈:中间主形不再使用土黄、脏黄或偏橙的暖黄,而是改为更温暖、低饱和、淡淡的奶油纸黄色;中心星星继续以四角闪光星参考裁剪图为准,重点避免被拉伸成细十字。 + +![陶泥儿 Logo REF-04 淡暖黄与四角闪光星 v4 总览](../../public/branding/taonier-logo-ref04-palette-refine-v4-concepts/taonier-logo-ref04-palette-refine-v4-contact-sheet.png) + +生成文件: + +```text +public/branding/taonier-logo-ref04-palette-refine-v4-concepts/ +├─ taonier-logo-ref04-palette-refine-v4-contact-sheet.png +├─ taonier-ref04-palette-refine-v4-cream-paper.png +├─ taonier-ref04-palette-refine-v4-warm-ivory.png +├─ taonier-ref04-palette-refine-v4-soft-champagne.png +└─ taonier-ref04-palette-refine-v4-pale-butter.png +``` + +本批次判断: + +```text +优先看:22.1 奶油纸淡黄、22.4 淡黄油暖白 +可作为更轻柔参考:22.2 暖象牙淡黄 +色彩高级但识别略弱:22.3 淡香槟暖黄 +不再使用:18 旧版土黄 +``` + +`22.1` 和 `22.4` 的中间黄色已经明显脱离旧版土黄,星星也不再是细长十字;`22.3` 的颜色最淡、最柔和,但粉红和薄荷青辅色也被一起降得偏软,小尺寸品牌识别可能弱一些。image-2 仍会轻微软化 REF-04 的外轮廓,若下一步要完全锁死造型,应以本批次的淡黄色与星星比例为参考,再回到确定性换色脚本或矢量稿做最终收口。 + +## 23. REF-04 填平中孔与中央亮星 v5 + +这一组根据用户给出的修改参考继续使用 image-2 精修 04 图标:补全左侧曲线,让淡黄主形更完整;将中央白色空心区域用同色淡黄填平;最后把四角闪光星改为更明亮的黄色并放在中央。 + +![陶泥儿 Logo REF-04 填平中孔与中央亮星 v5 总览](../../public/branding/taonier-logo-ref04-palette-refine-v5-concepts/taonier-logo-ref04-palette-refine-v5-contact-sheet.png) + +生成文件: + +```text +public/branding/taonier-logo-ref04-palette-refine-v5-concepts/ +├─ taonier-logo-ref04-palette-refine-v5-contact-sheet.png +├─ taonier-ref04-palette-refine-v5-filled-centered-spark.png +├─ taonier-ref04-palette-refine-v5-smooth-left-small-spark.png +├─ taonier-ref04-palette-refine-v5-balanced-bright-spark.png +└─ taonier-ref04-palette-refine-v5-solid-core-no-hole.png +``` + +本批次判断: + +```text +最接近本轮要求:23.1 填心居中亮星、23.3 平衡亮星 +更克制参考:23.2 顺滑左弧小亮星 +星星偏大参考:23.4 实体主形亮星 +``` + +`23.1` 和 `23.3` 都完成了中孔填平和亮黄星居中,左侧曲线也比 v4 更完整;`23.2` 更安静,但星星没有短光芒,视觉记忆点弱一点;`23.4` 的星星更醒目,但比例稍大,后续若作为主标应把星星缩小约 10%。 diff --git a/docs/prd/BABY_OBJECT_MATCH_EDUTAINMENT_TEMPLATE_PRD_2026-05-11.md b/docs/prd/BABY_OBJECT_MATCH_EDUTAINMENT_TEMPLATE_PRD_2026-05-11.md new file mode 100644 index 00000000..ca2cedf0 --- /dev/null +++ b/docs/prd/BABY_OBJECT_MATCH_EDUTAINMENT_TEMPLATE_PRD_2026-05-11.md @@ -0,0 +1,121 @@ +# 宝贝识物寓教于乐模板 PRD 2026-05-11 + +## 1. 目标 + +新增寓教于乐内容线的创作模板: + +```text +宝贝识物 +``` + +创作者必须通过该模板创作并发布作品后,用户才能在寓教于乐板块体验对应关卡。 + +本模板只服务儿童动作 Demo 内容线,不把普通教育题材作品自动归入寓教于乐。 + +## 2. 创作输入 + +创作者必须填写两个物品名称: + +1. 物品 A 名称; +2. 物品 B 名称。 + +两个名称都必须去除首尾空白后非空。当前阶段不新增题材、难度、计时、失败次数、分数、体力或递增规则。 + +## 3. 生成规则 + +提交后生成一份宝贝识物草稿,草稿包含: + +1. 模板 ID:`baby-object-match`; +2. 模板名称:`宝贝识物`; +3. 两个物品; +4. 两个物品图; +5. 游戏视觉主题包; +6. 作品标签。 + +素材使用 VectorEngine `gpt-image-2-all` / image-2 生成。图片生成只能走后端接口,前端不得读取、拼接或暴露 `VECTOR_ENGINE_API_KEY`。 + +为降低生成成本,创作提交后只生成两张原始图片:一张 `2x2` 素材 sheet 和一张单独场景背景图。`2x2` 素材 sheet 固定包含左上物品 A、右上物品 B、左下篮子、右下礼物盒。服务端必须按固定格切图,并把物品、篮子和礼物盒转成透明 PNG。只有透明抠图后的两个物品素材才允许写入草稿 `itemAssets` 并进入游戏运行态。左右手位置指示器属于运行态默认规则,使用项目内置静态素材,不在每次创作时生成。 + +同一次创作还必须生成游戏视觉主题包,必需资源为背景环境、礼物盒、篮子。主题包必须继续保持寓教于乐插画风,并根据用户填写的两个物品关键词匹配主题:例如关键词偏动漫角色或玩具时,背景环境和元素可使用动漫、玩具主题;关键词偏水果时,背景环境和元素可匹配果园、自然主题;其它关键词按其语义匹配合适主题。主题包不得改变关卡玩法规则,不新增文字说明、额外按钮或额外判定规则。 + +视觉主题包的资源边界: + +1. 背景环境图不做透明抠图,但必须保证屏幕中间、中下方和底部左右篮子区域清爽,不遮挡放大后的物品、礼物盒和篮子; +2. 礼物盒资源从 `2x2` 素材 sheet 右下格切出,输出为透明 PNG,运行态按当前礼盒视觉的 2 倍尺寸展示,素材主体必须饱满清晰; +3. 篮子资源从 `2x2` 素材 sheet 左下格切出,输出为透明 PNG,运行态按当前篮子视觉的 1.5 倍尺寸展示,左右篮子仍固定为两个物品对应选项,篮子造型资源可以复用同一张主题篮子图;篮子切图不得保留手柄、篮口或边缘处的白底描边和抠图毛边; +4. 运行态左右手位置指示器使用内置默认静态素材,姿势为用户第一人称看到的半抓握手,不随创作关键词重新生成; +5. 礼物盒打开时的烟雾弹出特效由运行态 CSS 动效兜底;历史草稿如果已有 `smoke-puff` 资源可继续兼容读取,但新生成链路不再单独生成该资源。 + +当前本地 Demo 阶段已接入真实 image-2 资源链路。创作提交必须成功获得 `generationProvider = "vector-engine-gpt-image-2"` 的两个物品透明 PNG、背景环境图、礼物盒和篮子后,才能进入结果页、试玩或发布;若后端接口、登录态、VectorEngine 配置或上游生成失败,前端必须停留在生成失败状态并展示错误,不得静默回退为占位图。历史草稿中若仍存在 `generationProvider = "placeholder"` 的占位资源,结果页必须提示重新生成,试玩和发布前必须先补齐 image-2 资源。 + +## 4. 标签规则 + +发布作品必须携带精确标签: + +```text +寓教于乐 +``` + +标签识别只接受精确等于 `寓教于乐`。不接受 `儿童教育`、`动作教育`、`寓教于乐 ` 等近似标签。 + +宝贝识物草稿与发布 payload 中都必须保留该标签。发布后的公开展示、搜索、深链和入口开关继续遵循 `CHILD_MOTION_EDUTAINMENT_DISCOVER_ENTRY_2026-05-09.md`。 + +## 5. 结果页能力 + +结果页展示: + +1. 作品名称; +2. 两个物品名称; +3. 两个物品图; +4. 标签; +5. 保存草稿; +6. 发布; +7. 试玩。 + +结果页不展示长规则说明文案。试玩按钮直接进入宝贝识物首关本地运行态。 + +试玩按钮进入宝贝识物首关运行态,运行态消费当前草稿中的两个物品名称和两张物品图,不重新生成或改写物品内容。 + +若草稿包含视觉主题包,运行态还必须消费该主题包中的背景环境、礼物盒和篮子资源;左右手位置指示器始终使用内置默认静态素材。旧草稿或接口失败时允许回退到当前 CSS 绘本风兜底。历史草稿中若已有 UI 装饰、左右手或烟雾弹出特效资源,运行态仅做兼容读取或忽略,不作为新链路必需资源。 + +## 6. 发布后体验 + +发布完成后作品应进入寓教于乐内容线,并在寓教于乐入口开启时可被板块消费。 + +入口关闭时,发布作品完全不可见,不能通过推荐、发现普通频道、搜索、作品号、公开详情深链或浏览历史访问。 + +## 7. 与运行时线程的边界 + +本 PRD 同步约束首关运行态,已确认规则包括: + +1. 进入关卡后先展示两个目标物品:物品 A 居中展示 2 秒,名称 UI 与字体约为默认大小的 2 倍,随后物品和名称飞入左侧篮子预设位置,并在飞行过程中恢复为默认大小;左侧就绪后等待 1 秒,再展示物品 B 并飞入右侧篮子预设位置;全部就绪后等待 1 秒再进入礼物盒入场。 +2. 目标展示完成后,首次礼物盒自动打开并弹出首个随机物品;后续每次正确反馈完全结束后重新进入礼物盒入场。 +3. 每轮仅中间礼物盒跳出的物品随机;左右两侧篮子固定为当前草稿两个物品的顺序; +4. 下一关按钮当前占位; +5. 不新增用户未确认的计时、失败次数、分数、体力或难度递增。 +6. 屏幕中上方字幕固定为“将物品放入对应的篮子里”。 +7. 礼物盒位于屏幕中下方并按当前视觉放大一倍,首次进入关卡和每次正确反馈结束后的新轮次都从上方落下后自动打开。 +8. 屏幕下方左侧和右侧分别展示两个固定篮子,左侧固定使用草稿第一个物品图,右侧固定使用草稿第二个物品图。 +9. 左右篮子按当前视觉放大 50%,物品图标与篮子中心尽量对齐,物品图标下方展示对应物品名称 UI。 +10. 礼物盒打开时播放烟雾特效,中央物品从烟雾特效中弹出;物品弹出后礼物盒从舞台移除。 +11. 中央物品 UI 和左右篮子上方物品图标都使用固定正方形槽位,生成素材只在槽位内等比缩放;长条形物品不得拉伸外层 UI 框。 +12. 运行态实时展示用户左右手位置;任意一只手先接触中央物品 UI 后,中央物品绑定并跟随该手移动,手带物品进入左侧或右侧篮子区域时代表选择对应篮子;选篮不使用动作名判定,也不再使用左手固定选左篮、右手固定选右篮的规则。 +13. 正确时展示“真棒”字幕和正确特效;错误时展示“再想一想吧”字幕和错误特效,物品回到中央。 +14. 成功 20 次后展示“恭喜你!小朋友!”字幕和特效,并展示“再来一次”和“下一关”按钮。 +15. 当前本地 Demo 阶段音效与语音播报接口只预留调用点,不在前端写死外部硬件或服务接口。 + +## 8. 验收 + +1. 创作入口显示 `宝贝识物` 并可进入模板表单。 +2. 未填写任一物品名称时不能生成草稿。 +3. 生成草稿后进入结果页,展示两个物品名称和物品图。 +4. 生成草稿后包含视觉主题包,主题包含背景环境、礼物盒、篮子三类必需资源。 +5. 草稿标签中始终包含精确 `寓教于乐`。 +6. 发布 payload 始终包含精确 `寓教于乐`。 +7. 发布完成后出现分享弹窗或发布完成状态。 +8. 前端不读取或暴露 VectorEngine 密钥。 +9. 结果页试玩进入宝贝识物运行态,不再显示“试玩关卡正在接入中”。 +10. 运行态通过鼠标左键映射左手位置、鼠标右键映射右手位置;调试输入也必须先触碰中央物品,再拖入任一篮子完成选择。 +11. 成功 20 次后出现“再来一次”和“下一关”按钮。 +12. 使用长条形物品素材时,中央物品 UI 和篮子物品图标仍保持固定正方形槽位,只缩放物品本体。 +13. 运行态开局先完成两个目标物品的居中展示和飞入篮子动画,之后才出现礼物盒并进入首轮随机物品。 diff --git a/docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md b/docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md new file mode 100644 index 00000000..36aa7c74 --- /dev/null +++ b/docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md @@ -0,0 +1,274 @@ +# 宝贝识物创作发布实现方案 2026-05-11 + +## 1. 范围 + +本方案对应第 2 线程:创作发布线程。 + +本线程落地: + +1. 创作入口配置; +2. 模板表单; +3. 本地草稿生成 service; +4. 结果页; +5. 发布 payload 约束; +6. 本地 Demo 运行态; +7. 后端 image-2 / 作品持久化 / 运行态接口预留形状。 + +本阶段运行态先做浏览器本地 Demo,并消费现有本地 mocap 动作数据源;正式硬件接口和摄像头调教在后续接口稳定后继续接入。 + +## 2. 前端接入点 + +新增玩法 ID: + +```text +baby-object-match +``` + +用户展示名: + +```text +宝贝识物 +``` + +工程接入文件: + +1. `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs` +2. `src/components/platform-entry/platformEntryCreationTypes.ts` +3. `src/components/platform-entry/PlatformEntryCreationTypeModal.tsx` +4. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` + +`src/config/newWorkEntryConfig.ts` 已迁移删除,不再作为入口事实源。`baby-object-match` 必须存在于 SpacetimeDB `creation_entry_type_config` 默认种子中,默认展示名为 `宝贝识物`、`visible=true`、`open=true`、`sortOrder=90`;前端只通过 `GET /api/creation-entry/config` 读取后端配置并在 `platformEntryCreationTypes.ts` 做展示派生。 + +`baby-object-match` 必须复用 `VITE_ENABLE_EDUTAINMENT_ENTRY` 开关;开关关闭时,创作类型弹层不展示 `宝贝识物`,创作页作品架不展示本地宝贝识物草稿或已发布作品卡,公开发现、搜索、详情、作品号和浏览历史也继续完全不可见。 + +新增阶段: + +```text +baby-object-match-workspace +baby-object-match-generating +baby-object-match-result +baby-object-match-runtime +``` + +## 3. 契约 + +前端共享契约放在: + +```text +packages/shared/src/contracts/edutainmentBabyObject.ts +``` + +核心字段: + +1. `BabyObjectMatchDraft.templateId = "baby-object-match"`; +2. `BabyObjectMatchDraft.templateName = "宝贝识物"`; +3. `BabyObjectMatchDraft.themeTags` 必须包含精确 `寓教于乐`; +4. `BabyObjectMatchItemAsset.generationProvider` 首版允许为 `vector-engine-gpt-image-2` 或 `placeholder`; +5. `BabyObjectMatchDraft.visualPackage` 可选承载背景环境、礼物盒和篮子三类必需视觉资源;历史草稿中的 `ui-frame`、`smoke-puff`、`left-hand` 与 `right-hand` 仅保留运行态兼容读取或忽略; +6. `BabyObjectMatchPublishRequest.draft.themeTags` 发布前必须归一化补齐 `寓教于乐`。 + +## 4. Service 边界 + +前端 service 放在: + +```text +src/services/edutainment-baby-object/babyObjectMatchClient.ts +``` + +首版提供: + +1. `createBabyObjectMatchDraft(payload)`; +2. `saveBabyObjectMatchDraft(draft)`; +3. `publishBabyObjectMatchWork(payload)`; +4. `deleteLocalBabyObjectMatchDraft(profileId)`; +5. `regenerateBabyObjectMatchDraftAssets(draft)`; +6. `hasBabyObjectMatchPlaceholderAssets(draft)`。 + +当前后端正式作品持久化接口未在本线程扩表落地,因此 service 仍使用本地 Demo 存储草稿和发布状态。由于 image-2 会返回多张 base64 PNG 大图,本地 Demo 草稿必须优先写入 IndexedDB `genarrative-edutainment-baby-object-drafts/drafts`,不得把完整草稿 JSON 写入 `localStorage`;`localStorage` 仅作为旧版小草稿迁移读取来源,读取后迁移到 IndexedDB 并清理旧 key,避免触发浏览器 `Storage` 配额错误。 + +物品图片生成已接入后端 image-2 接口: + +```text +POST /api/creation/edutainment/baby-object-match/assets +``` + +请求体: + +```json +{ + "itemNames": ["苹果", "香蕉"] +} +``` + +响应体: + +```json +{ + "assets": [ + { + "itemId": "baby-object-item-1", + "itemName": "苹果", + "imageSrc": "data:image/png;base64,...", + "assetObjectId": null, + "generationProvider": "vector-engine-gpt-image-2", + "prompt": "..." + } + ], + "visualPackage": { + "themePrompt": "...", + "assets": [ + { + "assetId": "baby-object-visual-background", + "assetKind": "background", + "imageSrc": "data:image/png;base64,...", + "assetObjectId": null, + "generationProvider": "vector-engine-gpt-image-2", + "prompt": "..." + } + ] + } +} +``` + +该接口返回从同一张 `2x2` 素材 sheet 切出的两个物品透明 PNG、礼物盒透明 PNG、篮子透明 PNG,以及单独生成的一张背景环境图。本地 Demo 阶段暂不写入 OSS 或 SpacetimeDB `asset_object`。当前创作链路必须真实拿到 `generationProvider = "vector-engine-gpt-image-2"` 的两个物品图和必需视觉主题包后才允许进入结果页;若本地未配置 VectorEngine、登录态失效、接口返回 401/5xx、上游生成失败或响应缺少任一必需资源,前端 service 必须抛出错误并停留在生成失败状态,不得静默回退到占位图。左右手位置指示器是运行态默认静态素材,不在该接口中生成。 + +为了降低 image-2 调用成本,一次创作只发起两次图片生成:一次 `1024x1024` 的 `2x2` 素材 sheet,固定包含左上物品 A、右上物品 B、左下篮子、右下礼物盒;一次 `1536x1024` 的场景背景图。前端 `babyObjectMatchClient` 对该 POST 使用 10 分钟请求超时,且不做自动重试,避免第一次生成仍在后端执行时又发起第二次重复生成。后端并发启动两张图生成,并把该路由的 VectorEngine 单图请求等待预算提升到至少 8 分钟,避免某张图 3 分钟附近仍在生成时被后端提前断开。后端日志记录资源开始、完成和耗时,排查时优先按同一次 HTTP 请求查看 `宝贝识物 image-2 2x2 素材 sheet 生成完成`、`宝贝识物 image-2 场景资源生成完成` 与 `VectorEngine 图片生成上游错误`。 + +历史本地草稿中若已保存 `generationProvider = "placeholder"` 的旧占位资源,结果页必须提示“重新生成 image-2 资源”,并禁用试玩和发布。用户点击重新生成、发布或试玩前,前端统一调用 `regenerateBabyObjectMatchDraftAssets(draft)` 补齐资源;补齐失败时保留在结果页并展示错误。 + +后续正式作品持久化接入时,应补齐: + +```text +POST /api/creation/edutainment/baby-object-match/drafts +PUT /api/creation/edutainment/baby-object-match/drafts/{draftId} +POST /api/creation/edutainment/baby-object-match/drafts/{draftId}/publish +``` + +图片生成必须在后端调用 VectorEngine `gpt-image-2-all`,不得从前端直接调用外部图片接口。 + +后端 `2x2` 素材 sheet prompt 约束: + +1. 锁定寓教于乐板块统一的明亮卡通绘本插画风; +2. 固定四格布局:左上格物品 A,右上格物品 B,左下格篮子,右下格礼物盒; +3. 两个物品格只能围绕对应关键词生成单一主体,不生成背景、场景、人物、手、篮子、礼物盒、文字、水印或 UI; +4. 篮子格只生成一个主体饱满、开口清晰的大号篮子,不放入待分类物品,手柄和篮口镂空处不得留下白底描边或毛边; +5. 礼物盒格只生成一个主体饱满、中心构图的大号礼物盒; +6. 每格使用纯白或接近纯白背景,不绘制网格线、标签、按钮或边框; +7. 服务端按 `2x2` 固定格切图,并按单格边缘采样背景色转透明 PNG,返回的物品、篮子和礼物盒素材必须已完成透明背景后处理; +8. 篮子切图在通用透明背景处理后,还必须额外清理近白、低饱和的白底毛边,优先覆盖手柄镂空、篮口镂空和边缘残留白底;该处理仅应用于篮子格,不应用于两个物品格,避免误伤白色物品主体。 + +后端场景背景 prompt 约束: + +1. 背景图单独生成,总风格继续锁定寓教于乐明亮卡通绘本插画风; +2. 若关键词偏动漫角色、玩具或公仔,背景环境匹配动漫、玩具主题;若关键词偏水果,匹配果园、自然主题;其它关键词按语义匹配合适主题; +3. 背景环境图使用非透明横向图,但必须保证中间、中下方和底部左右篮子区域清爽,给放大后的礼物盒、中央物品和左右篮子预留空间; +4. 背景图不画入礼物盒、篮子、物品、人物、文字或操作 UI; +5. 左右篮子的固定选项规则不受主题包影响,运行态只把 `basket` 作为篮子造型包装复用。 + +运行态左右手位置指示器不随创作生成。默认素材保存在 `public/edutainment-baby-object/image2-picture-book-hands/baby-object-left-hand-v8-transparent.png` 与 `public/edutainment-baby-object/image2-picture-book-hands/baby-object-right-hand-v8-transparent.png`,姿势沿用图1的圆形手与斜向手臂结构,并按寓教于乐明亮绘本插画风完成 image2 填色和风格化处理。后续若要替换默认手型,应更新这两个静态资源和运行态 CSS 默认变量,而不是恢复每次创作的左右手 image-2 生成。 + +## 5. UI 边界 + +工作台只展示两个必填输入和生成按钮。 + +结果页只展示草稿核心信息、两个物品、保存草稿、发布、试玩。不在 UI 内写玩法说明长文案。 + +移动端优先:表单和结果页使用单列布局,桌面端自然扩展为双列。 + +## 6. 运行态边界 + +前端运行态放在: + +```text +src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx +``` + +运行态直接消费 `BabyObjectMatchDraft`,必须使用草稿中的两个物品名称和物品图。 +每轮只随机当前从礼物盒跳出的物品;左右篮子不随机交换,左侧固定为草稿 `itemAssets[0]`,右侧固定为草稿 `itemAssets[1]`。 + +若草稿包含 `visualPackage`,运行态通过背景图片层、CSS 变量和图片节点消费: + +1. `background`:作为舞台最底层 `ResolvedAssetImage` 背景图;存在该资源时必须关闭默认草地兜底层,避免生成场景被 CSS 草地遮住或弱化; +2. `gift-box`:替换 CSS 礼物盒主体,按旧视觉约 2 倍尺寸展示,只在礼盒入场和打开阶段存在; +3. `basket`:替换篮子主体造型,按旧视觉约 1.5 倍尺寸展示,左右两侧复用同一张主题篮子图; +4. 左右手位置指示器:始终使用运行态默认静态素材;历史草稿中若带有 `left-hand` / `right-hand` 资源,不再作为视觉包完整性或运行时皮肤来源。 + +左右篮子的选项 UI 必须以篮子中心线为基准居中展示:物品图标位于篮子上方,图标下方展示对应物品名称短标签,左侧固定展示草稿第一个物品,右侧固定展示草稿第二个物品。该名称标签是运行态 UI 的一部分,用于后续只看图案或只看名称的玩法变体预留,但当前不新增额外规则。 + +历史草稿若包含 `ui-frame` 或 `smoke-puff`,运行态继续兼容读取;新生成链路不再把这两类资源作为必需 image-2 产物。礼物盒打开烟雾特效优先使用 CSS 动效兜底,避免为了单个特效额外增加生图调用。 + +旧草稿或接口失败时 `visualPackage = null`,运行态继续使用现有 CSS 绘本风兜底。 + +中央物品 UI 与左右篮子上方物品图标必须使用固定正方形槽位,不允许因为生成物品是手机、长条玩具等窄长形状而拉伸外层 UI 框。素材图片在槽位内使用等比 `contain` 缩放,长条形状只缩小主体,不改变圆形 UI 框尺寸。 + +首关状态机: + +1. `intro-left-showing`:物品 A 居中展示 2 秒,名称 UI 和字体约为默认大小的 2 倍,不接受动作判定; +2. `intro-left-flying`:物品 A 和名称飞入左侧篮子预设位置,飞行过程中名称 UI 和字体恢复为默认大小,不接受动作判定; +3. `intro-left-ready`:左侧目标就绪后等待 1 秒,不接受动作判定; +4. `intro-right-showing`:物品 B 居中展示 2 秒,名称 UI 和字体约为默认大小的 2 倍,不接受动作判定; +5. `intro-right-flying`:物品 B 和名称飞入右侧篮子预设位置,飞行过程中名称 UI 和字体恢复为默认大小,不接受动作判定; +6. `intro-right-ready`:右侧目标就绪后等待 1 秒,不接受动作判定; +7. `gift-entering`:礼物盒从上方落下入场动画阶段,不接受动作判定;首次进入该状态必须发生在两个目标展示完成后,后续正确反馈结束后直接进入该状态; +8. `gift-opening`:礼物盒打开并播放烟雾特效阶段,不接受动作判定; +9. `item-appearing`:礼物盒从舞台移除,当前物品从烟雾中出现并停稳,不接受动作判定; +10. `active`:物品彻底出现后才开放选篮判定; +11. `correct`:展示“真棒”反馈,对应篮筐播放正确特效并停顿,成功次数加 1;特效完全结束后重新进入 `gift-entering`,下一轮礼物盒从上方落下,不重复目标展示; +12. `wrong`:展示“再想一想吧”反馈,物品弹回中央;反馈结束后回到 `active`,不重新随机物品; +13. `complete`:成功次数达到 20,展示“恭喜你!小朋友!”和按钮。 + +动作输入: + +1. 运行态实时展示左右手位置,手部位置来自 `useMocapInput` 的明确左/右手坐标; +2. 任意一只手先接触中央物品 UI 后,当前物品绑定到该手并跟随移动; +3. 绑定手带物品进入左侧篮子区域时选择左篮,进入右侧篮子区域时选择右篮; +4. 正确时沿用“真棒”反馈和对应篮筐特效,错误时物品弹回中央并回到可再次抓取状态; +5. 物品被某只手持有时,手部指示器不再压在物品图标中心;左手吸附到当前物品图标左下角,右手吸附到当前物品图标右下角,保持图案主体可读; +6. 不再使用“左手固定选左篮、右手固定选右篮”的规则,也不再使用连续横向轨迹阈值直接选篮。 + +运行态直接通过 `useMocapInput` 消费本地 mocap WebSocket `/stream`。宝贝识物优先使用 `general.limb_nodes` / `limb_nodes` 里的骨架手腕节点作为左右手指示器、抓取和选篮坐标;若当前帧没有骨架手腕,再回退到每只手的 `wrist` 挂点,最后才回退到 `hand.x / hand.y`。该策略只让 `useMocapInput` 额外暴露 `bodyJoints.leftWrist/rightWrist`,不修改全局掌心派生点规则,避免影响拼图、热身关和其它运行态。选篮不再通过 `wave_left_hand`、`wave_right_hand`、`wave` 等动作名触发;侧别为 `unknown` 的手部轨迹不参与抓取或选篮。动作判定只在 `active` 阶段开放,礼盒入场、礼盒打开、物品出现、正确反馈和错误反馈阶段收到的动作包必须清空持有状态并忽略,不允许跨阶段补判定。当前本地 mocap 输出的 handedness 按摄像头视角标记,宝贝识物运行态必须先换算为用户身体视角:骨架 `rightWrist` / `rightHand.wrist` / `rightHand` 坐标映射玩家左手,骨架 `leftWrist` / `leftHand.wrist` / `leftHand` 坐标映射玩家右手;换算只用于展示和抓取手身份,不再决定只能选择哪一侧篮子。草稿试玩、发布后正式体验和热身关后的本地 Demo 都复用同一个运行态,因此三条入口都必须具备同一套动作控制能力。 + +开发者调试输入: + +1. 鼠标左键按下并拖动:映射左手位置; +2. 鼠标右键按下并拖动:映射右手位置; +3. 调试输入同样必须先触碰中央物品,物品绑定到目标手后,再拖入左侧或右侧篮子完成选择。 + +运行态控制按钮不参与调试输入和选篮判定。左上角返回按钮、完成弹层按钮以及后续新增的运行态控制元素,其 `pointerdown` 不得被舞台拖拽逻辑 `preventDefault` 或指针捕获吞掉,保证游戏进行中仍可直接点击返回。 + +当前篮子判定仍只认篮子主体附近区域,但在上一版核心区基础上扩大约 50%;命中阈值为左篮 `x <= 0.36 && y >= 0.62`、右篮 `x >= 0.64 && y >= 0.62`,既避免物品尚未贴近篮子主体就提前判定,也避免贴到篮子边缘后仍难以命中。 + +运行态不得新增计时、失败次数、分数、体力或难度递增规则。 + +音效和语音播报当前只保留接口预留边界,正式语音接口后续接入。 + +## 7. 发布约束 + +发布前必须执行: + +1. 两个物品名非空; +2. 两个物品名对应的 asset 存在; +3. 标签补齐精确 `寓教于乐`; +4. `publicationStatus` 从 `draft` 变为 `published`。 + +发布后首版本地响应返回 `publicWorkCode`,用于分享弹窗;正式后端接入时 public code 生成规则需要纳入统一作品号服务。 + +## 8. 热身关衔接 + +`/child-motion-demo` 热身完成后的“开始游戏”按钮进入同一个 `BabyObjectMatchRuntimeShell`。 + +热身关独立 Demo 没有创作者草稿上下文,因此使用固定本地 Demo 草稿承载两个物品,仅用于热身关后验证首关体验;正式平台体验仍必须从 `宝贝识物` 模板创作发布后进入寓教于乐板块。 + +## 9. 验收命令 + +```bash +npm run test -- src/components/platform-entry/platformEntryCreationTypes.test.ts src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx src/services/edutainment-baby-object/babyObjectMatchClient.test.ts +cargo test -p api-server edutainment_baby_object --manifest-path server-rs/Cargo.toml +npx vitest run src/components/platform-entry/platformEdutainmentVisibility.test.ts src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/custom-world-home/creationWorkShelf.test.ts src/services/useMocapInput.test.ts src/services/child-motion-demo/childMotionDebugInput.test.ts src/routing/appRoutes.test.ts +npx eslint src/components/platform-entry/platformEntryCreationTypes.ts src/components/platform-entry/platformEntryCreationTypes.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --ext .ts,.tsx --max-warnings 0 +npm run check:encoding +npm run typecheck +npm run build:raw +``` + +若后续接入真实 Rust API 和 SpacetimeDB 表,再补充 `npm run api-server`、`/healthz`、Rust contract / api-server / spacetime-client 定向测试和 migration 表目录更新。 diff --git a/docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md b/docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md new file mode 100644 index 00000000..697b7a8f --- /dev/null +++ b/docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md @@ -0,0 +1,710 @@ +# 儿童动作识别互动玩法 Demo 热身关开发规格文档 + +> 日期:2026-05-09 +> 关联设计文档:[CHILD_MOTION_DEMO_WARMUP_LEVEL_DESIGN_2026-05-09.md](../design/CHILD_MOTION_DEMO_WARMUP_LEVEL_DESIGN_2026-05-09.md) +> 适用范围:儿童动作识别互动玩法 Demo 固定启动热身关 +> 文档性质:开发落地规格 +> 说明:本文只将已确认的热身关设计内容拆解为工程可执行规格,不新增未确认的玩法、文案或视觉设计。 + +## 1. 开发目标 + +热身关作为 Demo 启动后的固定流程,需要完成以下开发目标: + +1. 调用摄像头并识别用户和环境。 +2. 使用横屏比例展示热身关。 +3. 在屏幕中央地面生成绿色圆环,引导用户到达建议位置。 +4. 将用户实际位置生成纯描边小人指示器。 +5. 只对摄像头背景做虚化处理,表达隐私保护、屏蔽环境干扰,并营造空间感。 +6. 按固定步骤完成站位、招手、左右移动、挥动左右手检测。 +7. 记录用户左右移动距离和挥动手臂空间。 +8. 将记录结果仅保存在当前 Demo 体验会话内。 +9. 后续关卡使用热身记录的边界进行安全提醒和暂停恢复。 +10. 热身结束后进入关卡选择。 + +当前阶段先落浏览器本地 Demo。浏览器摄像头视频流仅作为舞台背景;热身动作检测以本地 mocap 动作数据源为准,通过 `useMocapInput` 连接 `http://127.0.0.1:8876/stream` 对应的 WebSocket 流,消费 `general.body.center_norm` 身体中心、手势和左右手坐标推进站位、招手与左右手挥动步骤。正式语音播报接口继续预留适配层,不阻塞前端热身流程、调试输入和页面表现骨架落地。 + +## 2. 非目标范围 + +热身关当前不包含以下内容: + +1. 不接入创作模块。 +2. 不作为可配置玩法模板提供给创作者。 +3. 不允许跳过步骤。 +4. 不允许系统自动进入下一步。 +5. 不设置动作检测最长等待时间。 +6. 不做特定用户识别。 +7. 不跨会话保存左右空间边界和手臂挥动空间。 +8. 不对手部细节进行识别,只对肢体进行区分。 +9. 本阶段不处理无硬件、拒绝摄像头、多人入镜、识别丢失等异常流程;这些问题记录为待决策事项,后续硬件与摄像头方案稳定后再重新设计。 + +## 3. 运行入口与流向 + +### 3.1 入口 + +用户进入 Demo 后,先进入热身关。 + +### 3.2 出口 + +用户完成热身关所有步骤后,进入关卡选择。 + +热身结束后展示“开始游戏”按钮,用户点击后进入宝贝识物首关本地 Demo。该入口只用于热身关后的本地体验验证;正式平台体验仍必须通过“宝贝识物”创作模板发布后,在寓教于乐板块进入。 + +### 3.3 固定流程顺序 + +热身关必须按照以下顺序执行: + +```text +进入热身关 +↓ +到达中央绿色圆环并保持 2 秒 +↓ +招手 / 摆手 +↓ +热身说明 +↓ +向左一步,到达左侧绿色圆环并保持 2 秒 +↓ +回到中间,到达中央绿色圆环并保持 2 秒 +↓ +向右一步,到达右侧绿色圆环并保持 2 秒 +↓ +回到中间,到达中央绿色圆环并保持 2 秒 +↓ +挥动左手 +↓ +挥动右手 +↓ +播放热身结束特效和结束语音 +↓ +进入关卡选择 +``` + +## 4. 页面基础表现规格 + +### 4.1 横屏比例 + +热身关需要使用横屏比例制作和展示,适用于电视屏幕、电脑屏幕等环境。 + +### 4.2 摄像头画面处理 + +用户进入热身关时调用摄像头。 + +摄像头画面处理要求: + +1. 识别用户和环境。 +2. 将用户实际位置生成纯描边小人指示器。 +3. 只对摄像头背景做虚化处理。 +4. 用户纯描边小人指示器用于表达用户在画面中的实际位置。 +5. 背景虚化用于表达对用户隐私的保护、屏蔽周围环境干扰,并营造空间感。 + +### 4.3 绿色圆环 + +绿色圆环用于指引用户到达指定位置。 + +绿色圆环出现位置包括: + +1. 屏幕中央位置的地面。 +2. 屏幕中心向左一个身位,约半米的地面位置。 +3. 屏幕中心向右一个身位,约半米的地面位置。 + +“约半米”技术上以角色剪影移动距离为准,后续根据体验调校。 + +### 4.4 绿色圆环选中状态 + +用户到达绿色圆环后,绿色圆环进入 2 秒选中状态。 + +用户需要在绿色圆环内保持 2 秒,才算完成该位置检测。 + +## 5. 通用交互规则 + +### 5.1 不允许跳过 + +每个步骤都必须由用户完成。 + +系统不提供跳过,也不自动进入下一步。 + +### 5.2 引导动画规则 + +每个动作等待 3 秒后可以播放对应引导动画。 + +当前不设置最长等待时间。 + +### 5.3 手势检测规则 + +招手 / 摆手、挥动左手、挥动右手三类动作需要有动作区分。 + +检测只区分肢体,不识别手部细节。 + +### 5.4 手势引导规则 + +挥动哪只手,就使用对应手的引导。 + +## 6. 状态机规格 + +### 6.1 状态列表 + +热身关至少需要支持以下流程状态: + +| 状态 ID | 状态名称 | 进入条件 | 完成条件 | 下一状态 | +|---|---|---|---|---| +| warmup_enter | 进入热身关 | 用户进入 Demo | 摄像头调用并展示中央绿色圆环 | center_arrive | +| center_arrive | 到达中央圆环 | 中央绿色圆环出现 | 用户到达中央圆环并保持 2 秒 | wave_greeting | +| wave_greeting | 招手教学 | 中央圆环完成并播放圆圈消失特效 | 用户完成招手 / 摆手 | warmup_intro | +| warmup_intro | 热身说明 | 招手 / 摆手完成 | 播放热身说明文案与语音 | move_left | +| move_left | 向左一步 | 热身说明完成 | 用户到达左侧圆环并保持 2 秒 | return_center_1 | +| return_center_1 | 回到中间(一) | 向左一步完成 | 用户到达中央圆环并保持 2 秒 | move_right | +| move_right | 向右一步 | 回到中间(一)完成 | 用户到达右侧圆环并保持 2 秒 | return_center_2 | +| return_center_2 | 回到中间(二) | 向右一步完成 | 用户到达中央圆环并保持 2 秒 | wave_left_hand | +| wave_left_hand | 挥动左手 | 回到中间(二)完成 | 用户完成挥动左手 | wave_right_hand | +| wave_right_hand | 挥动右手 | 挥动左手完成 | 用户完成挥动右手 | warmup_finish | +| warmup_finish | 热身结束 | 挥动右手完成 | 播放热身结束特效和结束语音 | level_select | +| level_select | 关卡选择 | 热身结束 | 进入关卡选择 | - | + +### 6.2 状态推进约束 + +1. 状态必须按顺序推进。 +2. 用户未完成当前状态检测目标时,不进入下一状态。 +3. 位置类状态必须满足“到达绿色圆环并保持 2 秒”。 +4. 动作类状态没有最长等待时间。 +5. 动作类状态等待 3 秒后可以播放对应引导动画。 +6. 每个步骤进入时需要先展示本步骤文字字幕和语音播报入口,约 1 秒后再进入可交互阶段并展示绿色圆环、手势引导等检测提示。 +7. 步骤完成后需要先进入完成停顿阶段,当前停顿约 0.8 秒;停顿期间保留完成反馈位置,后续可在该阶段补充完成特效或音效,再切换到下一步骤。 +8. 入场等待和完成停顿阶段不消费动作完成判定,避免用户上一步残留动作直接触发下一步。 + +### 6.3 开发者调试输入 + +本地 Demo 需要支持开发者调试模式,用于无摄像头和自动化验证场景。 + +调试映射如下: + +1. `A` 键映射用户向左移动。 +2. `D` 键映射用户向右移动。 +3. 鼠标左键按下并拖动映射左手轨迹。 +4. 鼠标右键按下并拖动映射右手轨迹。 +5. 空格键仅映射小人弹起调试动画,不触发流程推进。 + +调试输入只作为本地 Demo 与测试辅助,不代表正式动作识别硬件口径。正式摄像头接入后,位置和手势判断需要按摄像头硬件调教结果重新校准。 + +## 7. 分步骤开发规格 + +### 7.1 进入热身关 + +#### 展示内容 + +- 调用摄像头。 +- 识别用户和环境。 +- 屏幕中央地面显示绿色圆环。 +- 用户实际位置显示为纯描边小人指示器。 +- 只对摄像头背景做虚化。 + +#### 文案与语音 + +```text +欢迎你,小朋友,见到你真开心 +来圆圈这里和我打个招呼吧 +``` + +首个 `center_arrive` 步骤不显示顶部大标题,只显示字幕文案。第一句展示后停顿 2 秒,再切换到第二句;绿色圆环仍按步骤入场节奏约 1 秒后出现。 + +#### 检测目标 + +用户到达中央绿色圆环并保持 2 秒。 + +#### 完成反馈 + +播放圆圈消失特效。 + +--- + +### 7.2 招手教学 + +#### 展示内容 + +播放招手的手势引导。 + +用户进入该步骤 3 秒仍未完成动作时,可以播放引导动画。 + +#### 检测目标 + +用户完成招手 / 摆手手势。 + +#### 完成后 + +进入热身说明。 + +--- + +### 7.3 热身说明 + +#### 文案与语音 + +```text +你好呀小朋友,为了你玩的安全和开心,先来和我一起热个身吧 +``` + +#### 完成后 + +进入“向左一步”。 + +--- + +### 7.4 向左一步 + +#### 展示内容 + +屏幕中心向左一个身位,约半米的地面位置出现新的绿色圆圈。 + +#### 文案与语音 + +```text +向左一步 +``` + +#### 检测目标 + +用户到达左侧绿色圆环并保持 2 秒。 + +#### 完成反馈 + +```text +真棒 +``` + +#### 数据记录 + +记录本次向左移动距离,作为后续关卡中的左侧空间边界参考。 + +--- + +### 7.5 回到中间来(一) + +#### 展示内容 + +场地中心位置出现绿色圆圈。 + +#### 文案与语音 + +```text +回到中间来 +``` + +#### 检测目标 + +用户到达中央绿色圆环并保持 2 秒。 + +#### 完成反馈 + +```text +真棒 +``` + +--- + +### 7.6 向右一步 + +#### 展示内容 + +屏幕中心向右一个身位,约半米的地面位置出现新的绿色圆圈。 + +#### 文案与语音 + +```text +向右一步 +``` + +#### 检测目标 + +用户到达右侧绿色圆环并保持 2 秒。 + +#### 完成反馈 + +```text +真棒 +``` + +#### 数据记录 + +记录本次向右移动距离,作为后续关卡中的右侧空间边界参考。 + +--- + +### 7.7 回到中间来(二) + +#### 展示内容 + +场地中心位置出现绿色圆圈。 + +#### 文案与语音 + +```text +回到中间来 +``` + +#### 检测目标 + +用户到达中央绿色圆环并保持 2 秒。 + +#### 完成反馈 + +```text +真棒 +``` + +--- + +### 7.8 挥动左手 + +#### 展示内容 + +播放伸展手臂挥动左手的手势引导。 + +用户进入该步骤 3 秒仍未完成动作时,可以播放引导动画。 + +#### 文案与语音 + +```text +挥动左手 +``` + +#### 检测目标 + +用户完成挥动左手。 + +当前本地 mocap 的 handedness 按摄像头视角输出,热身关内需要先换算成用户身体视角再判断:摄像头右侧手对应用户左手。挥动左手不是普通横向轨迹检测,而是用于确认现实环境中用户左侧手臂打开空间足够和安全。 + +完成条件必须同时满足: + +1. 使用用户身体左手轨迹。 +2. 手腕在左肩外侧达到最小外展距离。 +3. 手腕不能处于自然下垂低位。 +4. 最近连续有效帧中,手臂存在足够上下摆动幅度。 +5. 最近连续有效帧中,肩膀到手腕向量的角度变化达到阈值。 +6. 至少出现一次上下摆动方向变化。 + +#### 完成反馈 + +```text +真棒 +``` + +#### 数据记录 + +记录用户挥动左手的轨迹、空间包络、角度范围和最大外展距离,保存为该用户对应的行为坐标。 + +--- + +### 7.9 挥动右手 + +#### 展示内容 + +播放伸展手臂挥动右手的手势引导。 + +用户进入该步骤 3 秒仍未完成动作时,可以播放引导动画。 + +#### 文案与语音 + +```text +挥动右手 +``` + +#### 检测目标 + +用户完成挥动右手。 + +当前本地 mocap 的 handedness 按摄像头视角输出,热身关内需要先换算成用户身体视角再判断:摄像头左侧手对应用户右手。挥动右手不是普通横向轨迹检测,而是用于确认现实环境中用户右侧手臂打开空间足够和安全。 + +完成条件必须同时满足: + +1. 使用用户身体右手轨迹。 +2. 手腕在右肩外侧达到最小外展距离。 +3. 手腕不能处于自然下垂低位。 +4. 最近连续有效帧中,手臂存在足够上下摆动幅度。 +5. 最近连续有效帧中,肩膀到手腕向量的角度变化达到阈值。 +6. 至少出现一次上下摆动方向变化。 + +#### 完成反馈 + +```text +真棒 +``` + +#### 数据记录 + +记录用户挥动右手的轨迹、空间包络、角度范围和最大外展距离,保存为该用户对应的行为坐标。 + +--- + +### 7.10 热身结束 + +#### 进入条件 + +用户完成挥动右手后,直接进入热身结束阶段。 + +#### 完成反馈 + +播放热身结束特效、上浮字幕和语音: + +```text +真厉害,你是我见过最聪明的小朋友 +别走开,现在开始我们的游戏吧 +``` + +#### 完成后 + +进入关卡选择。 + +## 8. 当前 Demo 体验会话数据 + +### 8.1 保存范围 + +以下数据仅在当前 Demo 体验会话内保存: + +1. 左侧空间边界。 +2. 右侧空间边界。 +3. 左手挥动空间。 +4. 右手挥动空间。 + +当前 Demo 体验会话数据需要满足: + +1. 用户刷新产品或退出产品后失效。 +2. 用户只关闭当前游戏关卡并重新进入时,可以直接来到开始游戏界面,不强制重复热身。 +3. 首版可使用前端运行时内存或同等生命周期容器保存;不得跨产品刷新持久化保存。 + +### 8.2 当前 Demo 体验会话定义 + +“当前 Demo 体验会话”指用户本次打开并体验 Demo 的过程。 + +当用户关闭 Demo、刷新页面、退出当前体验流程、重新进入 Demo,或更换设备后,系统不再沿用上一次热身记录的数据,需要重新完成热身关并重新记录。 + +### 8.3 仅会话内保存原因 + +采用仅当前 Demo 体验会话内保存的原因: + +1. 每名用户的身高、体型、动作幅度不同,安全边界和行为坐标会发生变化。 +2. 当前 Demo 不做特定用户识别,无法确认下一次体验的是否仍是同一名用户。 +3. 用户所处的体验环境可能变化,包括房间大小、摄像头位置、屏幕位置和站立距离。 +4. 为保证安全,每次体验都需要重新对环境和距离进行安全检查。 + +## 9. 后续关卡安全边界使用规则 + +后续关卡需要使用热身关记录的左右空间边界进行安全判断。 + +### 9.1 覆盖安全边界线 + +当用户身体主体覆盖安全边界线时,对应侧屏幕边缘出现虚影提醒。 + +### 9.2 超出安全边界线 + +当用户身体主体超出安全边界线时: + +1. 关卡内容暂停。 +2. 屏幕虚化。 +3. 屏幕中央地面出现绿色圆圈。 +4. 屏幕提示文案: + +```text +小朋友,要注意安全哦 +``` + +5. 用户需要回到中心绿色圆圈并保持 2 秒后,才能继续游戏内容。 + +## 10. 识别能力清单 + +热身关需要接入或实现以下识别能力: + +1. 摄像头调用。 +2. 用户识别。 +3. 环境识别。 +4. 用户实际位置识别。 +5. 用户是否到达中央绿色圆环位置。 +6. 用户是否在绿色圆环内持续保持 2 秒。 +7. 用户是否到达左侧约半米绿色圆环位置。 +8. 用户是否到达右侧约半米绿色圆环位置。 +9. 招手 / 摆手手势识别。 +10. 挥动左手识别。 +11. 挥动右手识别。 +12. 用户左右移动距离记录。 +13. 用户挥动手臂空间记录。 +14. 用户身体主体覆盖安全边界线判断。 +15. 用户身体主体超出安全边界线判断。 +16. 用户回到中心绿色圆环并保持 2 秒判断。 + +## 11. 表现能力清单 + +热身关需要实现以下表现能力: + +1. 横屏比例显示。 +2. 摄像头背景虚化。 +3. 用户位置生成纯描边小人指示器。 +4. 屏幕中央地面绿色圆环。 +5. 左侧约半米地面绿色圆环。 +6. 右侧约半米地面绿色圆环。 +7. 绿色圆环 2 秒选中状态。 +8. 圆圈消失特效。 +9. 招手手势引导。 +10. 伸展手臂挥动左手手势引导。 +11. 伸展手臂挥动右手手势引导。 +12. 热身结束特效。 +13. 上浮字幕。 +14. 语音播报。 +15. 安全边界虚影提醒。 +16. 关卡暂停时屏幕虚化。 +17. 关卡暂停时屏幕中央地面绿色圆圈。 +18. 关卡暂停提示文案。 + +角色剪影、绿色圆环、虚影提醒、圆圈消失特效、手势引导动画和热身结束特效的正式视觉资源将通过 gpt-image-2 设计和生成。本地 Demo 阶段可以先使用 CSS、Canvas 或临时占位资源实现相同交互位置与状态,不把占位资源写死为正式资产。 + +## 12. 固定文案与语音清单 + +以下文案需要作为屏幕中上方浮现文字,并同步语音播报。 + +正式语音播报后续接入语音播报功能接口。本地 Demo 阶段保留播报适配层与调用点,可先只展示文字,不强制生成或播放正式语音资产。 + +```text +欢迎你,小朋友,见到你真开心 +来圆圈这里和我打个招呼吧 +你好呀小朋友,为了你玩的安全和开心,先来和我一起热个身吧 +向左一步 +真棒 +回到中间来 +真棒 +向右一步 +真棒 +回到中间来 +真棒 +挥动左手 +真棒 +挥动右手 +真厉害,你是我见过最聪明的小朋友 +别走开,现在开始我们的游戏吧 +小朋友,要注意安全哦 +``` + +## 13. 开发验收标准 + +### 13.1 热身流程验收 + +1. 用户进入 Demo 后先进入热身关。 +2. 热身关使用横屏比例展示。 +3. 摄像头被调用。 +4. 用户位置显示为纯描边小人指示器。 +5. 摄像头背景被虚化。 +6. 中央、左侧、右侧绿色圆环可以按流程出现。 +7. 用户到达每个绿色圆环后,需要保持 2 秒才算完成。 +8. 每个步骤未完成时不能跳过,也不能自动进入下一步。 +9. 动作等待 3 秒后可以播放对应引导动画。 +10. 所有固定文案可以展示并语音播报。 +11. 完成全部热身步骤后进入关卡选择。 + +### 13.2 数据记录验收 + +1. 完成向左一步后,可以记录左侧空间边界。 +2. 完成向右一步后,可以记录右侧空间边界。 +3. 完成挥动左手后,可以记录左手挥动空间。 +4. 完成挥动右手后,可以记录右手挥动空间。 +5. 以上数据仅在当前 Demo 体验会话内保存。 +6. 重新进入 Demo 后,不沿用上一次热身记录,需要重新完成热身关。 + +### 13.3 后续关卡安全边界验收 + +1. 用户身体主体覆盖安全边界线时,对应侧屏幕边缘出现虚影提醒。 +2. 用户身体主体超出安全边界线时,关卡内容暂停。 +3. 关卡暂停时,屏幕虚化。 +4. 关卡暂停时,屏幕中央地面出现绿色圆圈。 +5. 关卡暂停时,展示提示文案: + +```text +小朋友,要注意安全哦 +``` + +6. 用户回到中心绿色圆圈并保持 2 秒后,游戏内容继续。 + +## 14. 不确定项与补充确认 + +当前需求已明确本文所需的热身关开发规格。 + +以下内容作为待决策事项保留,后续硬件、摄像头和正式关卡设计稳定后再补充: + +1. 具体接入的动作识别 SDK、硬件接口和摄像头接口。 +2. 无硬件、摄像头拒绝授权、多人入镜、识别不到用户、跟踪丢失等异常流程。 +3. 小人指示器、圆环、虚影提醒、特效、手势引导动画的正式资源文件命名。 +4. 绿色圆环、小人指示器、安全边界在线性空间或屏幕坐标中的正式计算公式。 +5. 正式关卡选择页与后续游戏关卡的具体页面结构。 + +## 15. 第 3 项本地 Demo 落地记录 + +本地浏览器 Demo 入口已落在: + +```text +/child-motion-demo +``` + +当前实现范围: + +1. `src/ChildMotionDemoApp.tsx` 挂载独立 Demo 应用壳。 +2. `src/components/child-motion-demo/childMotionWarmupModel.ts` 维护热身步骤、圆环目标、2 秒保持判定、热身校准记录和当前运行时会话完成标记。 +3. `src/components/child-motion-demo/ChildMotionWarmupDemo.tsx` 实现横屏舞台、背景虚化占位层、角色剪影、绿色圆环、手势引导、热身记录面板、热身完成后的“开始游戏”按钮,并复用宝贝识物运行态进入首关本地 Demo。 +4. `src/services/child-motion-demo/childMotionDebugInput.ts` 保留开发者调试输入适配层,后续可被正式动作识别 SDK 适配层替换或并行接入。 +5. `src/routing/appRoutes.tsx` 新增 `/child-motion-demo` 独立路由,并复用 `VITE_ENABLE_EDUTAINMENT_ENTRY` 开关;开关关闭时不允许通过该直达路径进入 Demo。 + +当前调试输入: + +1. `A` 键映射用户向左移动,松开后回到中心。 +2. `D` 键映射用户向右移动,松开后回到中心。 +3. 鼠标左键按下并拖动映射左手轨迹。 +4. 鼠标右键按下并拖动映射右手轨迹。 +5. 空格键仅映射小人弹起调试动画,不触发流程推进。 +6. 调试输入只在步骤可交互阶段触发步骤完成;步骤入场字幕阶段和完成停顿阶段会忽略完成判定,便于观察节奏和后续补充特效。 + +当前硬件和动作检测接口接入: + +1. 浏览器摄像头视频流已接入舞台背景。 +2. 热身关全流程已通过 `src/services/useMocapInput.ts` 接入本地 mocap WebSocket `/stream`;动作数据源状态优先于浏览器背景摄像头状态展示。 +3. mocap 包支持从 `general.body.center_norm` 读取身体中心,位置类步骤使用该身体中心更新小人指示器横向位置并完成圆环保持检测。 +4. 身体中心横向坐标进入小人指示器前必须做输入稳定化处理:先 clamp 到 `0..1`,再使用小幅死区、低通阻尼和单包最大步长限制,避免硬件噪声造成角色左右误判、画面抽搐或视觉上的忽大忽小。当前实现参数为死区 `0.012`、阻尼系数 `0.28`、单包最大步长 `0.035`;位置保持检测使用稳定化后的角色坐标。 +5. 小人指示器渲染需要把水平位移和跳跃表现拆开:外层只负责横向定位,内层资源只负责轮廓图和跳跃位移,避免 `left` 与 `transform` 同时抢占导致资源重采样抖动。 +6. mocap 包支持从 `actions/action/gesture/gestures/event/name/type` 读取动作名,并支持 `hands[]`、`leftHand/rightHand`、`left_hand/right_hand` 读取左右手坐标。 +7. `hands[].landmarks` 存在时优先用手腕和 MCP 点计算掌心中心;掌心点不足时退回 wrist landmark,再退回 hand 直出坐标。 +8. 热身舞台需要复用宝贝识物运行态的左右手指示器资源与样式,显示用户当前左右手位置;mocap 显示同样按摄像头视角换算成用户身体视角,用户左手使用 camera-right,用户右手使用 camera-left。手部指示器优先使用 `general.limb_nodes` / `limb_nodes` 中换算后同侧的 `right_wrist` / `left_wrist` 骨架手腕节点,骨架手腕缺失时再回退到手部 landmark 的 `wrist`,最后回退到 hand 直出坐标,避免手掌识别不稳时指示器跟随掌心抽搐。鼠标左键 / 右键调试时也同步显示同款左手 / 右手指示器。 +9. `wave_greeting` 只消费左手、右手或未知单手的连续横向挥手轨迹,不再使用 `wave`、`hand_wave`、`open_palm`、张手状态或动作名直接完成判定;进入轨迹判定前必须先满足抬手有效区:优先使用 `hands[].landmarks.wrist` 与 `general.limb_nodes` 的同侧 `*_elbow` / `*_shoulder` 判断,当前阈值为 `wrist.y <= elbow.y + 0.04`,缺少肘部时使用 `wrist.y <= shoulder.y + 0.08`;缺少同侧肘部和肩膀参考时不允许招呼通过,不再使用身体中心兜底判断抬手。轨迹阈值为至少 5 个连续抬手点,横向 `x` 范围差值不小于 `0.075`,且至少出现 1 次横向方向变化,避免“手刚露出画面”或“手自然下垂抖动”被误判为招手。 +10. `wave_greeting` 完成后直接进入 `warmup_intro` 的“准备热身 / 你好呀小朋友...”字幕节奏,不显示“真棒”完成飘字;后续位置移动、左右手挥动等正式热身步骤仍保留“真棒”反馈。 +11. `wave_left_hand` 和 `wave_right_hand` 只消费用户身体侧对应手的连续坐标轨迹,不再使用动作名、张手状态或 primary hand 兜底完成判定;本地 mocap handedness 当前按摄像头视角输出,因此用户左手使用 camera-right,用户右手使用 camera-left。完成判定必须同时满足对应肩肘腕外展、手腕非自然下垂、连续有效帧、横向范围、上下摆动范围、肩腕角度范围和上下方向变化,当前阈值为连续外展点不少于 5 个、横向 `x` 范围不小于 `0.055`、垂直 `y` 范围不小于 `0.08`、肩腕角度范围不小于 `28°`、外展距离不小于 `0.12`、手腕相对肩膀外侧距离不小于 `0.1`;后续以真实体验结果继续调参。 +12. 挥动右手完成后直接进入 `warmup_finish`,不再要求原地跳跃检测或记录跳跃空间。 +13. 键盘 `A/D/Space` 与鼠标左右键拖拽仍保留为本地 Demo 调试兜底,不代表正式硬件口径;其中 `Space` 只播放小人弹起调试动画,不推进热身流程。 + +当前未接入但已保留边界: + +1. 正式语音播报接口暂不接入,当前先展示热身文案。 +2. 后续关卡安全边界暂停逻辑暂未落地,当前只完成热身记录和宝贝识物首关本地 Demo 衔接。 + +## 16. 当前视觉资产与生图口径补充 + +儿童动作 Demo 的视觉口径已经统一收敛到绘本风格草地舞台: + +1. 舞台主环境采用卡通绘本风格、明亮草地、天空、小山坡和树木的组合,默认背景环境需要保证中心与下方前景留空,便于角色轮廓和地面指示环叠加。 +2. 该卡通绘本草地风格是儿童动作 Demo 后续场景、物品、UI 资源的全局风格要求;新增资源不得切回暗色科技风、真实照片风或后台面板风。 +3. `src/index.css` 中的热身舞台、摄像头背景层、地面、角色轮廓、地面圆环、开始按钮和横屏提示均按绘本草地风格接入真实资源;资源加载失败时保留 CSS 兜底。 +4. 生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 触发;脚本使用 `gpt-image-2-all` 调用 VectorEngine `POST /v1/images/generations`,透明资源先生成品红底源图,再在本地移除色键,源图写入 `tmp/child-motion-demo-assets/`。 +5. 当前已生成并接入以下正式 Demo 资源: + - `public/child-motion-demo/picture-book-grass-stage.png`:默认草地舞台背景。 + - `public/child-motion-demo/picture-book-foreground-grass-v2.png`:底部前景草坪条,只覆盖舞台下沿,不作为整块地板拉伸。 + - `public/child-motion-demo/picture-book-ground-ring-v3.png`:已按透视绘制的浅蓝与暖黄色地面椭圆指示环,和草地材质做明显区分,CSS 只等比缩放。 + - `public/child-motion-demo/picture-book-character-outline-v4.png`:用户位置小人指示器,基于 v2 本地后处理为更细的白色描边样式,中间完全透明,耳朵、手指、脚趾等细节已弱化;页面显示尺寸相对上一版放大 50%。 + - `public/child-motion-demo/picture-book-hud-strip-v2.png`:顶部 HUD 细长软纸条。 + - `public/child-motion-demo/picture-book-calibration-strip-v2.png`:右下角五格热身状态条。 + - `public/child-motion-demo/picture-book-start-panel-v2.png`:开始按钮背后的轻盈托盘。 + - `public/child-motion-demo/picture-book-ui-button-v2.png`:开始按钮绘本风按钮底图。 + - `public/child-motion-demo/picture-book-wave-cat-body-guide-v7.png`:招手阶段中央猫咪身体底座资源,按可动纸偶结构只包含猫头和短身体;v7 基于 v6 局部去除了身体左右两侧不协调的小圆点,不再和旧猫头、胸口或猫爪资源叠加。 + - `public/child-motion-demo/picture-book-wave-cat-arm-guide-v7.png`:招手阶段左右独立手臂资源,也用于左右手阶段单手提示;网页用同一拆件承接挥手摆动动画,但左手阶段使用 `picture-book-wave-cat-paw-left-v1.png`,右手阶段使用 `picture-book-wave-cat-paw-right-v1.png`,不再依赖同图镜像猜方向。v7 重点修正猫爪掌面朝向,末端圆猫爪必须正面对玩家,避免看起来朝内或朝向角色自己。 +6. v2 资源按最终用途拆分,CSS 必须按资源原始比例、`aspect-ratio` 或 `background-size: contain / auto` 等方式等比使用;禁止把方形面板强行拉伸为 HUD、状态条或地板,也禁止把底部草坪扩展成覆盖角色脚下的大色块。 +7. 猫咪招手引导拆件必须由 `.child-motion-gesture-guide__wave-cat` 父级统一承接上下浮动;身体层不再单独 bob,左右手臂只在同一父级坐标系内围绕肩部挂点旋转,并且手臂层级必须位于身体层前方。招手阶段使用独立全屏定位容器,猫咪整体放在上半屏幕、顶部字幕 UI 下方,避免压到地面小人指示器和圆环。当前 v7 资源的手臂贴近身体外缘摆放,左右侧距为 `12%`,左臂使用原图层与 `60% 78%` 旋转轴,右臂使用镜像图层与 `40% 78%` 旋转轴;左右手臂同步摆动,挥手动画周期为 `0.47s`,相对上一版约提速 50%,避免身体和手臂在动画过程中产生相对位移或压住胸口主体。8/11 挥动左手和 9/11 挥动右手阶段的单手猫猫手臂提示需要与打招呼双臂区分:不再使用左右招手式摆动,而是显示单侧外展安全弧线,并让猫爪沿外侧弧线做上下摆动,和“手臂外展、上下摆动幅度、角度变化、方向变化”的判定规则保持一致。 +8. 猫咪招手引导资源使用 `cat-guide` 透明后处理:先由 image-2 生成品红底源图,再通过边缘背景连通区域去背,避免把浅粉、淡橘和暖棕主体误删。源图只保存在 `tmp/child-motion-demo-assets/`,正式页面只引用 `public/child-motion-demo/` 下的最终 PNG。 +9. 若后续补充或重绘资源,应先运行 `npm run assets:child-motion-demo -- --dry-run` 核对 prompt 和输出路径,再使用 `--live --only ` 小批量生成;仅调整透明去背、裁切、画布归一、品红边缘、`character-outline-only-v3` / `character-outline-white-v4` 或 `wave-cat-body-guide-v7` 这种基于正式资源的局部后处理时,可用 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only ` 复用本地源图,不额外请求 image-2;不得把 `VECTOR_ENGINE_API_KEY`、源图或中间预览图提交到仓库。 + +已执行的定向验证命令: + +```bash +npx eslint src/components/child-motion-demo/ChildMotionWarmupDemo.tsx src/components/child-motion-demo/childMotionWarmupModel.ts src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx src/components/child-motion-demo/childMotionWarmupModel.test.ts src/services/child-motion-demo/childMotionDebugInput.ts src/services/child-motion-demo/childMotionDebugInput.test.ts src/services/child-motion-demo/index.ts src/ChildMotionDemoApp.tsx src/routing/appRoutes.tsx src/routing/appRoutes.test.ts --ext .ts,.tsx --max-warnings 0 +npx vitest run src/components/child-motion-demo/childMotionWarmupModel.test.ts src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx src/services/child-motion-demo/childMotionDebugInput.test.ts src/routing/appRoutes.test.ts +npm run check:encoding +``` diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index f65a7b87..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,6 +79,33 @@ 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.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 路径选择。 +- `server-rs/crates/api-server/src/puzzle/vector_engine.rs` 承接 VectorEngine 请求体、HTTP 调用、下载 / base64 解码、OSS 写入、asset object / binding 持久化和上游错误归一。 +- `server-rs/crates/api-server/src/puzzle/mappers.rs` 承接 SpacetimeDB record 到 shared-contracts DTO 的映射。 +- `server-rs/crates/api-server/src/puzzle/tags.rs` 保留拼图标签生成、拼图通用错误映射和 SSE helper。 + +该拆分只改变 `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 规则: @@ -84,14 +117,19 @@ npm run check:server-rs-ddd ## SpacetimeDB schema 变更规则 -1. 任何 table、reducer、procedure、row shape 或 bindings 变化,都必须同步 `server-rs/crates/spacetime-module/src/migration.rs`、本文件表目录和生成绑定。 +1. 任何 table、view、reducer、procedure、row shape 或 bindings 变化,都必须同步本文件表 / view 目录和生成绑定;真实 table 变化还必须同步 `server-rs/crates/spacetime-module/src/migration.rs`,view 属于派生投影,不写入迁移导入导出表清单。 2. 已有表新增字段必须放在 Rust 表结构体最后,并设置明确 `#[default(...)]`。 3. 删除字段、改名、重排字段、改类型或修改字段属性前,必须先询问用户并确认迁移计划。 4. Vec 字段不要直接写无法 const 求值的 default;需要默认空集合时优先使用 `Option>` 加 `#[default(None::>)]`,业务层归一为空数组。 -5. 修改后运行: +5. 运行态读表必须按已声明索引访问。只要 table 上存在覆盖查询前缀的 `#[index(...)]` 或主键 / unique accessor,列表、详情、快照组装和计数都先用对应 accessor `.filter(...)` / `.find(...)`,再在内存中处理索引无法覆盖的残余条件;不得用 `.iter().filter(...)` 扫整表替代现成索引。 +6. 面向公开列表的只读投影优先做成 public view / public 读模型表,并由 `api-server` 的 `spacetime-client` 长期订阅后读本地 cache。短期不把作品列表整体交给浏览器前端直接订阅;不要让 HTTP 列表接口每次请求都调用 procedure 重新组装全量列表。需要请求时间窗口的轻量统计可订阅公开统计表后在 `api-server` 本地聚合,需要写入副作用的详情、点赞、游玩记录仍可走 procedure / reducer。中期如要让前端可选直连订阅,只能新增或统一稳定的专用 public read model,例如 `public_work_gallery_entry`,并保持字段、排序键、公开权限和降级语义由后端投影定义;前端不得直接订阅 `puzzle_work_profile`、`custom_world_profile` 等领域源表,也不得自己做 join、聚合或权限逻辑。首屏、排序、字段归一、权限降级和 HTTP fallback 仍由 `api-server` BFF 维持。 +7. 多列索引按 SpacetimeDB 绑定生成的元组参数直接传入,例如 `.filter((source_type, profile_id, played_day))`;前缀查询只传前缀元组,例如 `.filter((scope_kind, scope_id.as_str()))`。不要为了绕过类型问题退回整表遍历。 +8. procedure result 必须返回 typed snapshot / typed value。`spacetime-client` mapper 不得再通过 `row_json/session_json/work_json/items_json/run_json/event_json/feedback_json: Option` 做跨层 JSON 字符串传输,也不得在 mapper 里反序列化旧 `*JsonRecord` 兼容结构。业务内部持久化字段如 `profile_payload_json`、`levels_json` 等不属于 procedure result 载荷例外,仍按各自表契约处理。 +9. 修改后运行: ```bash npm run spacetime:generate +npm run check:spacetime-runtime-access npm run check:spacetime-schema npm run check:server-rs-ddd ``` @@ -222,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` @@ -238,6 +276,7 @@ npm run check:server-rs-ddd - Rust 结构体:`BigFishCreationSession` - 源码:`server-rs/crates/spacetime-module/src/big_fish/tables.rs` +- 索引:`by_big_fish_session_owner_user_id`、`by_big_fish_session_stage`。公开广场 view 使用 `by_big_fish_session_stage` 读取已发布会话,避免扫整表。 ### `big_fish_event` @@ -249,10 +288,17 @@ npm run check:server-rs-ddd - Rust 结构体:`BigFishRuntimeRun` - 源码:`server-rs/crates/spacetime-module/src/big_fish/tables.rs` +### SpacetimeDB view:`big_fish_gallery_view` + +- Rust view:`big_fish_gallery_view` +- 返回类型:`Vec` +- 源码:`server-rs/crates/spacetime-module/src/big_fish/session.rs` +- 说明:大鱼吃小鱼公开广场列表投影,只从 `Published` creation session 组装公开卡片字段;`api-server` 的 `spacetime-client` 长期订阅 `SELECT * FROM big_fish_gallery_view` 与 `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'big-fish'` 后,从本地 cache 构造 `/api/runtime/big-fish/gallery` 响应。公开列表不再每个 HTTP 请求调用 `list_big_fish_works` procedure;个人作品列表、详情、点赞、游玩记录和 Remix 仍按原有 procedure / reducer 路径处理。 + ### `chapter_progression` - Rust 结构体:`ChapterProgression` -- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs` +- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs` ### `creation_entry_config` @@ -267,37 +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` @@ -312,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` @@ -334,15 +381,22 @@ npm run check:server-rs-ddd - Rust 结构体:`Match3DWorkProfileRow` - 源码:`server-rs/crates/spacetime-module/src/match3d/tables.rs` +### SpacetimeDB view:`match_3_d_gallery_view` + +- Rust view:`match3d_gallery_view` +- 返回类型:`Vec` +- 源码:`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` @@ -460,15 +514,64 @@ npm run check:server-rs-ddd - Rust 结构体:`PuzzleWorkProfileRow` - 源码:`server-rs/crates/spacetime-module/src/puzzle.rs` +### SpacetimeDB view:`puzzle_gallery_view` + +- Rust view:`puzzle_gallery_view` +- 返回类型:`Vec` +- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs` +- 说明:拼图广场公开详情兼容投影,只暴露 `publication_status = Published` 的作品,但返回完整 `PuzzleWorkProfile`,包含 levels / anchor_pack 等详情级字段;公开列表主路径不再订阅该 view。 + +### SpacetimeDB view:`puzzle_gallery_card_view` + +- Rust view:`puzzle_gallery_card_view` +- 返回类型:`Vec` +- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs` +- 说明:拼图广场公开列表卡片投影,只暴露前端列表卡片需要的公开字段,不携带 levels / anchor_pack 等详情级载荷;`api-server` 的 `spacetime-client` 长期订阅 `SELECT * FROM puzzle_gallery_card_view` 与 `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle'` 后,从本地 cache 构造 `/api/runtime/puzzle/gallery` 响应,并在本地按当前请求时间聚合 `recentPlayCount7d`,不再每个 HTTP 请求调用 `list_puzzle_gallery` procedure。 + +### 拼图公开列表 HTTP 窗口缓存 + +- 接口:`GET /api/runtime/puzzle/gallery` +- 响应契约:保留 `items` 字段兼容旧前端;当前 `items` 只返回前 10 个完整卡片,新增 `previewRefs` 返回后 10 个 `workId/profileId` 引用,并返回 `hasMore`、`nextCursor` 与 `totalCount`。 +- 缓存策略:`api-server` 在 `PuzzleGalleryCache` 中缓存最终 `PuzzleGalleryResponse` 的预序列化 data JSON。缓存 miss / 过期时单飞重建,避免并发请求重复排序、映射、DTO 深拷贝和 `serde_json::Value` 树构造;开启响应 envelope 时只按请求拼接轻量 meta,缓存短 TTL 刷新 `recentPlayCount7d`,后台 cleanup task 周期清理超过最大空闲窗口的旧响应。OTLP 通过 `genarrative.puzzle_gallery.cache.*`、`genarrative.spacetime.read.*`、`genarrative.http.server.response_bodies.in_flight` 和 `genarrative.http.server.request_permits.available` 区分缓存重建、SpacetimeDB 本地订阅读、响应 body 生命周期和 HTTP 背压状态。 +- 详情路径:公开详情、点赞、游玩记录和 Remix 仍按原有 procedure / reducer 路径处理;前端拿到 `previewRefs` 后如果需要展开更多内容,应优先使用后续列表窗口能力或详情 cache,不要把自动详情预取变成新的 procedure 热点。 + +### api-server 长期订阅读模型 + +`spacetime-client` 建立每个池连接时会等待下列订阅初始同步: + +- `SELECT * FROM puzzle_gallery_card_view` +- `SELECT * FROM custom_world_gallery_entry` +- `SELECT * FROM match_3_d_gallery_view` +- `SELECT * FROM square_hole_gallery_view` +- `SELECT * FROM visual_novel_gallery_view` +- `SELECT * FROM big_fish_gallery_view` + +下列订阅用于统计或配置缓存,订阅失败不会让公开列表连接整体不可用,调用方保留兼容兜底: + +- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle'` +- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'custom-world'` +- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'match3d'` +- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'square-hole'` +- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'visual-novel'` +- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'big-fish'` +- `SELECT * FROM creation_entry_config` +- `SELECT * FROM creation_entry_type_config` + +拼图、自定义世界、抓大鹅、方洞挑战、视觉小说和大鱼吃小鱼的公开列表 HTTP 路由都从订阅 cache 读取公开 read model / view。各玩法的个人作品列表、详情、发布、点赞、游玩记录、Remix 和其它需要鉴权或写入副作用的路径继续走 procedure / reducer;不要为了公开列表性能把这些 owner-specific 或 mutation 语义混进 public view。 + +`GET /api/creation-entry/config` 和入口熔断优先从订阅 cache 读取创作入口配置;cache 缺失时使用最近一次成功读取的内存快照,再兜底调用 `get_creation_entry_config` procedure 完成空库种子或旧库兼容。 + +未来可选:若发现页、推荐流和各玩法广场需要统一给浏览器前端直接订阅公开作品列表,只新增 / 统一专用 public read model,例如 `public_work_gallery_entry`。该 read model 必须是后端投影后的公开作品卡片契约,覆盖作品类型、公开作品号、标题、摘要、封面、作者展示名、排序键、公开统计和入口开关后的可见性,不暴露玩法领域源表 row shape。前端可选择订阅这个稳定投影来减少 HTTP 拉取,但不能订阅 `puzzle_work_profile`、`custom_world_profile` 等源表后自行拼装列表;BFF 仍保留首屏、SEO / 分享、旧客户端、订阅失败和灰度期间的 HTTP fallback。 + ### `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` @@ -505,15 +608,22 @@ npm run check:server-rs-ddd - Rust 结构体:`SquareHoleWorkProfileRow` - 源码:`server-rs/crates/spacetime-module/src/square_hole/tables.rs` +### SpacetimeDB view:`square_hole_gallery_view` + +- Rust view:`square_hole_gallery_view` +- 返回类型:`Vec` +- 源码:`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` @@ -528,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` @@ -569,3 +679,10 @@ npm run check:server-rs-ddd - Rust 结构体:`VisualNovelWorkProfileRow` - 源码:`server-rs/crates/spacetime-module/src/visual_novel.rs` + +### SpacetimeDB view:`visual_novel_gallery_view` + +- Rust view:`visual_novel_gallery_view` +- 返回类型:`Vec` +- 源码:`server-rs/crates/spacetime-module/src/visual_novel.rs` +- 说明:视觉小说公开广场列表投影,只暴露 `publication_status = published` 的作品卡片字段,不把完整 `draft` 暴露给公开列表订阅;`api-server` 的 `spacetime-client` 长期订阅 `SELECT * FROM visual_novel_gallery_view` 与 `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'visual-novel'` 后,从本地 cache 构造 `/api/runtime/visual-novel/gallery` 响应。公开列表不再每个 HTTP 请求调用 `list_visual_novel_works` procedure;个人历史、详情、运行态和发布仍按原有 procedure / reducer 路径处理。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 6cc9b533..d65b4209 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -79,6 +79,8 @@ npm run lint npm run check ``` +`npm run build` 由 `scripts/build-gate.mjs` 串行构建主站和后台;该门禁会把 Vite warning 当成失败处理。若看到 `Build gate failed because warnings were emitted`,先看 warning 原文,例如 chunk 体积超过 `vite.config.ts` / `apps/admin-web/vite.config.ts` 的 `chunkSizeWarningLimit`,不要先按 Rust 编译失败排查。 + 视觉小说负向扫描与验收门禁: ```bash @@ -147,8 +149,49 @@ Nginx 负责站点和反向代理 Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分 ``` +Windows Stdb module 构建流水线运行在 Jenkins `windows` 节点上。该流水线需要执行 PowerShell 逻辑时,统一通过 `bat` 显式调用 `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe`,不要直接使用 Jenkins `powershell` step;本地 Jenkins durable-task 曾在 `Genarrative-Stdb-Module-Build` workspace 中启动裸 `powershell` 时触发 `CreateProcess error=5, 拒绝访问`。临时 `.ps1` 由 Jenkins `writeFile` 写出后要先转成 UTF-8 with BOM 再交给 Windows PowerShell 5.1 `-File` 解析,避免中文错误消息在无 BOM UTF-8 下被当成本地 ANSI 误解码。Checkout 阶段要优先复用 Jenkins GitSCM 已经完成的结果:`COMMIT_HASH` 为空或与当前 `HEAD` 一致时,不要再额外 `git clean` / `git checkout`,只在确实需要切到别的指定 commit 时才补 fetch、校验和切换。排查时先看对应 build log、`@tmp/durable-*` 下的 `powershellWrapper.ps1`,以及日志中的 `[jenkins-powershell] user/exe`。 + 生产环境变量模板:`deploy/env/api-server.env.example`。真实密钥只放服务器,不提交 Git,不写入文档示例。 +50 HTTP req/s 首版压测优化口径: + +- `api-server` 生产模板默认 `GENARRATIVE_API_LISTEN_BACKLOG=1024`、`GENARRATIVE_API_WORKER_THREADS=4`;本地未设置 worker threads 时继续使用 Tokio 默认值。 +- `GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512` 开启应用内 HTTP 并发背压,超过并发许可时直接返回 `429 Too Many Requests` 和 `Retry-After: 1`,`/healthz` 不受该限制。该值不是 RPS 限速;如果压测中 429 上升但内存和 p95 收敛,说明背压正在保护进程,需要结合真实容量调阈值或在 Nginx 前置限流。直连 `api-server` 的极高 RPS 压测若出现 `connection refused`,通常已经打到 TCP 监听 / accept 层,应同时检查 backlog、Nginx upstream keepalive 和前置限流。 +- `genarrative-api.service` 设置 `LimitNOFILE=65535`、`TasksMax=2048`;上线后用 `systemctl show genarrative-api.service -p LimitNOFILE -p TasksMax` 和 `cat /proc/$(pidof api-server)/limits` 核对。 +- Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代理到 `127.0.0.1:8082`,upstream keepalive 为 64;压测时看 `/var/log/nginx/genarrative.access.log` 中的 `request_time`、`upstream_connect_time`、`upstream_header_time`、`upstream_response_time`、`upstream_status`、`request_id`。 +- 作品列表 K6 脚本一次 iteration 默认请求两个公开接口,因此约 50 HTTP req/s 的目标命令使用 `SCENARIO=spike START_RPS=5 PEAK_RPS=25 HOLD=60s END_RPS=5 DETAIL_RATIO=0 npm run loadtest:k6:works`。 +- 作品列表短期继续由 `api-server` / BFF 订阅 SpacetimeDB 公开 read model 后读本地 cache,不让浏览器前端直接订阅完整列表;未来如新增 `public_work_gallery_entry` 等专用公开作品列表 read model,前端只可订阅稳定、低基数、公开的专用投影,禁止订阅 `puzzle_work_profile`、`custom_world_profile` 等玩法源表后自行 join、聚合或判断权限。前端直订阅落地前必须先补齐权限、字段契约、排序 / 分页、埋点和 BFF 回退策略。 +- 50 HTTP req/s 验收目标为 `http_req_failed < 1%`、`p95 < 2s`、`dropped_iterations = 0`,同时压测窗口内 Nginx 无新增 502。 + +容器化压测与隔离部署方案单独放在 `deploy/container/`,用于本机或预发模拟 Linux release + Nginx + OTLP Collector 拓扑,不替换当前生产 `systemd + Nginx + Jenkins` 发布路径: + +```bash +npm run container:init +npm run container:config +npm run container:build +npm run container:up +npm run container:k6 +npm run container:down +``` + +容器方案默认暴露 `http://127.0.0.1:18080`,`api-server` 在容器内监听 `0.0.0.0:8082`,Nginx 通过 `api-server:8082` upstream 反代 `/api/` 和 `/admin/api/`。SpacetimeDB 默认仍连接宿主机 `http://host.docker.internal:3101`,真实库名、token 和外部服务密钥只写本地 `deploy/container/api-server.env`,不提交 Git。完整拓扑、端口、k6 参数和 OTLP debug exporter 使用方法见 `deploy/container/README.md`。 +`npm run container:config` 默认只做 quiet 校验,避免把本地 env 中的 token 展开到终端;确需排查完整 compose 时再传 `-- --print`。 + +OpenTelemetry 现阶段可选 OTLP traces / metrics / logs,但本地日志与 Nginx 文件日志仍保留: + +- 默认 `GENARRATIVE_OTEL_ENABLED=false`,未开启时 api-server 不依赖 Collector。 +- Collector 使用官方 `otelcol-contrib`,只监听 `127.0.0.1:4317/4318`;本地用 `npm run otel:debug` 启动 debug exporter,用 `npm run otel:rider` 转发到 Rider,再接 Jaeger、Tempo、Prometheus、Grafana 或托管平台。 +- api-server 开启时使用 `OTEL_SERVICE_NAME=genarrative-api`、`OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318`。 +- api-server 当前发 OTLP HTTP,`OTEL_EXPORTER_OTLP_ENDPOINT` 指向 Collector HTTP base endpoint;不要改到 gRPC `4317` 或 Rider 端口,Rider 由 Collector 通过 `RIDER_OTLP_GRPC_ENDPOINT` 转发。 +- 应用日志仍通过 `journalctl -u genarrative-api.service` 查看,Nginx 日志仍写文件;日志等级继续用 `GENARRATIVE_API_LOG` / `RUST_LOG` 控制,例如 `info,tower_http=info,spacetime_client=info`。 +- debug exporter / Rider 转发都会同时接收 traces、metrics 和 logs。 +- api-server 会随 metrics 发送进程级指标:`process.memory.usage`、`process.memory.virtual`、`process.thread.count`、`genarrative.process.memory.private`;Windows 额外发送 `process.windows.handle.count`,Linux 额外发送 `process.unix.file_descriptor.count`。这些指标只描述当前进程,不携带请求、用户或作品 label。 +- HTTP 运行态补充发送 `genarrative.http.server.response_bodies.in_flight` 与 `genarrative.http.server.request_permits.available`,用于区分业务 handler / 背压 permit 是否仍被占用;拼图广场热点缓存补充发送 `genarrative.puzzle_gallery.cache.*` 指标,记录命中、未命中、重建耗时和预序列化 data JSON 字节数。 +- SpacetimeDB 观测分为两类:procedure / reducer 调用继续用 `genarrative.spacetime.procedure.*`,订阅本地 cache 读使用 `genarrative.spacetime.read.*`。`read=list_puzzle_gallery` 表示拼图广场当前从 `puzzle_gallery_card_view` 本地 cache 读取,不再每个 HTTP 请求调用 `list_puzzle_gallery` procedure。 +- 本地 Windows 直连压测的内存高水位要结合 K6 VU / 连接数解释。250 RPS 下过高 `PREALLOCATED_VUS` 可能让 300 个本地 Established 连接把 `api-server` private memory 瞬时推到 GB 级,且 `/healthz` 小响应也能复现;若压测结束后回落、`response_bodies.in_flight` 和背压 permit 未显示业务积压,应优先按连接 / 发送链路高水位处理,而不是判断为 SpacetimeDB 或 JSON 缓存泄漏。 +- Rider 的 Logs 面板只展示 log event 自身字段,不会自动展开父 span 的全部 attributes;请求完成日志会直接带 `request_id`、`http.request.method`、`http.route`、`url.scheme`、`url.path`、`http.response.status_code`、`status_class`、`latency_ms` 和 `slow_request`,完整链路继续到 Traces 面板按 trace/span 查看。 +- 指标 label 只允许低基数字段:HTTP 使用 `method`、`route`、`status_class`,SpacetimeDB 调用使用 `procedure`、`status_class`;`request_id` 只进入 trace/log attribute,不进入 metric label。 + 常见外部服务变量: - `GENARRATIVE_SPACETIME_SERVER_URL` @@ -164,6 +207,30 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分 - `WECHAT_*` - `ALIYUN_OSS_*` +### 手机验证码短信 + +手机验证码发送走阿里云普通短信 `SendSms`,验证码由 `module-auth` 在当前 `api-server` 进程内生成、哈希存储和校验,不再调用阿里云托管验证码的 `SendSmsVerifyCode` / `CheckSmsVerifyCode`。因此 `api-server` 重启后,已发送但未校验的验证码会失效。 + +生产默认短信配置: + +```env +ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com +ALIYUN_SMS_SIGN_NAME=北京亓盒网络科技 +ALIYUN_SMS_TEMPLATE_CODE=SMS_506245486 +ALIYUN_SMS_TEMPLATE_PARAM_KEY=code +``` + +阿里云模板参数固定发送为 `{"code":"<验证码>"}`。旧托管验证码相关变量如 `ALIYUN_SMS_CODE_LENGTH`、`ALIYUN_SMS_CODE_TYPE`、`ALIYUN_SMS_RETURN_VERIFY_CODE`、`ALIYUN_SMS_CASE_AUTH_POLICY`、`ALIYUN_SMS_SCHEME_NAME` 不再影响真实阿里云校验;验证码长度、有效期、冷却和失败次数由后端本地逻辑控制。真实短信联调仍需 `SMS_AUTH_PROVIDER=aliyun`、`SMS_AUTH_ENABLED=true` 和有效 `ALIYUN_SMS_ACCESS_KEY_*`。修改 `.env.local` 后必须重启 `api-server`,再用 `/api/auth/login-options` 确认返回包含 `phone`;如果通过 shell 临时覆盖,PowerShell 使用 `$env:SMS_AUTH_ENABLED="true"`,cmd 使用 `set SMS_AUTH_ENABLED=true`,不要把引号作为环境变量值的一部分传给进程。 + +如需在本地确认平台层确实调用阿里云 `SendSms`,可手动运行默认忽略的真实短信测试。该测试会向 `ALIYUN_SMS_REAL_TEST_PHONE_NUMBER` 发送验证码短信,普通 `cargo test` 不会执行: + +```powershell +$env:ALIYUN_SMS_ACCESS_KEY_ID="..." +$env:ALIYUN_SMS_ACCESS_KEY_SECRET="..." +$env:ALIYUN_SMS_REAL_TEST_PHONE_NUMBER="13800138000" +cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms_real_provider_sends_verify_code -- --ignored --nocapture +``` + ## 埋点与运营查询 用户行为埋点原始事实写入 `tracking_event`,聚合投影写入 `tracking_daily_stat`。任务配置、进度、领奖、钱包流水分别写入: diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 4f448db2..c82e2a6b 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -8,13 +8,17 @@ 当前创作 Tab 固定为智能创作首页与模板入口,草稿 Tab 承接作品架。点击独立入口后应切换到对应内嵌创作表单或生成页;不要额外做一张平行配置页,除非玩法本身需要完整独立工作台。 +`PlatformEntryFlowShellImpl.tsx` 仍是平台入口编排壳,后续维护时应优先把独立 UI 片段、公开作品映射、草稿生成 notice 和运行态状态 helper 拆到 `src/components/platform-entry/PlatformEntryFlowShellImpl/` 或同目录紧邻 helper 文件。拆分只允许改变文件组织,不改变入口配置事实源、默认导出、props、页面阶段、UI 文案或现有交互;其中拼图首访 onboarding 已拆为 `PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx`。 + ## 草稿与作品架 1. 草稿页作品卡对齐发现页列表卡风格:左侧信息,右侧封面图,移动端单列,桌面两到三列。 2. 草稿 / 已发布状态尽量图标化,不使用大段状态文案。 3. 草稿卡常态不外露低频动作;已发布作品卡右上角可直接显示无边框分享 icon,删除等破坏性动作继续收口到左滑或长按操作层。 4. 生成中作品在整卡上加等待遮罩,但不移除作品基础信息。 -5. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。 +5. 生成中状态不能只存在前端内存 notice。后端作品摘要必须下发可恢复的 `generationStatus`;前端刷新或退出产品后,作品架优先用摘要状态恢复等待遮罩,本轮内存 notice 只作为即时反馈。 +6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 使用作品摘要 `updatedAt` 推导。 +7. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。 ## 拼图 @@ -28,8 +32,13 @@ - 图像输入复用 `CreativeImageInputPanel`。 - 支持画面描述生图、多参考图生图、上传主图后 AI 重绘、上传主图后不重绘。 -- 草稿生成会保留关卡图和 UI 背景;当前不自动生成背景音乐。 +- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要,生成完成并回写关卡图、UI 背景后再变为 `ready`;首关关卡图和 UI 背景在命名稳定后并行启动,当前不自动生成背景音乐。 +- 拼图草稿编译是长耗时 action,前端 action 请求默认等待 `1_000_000ms` 且不自动重试,生成页预计完成时间按 `5` 分钟展示;生成页恢复时必须沿用作品摘要 `updatedAt` 作为原始 `startedAtMs`,失败/完成态用 `finishedAtMs` 冻结耗时,不能在锁屏或返回草稿页后重新从 0 计时。 +- 若浏览器锁屏、息屏或网络切换导致 compile 请求失败,前端在标记失败前必须先复读 `getPuzzleAgentSession(sessionId)`;只有最新 session 仍缺 `draft.coverImageSrc`、首关 `coverImageSrc` 或候选图时才展示失败,复读到已生成草稿时按成功收尾、刷新作品架并继续自动试玩/结果页链路。 +- 拼图参考图 AI 重绘优先走 VectorEngine `/v1/images/edits`;若编辑接口超时,`api-server` 会降级为 `/v1/images/generations`,并把同一参考图塞进 `image` 数组继续生成,避免参考图草稿整单失败。 - 结果页素材配置当前只保留 UI 相关能力;旧背景音乐入口隐藏。 +- 结果页允许多关卡并行编辑和生成;某一关卡图片生成完成回包只静默更新该关卡素材与生成态,不得自动打开或切换关卡详情面板,避免打断用户正在编辑的其它关卡。 +- 拼图 UI 背景是作品运行态背景,不只属于第一关;本地试玩、直达指定关卡和正式 `next-level` 推进时,目标关卡缺 `uiBackgroundImageSrc/uiBackgroundImageObjectKey` 必须继承同作品首个可用 UI 背景,仍缺失时才沿用当前运行态快照背景或默认 UI。 - 拼图运行态棋盘不叠加分块蒙版、描边、阴影、选中底色或合并块 SVG 轮廓;拼图片本体需要裁切为圆角形状,单块使用独立圆角裁切,合并块使用 SVG 原生 `clipPath` 裁切整体外轮廓,外凸角和内凹角分别计算半径,内凹角半径要比外凸角更明显以避免手机 WebView 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。 - 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform,不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。 - 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。 @@ -51,24 +60,24 @@ 难度映射: | 难度 | clearCount | difficulty | 总物品数 | 物品种类 | -| --- | ---: | ---: | ---: | ---: | -| 轻松 | 8 | 2 | 24 | 3 | -| 标准 | 12 | 4 | 36 | 9 | -| 进阶 | 16 | 6 | 48 | 15 | -| 硬核 | 21 | 8 | 63 | 21 | +| ---- | ---------: | ---------: | -------: | -------: | +| 轻松 | 8 | 2 | 24 | 3 | +| 标准 | 12 | 4 | 36 | 9 | +| 进阶 | 16 | 6 | 48 | 15 | +| 硬核 | 21 | 8 | 63 | 21 | 当前素材生成流水线: 1. 点击生成前弹出泥点确认,草稿生成固定消耗 `10` 泥点。 -2. 先写入可恢复草稿 profile,再执行文本计划、图片生成、切图、OSS 上传、背景和容器生成;草稿完成条件不包含 `backgroundMusic`。 +2. 先写入可恢复草稿 profile,再执行文本计划、图片生成、切图、OSS 上传、背景和容器生成;作品摘要在素材或背景未完整时下发 `generationStatus=generating`,素材和背景完整后下发 `ready`,草稿完成条件不包含 `backgroundMusic`。 3. 物品素材不再调用 Hyper3D Rodin,不再生成 GLB。新草稿和批量新增固定生成 2D 五视角素材。 4. 物品 sheet 走 VectorEngine Gemini `gemini-3-pro-image-preview` 原生 `generateContent`,单张 `1:1` 图固定 `5*5`,每张承载 `5` 个物品、每个物品 `5` 个视角。 -5. 切图前先在整张 sheet 上做绿幕 / 近白底透明化和边缘去污染,再按格子导出独立 PNG;每个视角图再以扩大的 PNG 边界带为种子,把连通的浅绿 / 近白抗锯齿边直接改为透明,并按剩余可见主体二次收紧;不要先裁剪单格再各自去绿。 +5. 切图前先在整张 sheet 上做绿幕 / 近白底透明化和边缘去污染,再按格子导出独立 PNG;每个视角图再以扩大的 PNG 边界带为种子,把连通的浅绿 / 近白抗锯齿边直接改为透明,并对贴透明背景的弱绿 / 暗绿轮廓像素做去绿污染处理,最后按剩余可见主体二次收紧;不要先裁剪单格再各自去绿。 6. `generatedItemAssets[].imageViews[]` 是新素材主字段,`imageSrc/imageObjectKey` 只兼容首张视角。 7. 文本生成物品名称时必须同时生成 `itemSize`,只允许 `大`、`中`、`小`。该字段随 `generatedItemAssets[].itemSize` 持久化并下发;历史缺失字段的素材按 `大` 兼容,模型缺失或非法值按物品名本地推断。 8. 局内 `9:16` 纯背景图和 `1:1` 中心容器 UI 图分开生成。纯背景不得包含锅、盘、托盘、HUD、按钮、文字或物品,且入库前必须合成为全画幅不透明图片,不允许出现透明区域;容器图走 `/v1/images/edits` 参考透明容器图。 9. 当前抓大鹅音频生成关闭:入口无 `生成音效`,草稿不生成背景音乐或点击音效,结果页不展示背景音乐 Tab 或点击音效生成入口。历史 `backgroundMusic` / `clickSound` 字段继续兼容传递。 -10. UI 背景和容器资产的持久化真相仍在 `generatedItemAssets[].backgroundAsset`;Agent session、work summary/detail、结果页和运行态入口都必须把该字段提升为 `backgroundImageSrc/backgroundImageObjectKey/generatedBackgroundAsset` 读取,避免草稿重进、结果页预览或试玩退回默认素材。 +10. UI 背景和容器资产的持久化真相仍在 `generatedItemAssets[].backgroundAsset`;Agent session、work summary/detail、结果页和运行态入口都必须把该字段提升为 `backgroundImageSrc/backgroundImageObjectKey/generatedBackgroundAsset` 读取。草稿编译后的 `draftJson` 自身也必须携带 `generatedItemAssets` 快照;HTTP facade 不能只依赖 work detail 回读补齐 UI 资产,外部回读为空时也不得清空草稿内已有的背景 / 容器图。平台壳层从作品架、广场、生成完成回调、结果页保存 / 发布 / 试玩回调进入 Match3D profile 时也要先归一化并提升,避免首次试玩、手动试玩、推荐流或公开详情运行态退回默认背景 / 默认容器。 结果页当前结构: @@ -81,15 +90,16 @@ 运行态当前口径: - 规则真相在后端;前端只做即时表现、点击候选、飞入、入槽、三消和胜负过渡。 -- 物品选择只在 `pointerup` 时提交;`pointerdown` / `pointermove` 只更新候选样式。松手时按当前位置和最新快照命中一个最上层可点击物品。 +- 物品选择只在 `pointerup` 时提交;`pointerdown` / `pointermove` 只更新候选样式。松手时按当前位置和最新快照命中一个最上层可点击物品;生成 2D PNG 物品必须按当前展示图的 alpha 像素做热区精筛,透明像素、`object-contain` 留白和 `itemSize` 缩小后的空白区不能响应点击。 - 物品 DOM 只负责展示,不通过自身 `click` 事件直接提交,避免浏览器后续 click 绕过松手判定造成重复提交。 - 初始物品坐标围绕容器口中心生成,并保留内缩安全距离,避免贴边和局部角落聚集。 - 本地试玩与 Rust `module-match3d` 后端领域生成使用同一套中心铺开口径;生成点覆盖四象限且均值接近中心。 - 运行态优先消费 2D 生成图;默认积木 / 程序化 3D 表现只作为视觉分支和兜底,不改变规则真相。 -- 运行态启动前要预加载 `generatedItemAssets[].imageViews[]`、顶层 `generatedBackgroundAsset`、物品挂载 `backgroundAsset` 中的背景和容器图;卡片摘要缺 UI 背景或容器字段时,进入运行态前必须补读 work detail。补读后的 profile 也要再次提升 `generatedItemAssets[].backgroundAsset`,确保背景和容器字段传给 `Match3DRuntimeShell`。 -- 局内容器图在移动端宽度接近屏幕宽度并居中显示,保持原图比例不拉伸;生成容器图加载成功后棋盘外壳透明且 `overflow-visible`,只有生成图缺失或加载失败时才显示透明参考容器兜底。 +- 运行态启动前要预加载 `generatedItemAssets[].imageViews[]`、顶层 `generatedBackgroundAsset`、物品挂载 `backgroundAsset` 中的背景和容器图;首次生成自动试玩、结果页手动试玩、推荐流和公开详情启动都必须传入提升后的 profile。卡片摘要缺 UI 背景或容器字段时,进入运行态前必须补读 work detail。补读后的 profile 也要再次提升 `generatedItemAssets[].backgroundAsset`,确保背景和容器字段传给 `Match3DRuntimeShell`。 +- 局内容器图在移动端宽度大于屏幕宽度并略向下压,当前运行态使用 `w-[min(116vw,42rem)]` 与 `top-[54%]` 放大和下移容器图本体,保持原图比例不拉伸且不改变后端物品布局、点击半径或消除规则;生成容器图加载成功后棋盘外壳透明且 `overflow-visible`,只有生成图缺失或加载失败时才显示透明参考容器兜底。 - generated 私有图换签未完成时,局内物品先隐藏等待,不得短暂显示默认积木;同一批资源在重启 run 时保留已解析签名 URL,只有资源源列表变化或换签失败后才允许进入兜底视觉。 -- `itemSize` 只缩放生成 2D 图片本体:`大` 使用当前默认显示尺寸,`中` 和 `小` 缩小显示;不改变后端下发的布局半径、点击半径或三消规则。 +- `itemSize` 只缩放生成 2D 图片本体:`大`、`中`、`小` 均按相对尺寸缩放,其中 `大` 也比原始图片略小,`中` 和 `小` 进一步缩小;不改变后端下发的布局半径、点击半径或三消规则。 +- 物品进入底部物品栏时按同类型插入:如果物品栏已有同类物品,新物品插到该类型最后一个物品后面,后续物品整体后移;没有同类时追加到当前末尾。达到三件同类时,在飞入物品栏动画结束后,左侧和右侧同类物品向中间合成,三件一起消失,播放合成音效,不展示星星图标,后面的物品再向前补位。该动效只是前端表现层,后端和本地试玩仍负责权威插入、指定点击类型清除与补位后的槽位快照。 - 抓大鹅运行态右上角常驻设置入口,不直接暴露重新开始按钮;重新开始收口到设置面板内,结算弹层仍保留结果态的再来一局动作。 - 高 DPR 移动端 WebGL canvas 必须锁定 CSS 尺寸,避免右下溢出。 @@ -153,3 +163,4 @@ 3. 生成失败时,后端应返回可操作 `details.reason` / `details.missingEnv`,前端优先展示具体原因。 4. 半配置 OSS 不应阻断 `api-server` 启动;具体生成或换签接口在需要时返回配置缺失。 5. 历史 generated path 可以兼容读取,但新链路不要把裸 path 当公开静态资源。 +6. 发现页 / 推荐流公开作品卡封面必须兼容旧移动浏览器内核:封面容器不能只依赖 CSS `aspect-ratio` 撑高,必须保留 16:9 或对应沉浸卡比例的可见高度兜底;generated 私有封面换签失败时要回落到玩法类型参考图,避免卡片整体黑底。 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index d04c7773..7b539e81 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -39,6 +39,12 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当 内部状态值可继续复用历史 `home/category/create/saves/profile`,但用户可见文案按上面的新口径展示。 +## 账户与登录 + +1. 主站登录弹窗必须稳定展示 `短信登录` 与 `密码登录` 两个核心入口;`GET /api/auth/login-options` 只能补充微信等环境相关入口,不能决定是否隐藏短信或密码登录。 +2. `login-options` 为空、失败、只返回 `phone` 或只返回 `password` 时,前端仍要同时展示验证码登录页签和密码登录页签;短信能力真实可用性由发送验证码接口返回结果表达。 +3. 登录弹窗继续复用现有独立 modal 和页签结构,不在页面中新增功能说明类文案,也不把邀请码输入放回登录面板。 + ## 账户与充值 1. “我的”页账户充值弹窗包含 `泥点充值` 与 `会员卡充值` 两个页签,入口必须打开独立弹窗,不在当前面板下方展开。 diff --git a/jenkins/Jenkinsfile.production-stdb-module-build b/jenkins/Jenkinsfile.production-stdb-module-build index 4ac2bfa3..9d4ead23 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-build +++ b/jenkins/Jenkinsfile.production-stdb-module-build @@ -1,3 +1,24 @@ +def runWindowsPowerShell(String scriptName, String scriptBody) { + def scriptPath = ".jenkins-${scriptName}.ps1" + writeFile file: scriptPath, text: scriptBody, encoding: 'UTF-8' + bat label: "PowerShell ${scriptName}", script: """ +@echo off +setlocal +set "GENARRATIVE_POWERSHELL=%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" +if not exist "%GENARRATIVE_POWERSHELL%" ( + echo [jenkins-powershell] powershell.exe not found: %GENARRATIVE_POWERSHELL% + exit /b 1 +) +echo [jenkins-powershell] user: +whoami +echo [jenkins-powershell] exe: %GENARRATIVE_POWERSHELL% +"%GENARRATIVE_POWERSHELL%" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "\$path = '%CD%\\${scriptPath}'; \$text = [System.IO.File]::ReadAllText(\$path, [System.Text.Encoding]::UTF8); \$utf8Bom = New-Object System.Text.UTF8Encoding(\$true); [System.IO.File]::WriteAllText(\$path, \$text, \$utf8Bom)" +if errorlevel 1 exit /b %ERRORLEVEL% +"%GENARRATIVE_POWERSHELL%" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%CD%\\${scriptPath}" +exit /b %ERRORLEVEL% +""" +} + pipeline { agent { label 'windows' @@ -45,23 +66,95 @@ pipeline { ], userRemoteConfigs: [[url: "${GIT_REMOTE_URL}", refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]], ]) - powershell ''' - $ErrorActionPreference = 'Stop' - $sourceBranch = if ($env:SOURCE_BRANCH) { $env:SOURCE_BRANCH } else { 'master' } - $commitHash = if ($env:COMMIT_HASH) { $env:COMMIT_HASH } else { '' } - $gitRemoteUrl = if ($env:GIT_REMOTE_URL) { $env:GIT_REMOTE_URL } else { 'https://git.genarrative.world/GenarrativeAI/Genarrative.git' } - git fetch --no-tags --prune --depth=1 $gitRemoteUrl "+refs/heads/${sourceBranch}:refs/remotes/origin/${sourceBranch}" - if ($commitHash) { - git checkout --force $commitHash - } else { - git checkout --force "origin/$sourceBranch" - } - git clean -ffdx - $resolvedCommit = (git rev-parse HEAD).Trim() - $utf8NoBom = New-Object System.Text.UTF8Encoding $false - [System.IO.File]::WriteAllText((Join-Path (Get-Location) '.jenkins-source-commit'), "$resolvedCommit`n", $utf8NoBom) - ''' script { + runWindowsPowerShell('stdb-checkout', ''' + $ErrorActionPreference = 'Stop' + $sourceBranch = if ($env:SOURCE_BRANCH) { $env:SOURCE_BRANCH } else { 'master' } + $commitHash = if ($env:COMMIT_HASH) { $env:COMMIT_HASH } else { '' } + $gitRemoteUrl = if ($env:GIT_REMOTE_URL) { $env:GIT_REMOTE_URL } else { 'https://git.genarrative.world/GenarrativeAI/Genarrative.git' } + + function Invoke-GitCommand { + param( + [string]$Label, + [string[]]$Arguments + ) + + Write-Host "[stdb-checkout] $Label" + & git @Arguments + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { + throw "[stdb-checkout] $Label failed with exit code $exitCode" + } + } + + Write-Host "[stdb-checkout] sourceBranch: $sourceBranch" + Write-Host "[stdb-checkout] remote: $gitRemoteUrl" + $currentCommit = (git rev-parse HEAD).Trim() + if ($LASTEXITCODE -ne 0 -or -not $currentCommit) { + throw '[stdb-checkout] cannot resolve current HEAD' + } + Write-Host "[stdb-checkout] current HEAD: $currentCommit" + + if ($commitHash) { + Write-Host "[stdb-checkout] requested commit: $commitHash" + $resolvedCommit = (git rev-parse --verify "${commitHash}^{commit}" 2>$null).Trim() + if ($LASTEXITCODE -eq 0 -and $resolvedCommit -eq $currentCommit) { + Write-Host '[stdb-checkout] requested commit already matches Jenkins GitSCM checkout' + } else { + Invoke-GitCommand -Label 'fetch source branch history' -Arguments @( + 'fetch', + '--no-tags', + '--prune', + $gitRemoteUrl, + "+refs/heads/${sourceBranch}:refs/remotes/origin/${sourceBranch}" + ) + $isShallowRepository = (git rev-parse --is-shallow-repository 2>$null).Trim() + if ($LASTEXITCODE -ne 0) { + throw '[stdb-checkout] cannot determine whether repository is shallow' + } + if ($isShallowRepository -eq 'true') { + Invoke-GitCommand -Label 'deepen source branch history' -Arguments @( + 'fetch', + '--unshallow', + '--no-tags', + $gitRemoteUrl, + "+refs/heads/${sourceBranch}:refs/remotes/origin/${sourceBranch}" + ) + } + Invoke-GitCommand -Label 'validate source branch ref' -Arguments @( + 'cat-file', + '-e', + "refs/remotes/origin/${sourceBranch}^{commit}" + ) + Invoke-GitCommand -Label 'validate requested commit' -Arguments @( + 'cat-file', + '-e', + "${commitHash}^{commit}" + ) + $resolvedCommit = (git rev-parse --verify "${commitHash}^{commit}").Trim() + if ($LASTEXITCODE -ne 0 -or -not $resolvedCommit) { + throw "[stdb-checkout] cannot resolve requested commit: $commitHash" + } + Invoke-GitCommand -Label 'validate requested commit belongs to branch' -Arguments @( + 'merge-base', + '--is-ancestor', + $resolvedCommit, + "refs/remotes/origin/${sourceBranch}" + ) + Invoke-GitCommand -Label "checkout commit $resolvedCommit" -Arguments @( + 'checkout', + '--force', + $resolvedCommit + ) + } + } else { + Write-Host "[stdb-checkout] COMMIT_HASH empty, reusing Jenkins GitSCM checkout result" + } + + $resolvedCommit = (git rev-parse HEAD).Trim() + $utf8NoBom = New-Object System.Text.UTF8Encoding $false + [System.IO.File]::WriteAllText((Join-Path (Get-Location) '.jenkins-source-commit'), "$resolvedCommit`n", $utf8NoBom) + ''') env.SOURCE_COMMIT = readFile('.jenkins-source-commit').replace('\uFEFF', '').trim() env.EFFECTIVE_BUILD_VERSION = params.BUILD_VERSION?.trim() ? params.BUILD_VERSION.trim() : env.BUILD_NUMBER } @@ -72,7 +165,7 @@ pipeline { steps { script { def buildStep = { - powershell ''' + runWindowsPowerShell('stdb-build', ''' $ErrorActionPreference = 'Stop' $workspaceTmp = if ($env:WORKSPACE_TMP) { $env:WORKSPACE_TMP } else { "$env:WORKSPACE@tmp" } $env:CARGO_HOME = "$workspaceTmp/cargo-home" @@ -110,6 +203,7 @@ pipeline { } npm run build:production-release -- --component spacetime-module --name "$env:EFFECTIVE_BUILD_VERSION" ''' + ) } if (params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID?.trim()) { withCredentials([ diff --git a/package-lock.json b/package-lock.json index 9008c606..b30a634e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,6 +72,7 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1515,7 +1516,6 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, - "peer": true, "engines": { "node": ">=10" }, @@ -1528,7 +1528,6 @@ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -1542,8 +1541,7 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "peer": true + "dev": true }, "node_modules/@testing-library/react": { "version": "16.3.2", @@ -1606,8 +1604,7 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "peer": true + "dev": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -1650,7 +1647,8 @@ "version": "4.3.20", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz", "integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==", - "dev": true + "dev": true, + "peer": true }, "node_modules/@types/chai-subset": { "version": "1.3.6", @@ -1696,6 +1694,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1705,6 +1704,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -1796,6 +1796,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -2126,6 +2127,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2216,7 +2218,6 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -2338,6 +2339,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2629,7 +2631,6 @@ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -2685,8 +2686,7 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "peer": true + "dev": true }, "node_modules/domexception": { "version": "4.0.0", @@ -2873,6 +2873,7 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3697,6 +3698,7 @@ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz", "integrity": "sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==", "dev": true, + "peer": true, "dependencies": { "abab": "^2.0.6", "cssstyle": "^3.0.0", @@ -4096,7 +4098,6 @@ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -4435,6 +4436,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "peer": true, "engines": { "node": ">=12" }, @@ -4486,6 +4488,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4619,6 +4622,7 @@ "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4627,6 +4631,7 @@ "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5074,6 +5079,7 @@ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "devOptional": true, + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -5126,6 +5132,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5207,6 +5214,7 @@ "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -7027,6 +7035,7 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "peer": true, "requires": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -7835,15 +7844,13 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "peer": true + "dev": true }, "pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, - "peer": true, "requires": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -7854,8 +7861,7 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "peer": true + "dev": true } } }, @@ -7891,8 +7897,7 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "peer": true + "dev": true }, "@types/babel__core": { "version": "7.20.5", @@ -7935,7 +7940,8 @@ "version": "4.3.20", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz", "integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==", - "dev": true + "dev": true, + "peer": true }, "@types/chai-subset": { "version": "1.3.6", @@ -7978,6 +7984,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, + "peer": true, "requires": { "csstype": "^3.2.2" } @@ -7987,6 +7994,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, + "peer": true, "requires": {} }, "@types/semver": { @@ -8053,6 +8061,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, + "peer": true, "requires": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -8263,7 +8272,8 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true + "dev": true, + "peer": true }, "acorn-jsx": { "version": "5.3.2", @@ -8326,7 +8336,6 @@ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, - "peer": true, "requires": { "dequal": "^2.0.3" } @@ -8396,6 +8405,7 @@ "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "peer": true, "requires": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -8605,8 +8615,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "peer": true + "dev": true }, "detect-libc": { "version": "2.1.2", @@ -8646,8 +8655,7 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "peer": true + "dev": true }, "domexception": { "version": "4.0.0", @@ -8782,6 +8790,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "dev": true, + "peer": true, "requires": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -9360,6 +9369,7 @@ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz", "integrity": "sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==", "dev": true, + "peer": true, "requires": { "abab": "^2.0.6", "cssstyle": "^3.0.0", @@ -9566,8 +9576,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, - "peer": true + "dev": true }, "magic-string": { "version": "0.30.21", @@ -9813,7 +9822,8 @@ "picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==" + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "peer": true }, "pkg-types": { "version": "1.3.1", @@ -9843,6 +9853,7 @@ "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "peer": true, "requires": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9926,12 +9937,14 @@ "react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==" + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "peer": true }, "react-dom": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "peer": true, "requires": { "scheduler": "^0.27.0" } @@ -10256,6 +10269,7 @@ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "devOptional": true, + "peer": true, "requires": { "esbuild": "~0.27.0", "fsevents": "~2.3.3", @@ -10287,7 +10301,8 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true + "dev": true, + "peer": true }, "ufo": { "version": "1.6.3", @@ -10339,6 +10354,7 @@ "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "peer": true, "requires": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/package.json b/package.json index 325149e9..4f65c0b6 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,14 @@ "dev:web": "node scripts/dev.mjs web", "dev:admin-web": "node scripts/dev.mjs admin-web", "dev:spacetime:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh", + "otel:debug": "node scripts/run-otelcol.mjs debug", + "otel:rider": "node scripts/run-otelcol.mjs rider", "admin-web:build": "node scripts/admin-web-build.mjs build", "admin-web:typecheck": "node scripts/admin-web-build.mjs typecheck", "admin-web:preview": "npm --prefix apps/admin-web run preview --", "spacetime:generate": "node scripts/generate-spacetime-bindings.mjs", "check:api-server-env": "node scripts/check-api-server-env.mjs", + "check:spacetime-runtime-access": "node scripts/check-spacetime-runtime-access.mjs", "deploy:rust:remote": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh", "build:production-release": "node scripts/run-bash-script.mjs scripts/build-production-release.sh", "build:rust:ubuntu": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh", @@ -29,7 +32,7 @@ "check:visual-novel-vn11": "node scripts/check-visual-novel-vn11-negative-scan.mjs", "check:visual-novel-vn12": "node scripts/check-visual-novel-vn12-acceptance.mjs", "check:wechat-miniprogram-auth": "node scripts/check-wechat-miniprogram-auth-smoke.mjs", - "check:server-rs-ddd": "npm run check:spacetime-schema && node scripts/check-server-rs-ddd-boundaries.mjs", + "check:server-rs-ddd": "npm run check:spacetime-schema && npm run check:spacetime-runtime-access && node scripts/check-server-rs-ddd-boundaries.mjs", "lint:eslint": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --max-warnings 0", "lint:guardrails": "npm run lint:eslint", "typecheck": "tsc -p tsconfig.typecheck-guardrails.json --noEmit", @@ -42,6 +45,14 @@ "test:watch": "vitest", "loadtest:extract-works": "node scripts/loadtest/extract-works-list-data.mjs", "loadtest:k6:works": "k6 run scripts/loadtest/k6-works-list.js", + "container:init": "node scripts/container-compose.mjs init", + "container:build": "node scripts/container-compose.mjs build", + "container:up": "node scripts/container-compose.mjs up", + "container:down": "node scripts/container-compose.mjs down", + "container:logs": "node scripts/container-compose.mjs logs", + "container:ps": "node scripts/container-compose.mjs ps", + "container:config": "node scripts/container-compose.mjs config", + "container:k6": "node scripts/container-compose.mjs k6", "check": "npm run lint && npm run test && npm run build && npm run check:content", "check:data": "node scripts/run-tsx.cjs scripts/validate-content.ts", "check:overrides": "node scripts/run-tsx.cjs scripts/validate-overrides.ts", diff --git a/packages/shared/src/contracts/edutainmentBabyObject.ts b/packages/shared/src/contracts/edutainmentBabyObject.ts index f1612c4e..d1ba0687 100644 --- a/packages/shared/src/contracts/edutainmentBabyObject.ts +++ b/packages/shared/src/contracts/edutainmentBabyObject.ts @@ -24,7 +24,9 @@ export type BabyObjectMatchVisualAssetKind = | 'ui-frame' | 'gift-box' | 'basket' - | 'smoke-puff'; + | 'smoke-puff' + | 'left-hand' + | 'right-hand'; export type BabyObjectMatchVisualAsset = { assetId: string; diff --git a/packages/shared/src/contracts/match3dWorks.ts b/packages/shared/src/contracts/match3dWorks.ts index d1e4e4f8..f54ac624 100644 --- a/packages/shared/src/contracts/match3dWorks.ts +++ b/packages/shared/src/contracts/match3dWorks.ts @@ -5,6 +5,7 @@ import type { CreationAudioAsset } from './creationAudio'; export type Match3DWorkPublicationStatus = 'draft' | 'published' | string; +export type Match3DWorkGenerationStatus = 'idle' | 'generating' | 'ready' | string; export type Match3DGeneratedItemAssetStatus = | 'pending' @@ -163,6 +164,7 @@ export interface Match3DWorkSummary { updatedAt: string; publishedAt?: string | null; publishReady: boolean; + generationStatus?: Match3DWorkGenerationStatus | null; backgroundPrompt?: string | null; backgroundImageSrc?: string | null; backgroundImageObjectKey?: string | null; diff --git a/packages/shared/src/contracts/puzzleWorkSummary.ts b/packages/shared/src/contracts/puzzleWorkSummary.ts index 3bfd4a44..64678bb4 100644 --- a/packages/shared/src/contracts/puzzleWorkSummary.ts +++ b/packages/shared/src/contracts/puzzleWorkSummary.ts @@ -2,6 +2,7 @@ import type { JsonObject } from './common'; import type { PuzzleAnchorPack, PuzzleDraftLevel } from './puzzleAgentDraft'; export type PuzzleWorkPublicationStatus = 'draft' | 'published'; +export type PuzzleWorkGenerationStatus = PuzzleDraftLevel['generationStatus']; export interface PuzzleWorkSummary { workId: string; @@ -28,6 +29,7 @@ export interface PuzzleWorkSummary { pointIncentiveTotalPoints?: number; pointIncentiveClaimablePoints?: number; publishReady: boolean; + generationStatus?: PuzzleWorkGenerationStatus | null; levels?: PuzzleDraftLevel[]; } @@ -40,6 +42,19 @@ export interface PuzzleWorksResponse { items: PuzzleWorkSummary[]; } +export interface PuzzleGalleryWorkRef { + workId: string; + profileId: string; +} + +export interface PuzzleGalleryResponse { + items: PuzzleWorkSummary[]; + previewRefs?: PuzzleGalleryWorkRef[]; + hasMore?: boolean; + nextCursor?: string | null; + totalCount?: number; +} + export interface PuzzleWorkDetailResponse { item: PuzzleWorkProfile; } diff --git a/scripts/check-api-server-env.mjs b/scripts/check-api-server-env.mjs index 9a9932ef..212523cc 100644 --- a/scripts/check-api-server-env.mjs +++ b/scripts/check-api-server-env.mjs @@ -27,6 +27,10 @@ function printStatus(key, present) { const env = mergeApiServerEnv(process.cwd(), process.env); const missing = []; +console.log('[api-server-env] 认证短信配置检查'); +printStatus('SMS_AUTH_ENABLED', env.SMS_AUTH_ENABLED === 'true'); +printStatus('SMS_AUTH_PROVIDER', hasValue(env.SMS_AUTH_PROVIDER)); + console.log('[api-server-env] 拼图真实生成配置检查'); for (const key of REQUIRED_FOR_PUZZLE_GENERATION) { const present = hasValue(env[key]); diff --git a/scripts/check-spacetime-runtime-access.mjs b/scripts/check-spacetime-runtime-access.mjs new file mode 100644 index 00000000..0931ef21 --- /dev/null +++ b/scripts/check-spacetime-runtime-access.mjs @@ -0,0 +1,221 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const repoRoot = process.cwd(); + +function readUtf8(relativePath) { + const absolute = path.join(repoRoot, relativePath); + if (!fs.existsSync(absolute)) { + failures.push(`${relativePath}: 文件不存在,无法执行 SpacetimeDB runtime access 检查`); + return null; + } + return fs.readFileSync(absolute, 'utf8'); +} + +const forbiddenSnippets = [ + { + file: 'server-rs/crates/spacetime-module/src/puzzle.rs', + snippet: '.puzzle_work_profile()\n .iter()\n .filter(|row| row.owner_user_id == input.owner_user_id)', + reason: 'puzzle_work_profile 已有 by_puzzle_work_owner_user_id 索引', + }, + { + file: 'server-rs/crates/spacetime-module/src/puzzle.rs', + snippet: '.puzzle_work_profile()\n .iter()\n .filter(|row| row.publication_status == PuzzlePublicationStatus::Published)', + reason: 'puzzle_work_profile 已有 by_puzzle_work_publication_status 索引', + }, + { + file: 'server-rs/crates/spacetime-module/src/puzzle.rs', + snippet: '.puzzle_leaderboard_entry()\n .iter()\n .filter(|row| row.profile_id == profile_id && row.grid_size == grid_size)', + reason: 'puzzle_leaderboard_entry 已有 by_puzzle_leaderboard_profile_grid 索引', + }, + { + file: 'server-rs/crates/spacetime-module/src/match3d.rs', + snippet: '.match3d_work_profile()\n .iter()\n .filter(|row| {', + reason: 'match3d_work_profile 已有 owner/status 索引,列表不应整表过滤', + }, + { + file: 'server-rs/crates/spacetime-module/src/visual_novel.rs', + snippet: '.visual_novel_work_profile()\n .iter()\n .filter(|row| {', + reason: 'visual_novel_work_profile 已有 owner/status 索引,列表不应整表过滤', + }, + { + file: 'server-rs/crates/spacetime-module/src/asset_metadata/objects.rs', + snippet: '.asset_object()\n .iter()\n .find(|row| row.bucket == input.bucket && row.object_key == input.object_key)', + reason: 'asset_object 已有 by_bucket_object_key 索引', + }, + { + file: 'server-rs/crates/spacetime-module/src/asset_metadata/objects.rs', + snippet: '.asset_object()\n .iter()\n .filter(|row| row.asset_kind == asset_kind)', + reason: 'asset_object 已有 asset_kind 索引', + }, + { + file: 'server-rs/crates/spacetime-module/src/ai/stages.rs', + snippet: '.ai_task_stage()\n .iter()\n .filter(|row| row.task_id == task_id)', + reason: 'ai_task_stage 已有 by_ai_task_stage_task_id 索引', + }, + { + file: 'server-rs/crates/spacetime-module/src/ai/stages.rs', + snippet: '.ai_text_chunk()\n .iter()\n .filter(|row| row.task_id == task_id && row.stage_kind == stage_kind)', + reason: 'ai_text_chunk 已有 by_ai_text_chunk_task_id / by_ai_text_chunk_task_stage_sequence 索引', + }, + { + file: 'server-rs/crates/spacetime-module/src/ai/snapshots.rs', + snippet: '.ai_task_stage()\n .iter()\n .filter(|stage| stage.task_id == row.task_id)', + reason: 'ai_task_stage 快照组装应使用 by_ai_task_stage_task_id 索引', + }, + { + file: 'server-rs/crates/spacetime-module/src/ai/snapshots.rs', + snippet: '.ai_result_reference()\n .iter()\n .filter(|reference| reference.task_id == row.task_id)', + reason: 'ai_result_reference 快照组装应使用 by_ai_result_reference_task_id 索引', + }, + { + file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs', + snippet: '.profile_save_archive()\n .iter()\n .filter(|row| row.user_id == validated_input.user_id)', + reason: 'profile_save_archive 已有 by_profile_save_archive_user_id 索引', + }, + { + file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs', + snippet: '.profile_played_world()\n .iter()\n .filter(|row| row.user_id == validated_input.user_id)', + reason: 'profile_played_world 已有 by_profile_played_world_user_id 索引', + }, + { + file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs', + snippet: '.profile_wallet_ledger()\n .iter()\n .filter(|row| row.user_id == validated_input.user_id)', + reason: 'profile_wallet_ledger 已有 by_profile_wallet_ledger_user_id 索引', + }, + { + file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs', + snippet: '.profile_referral_relation()\n .iter()\n .filter(|row| row.inviter_user_id == user_id)', + reason: 'profile_referral_relation 已有 by_profile_referral_inviter_user_id 索引', + }, + { + file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs', + snippet: '.profile_recharge_order()\n .iter()\n .filter(|row| row.user_id == user_id)', + reason: 'profile_recharge_order 已有 by_profile_recharge_order_user_id 索引', + }, + { + file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs', + snippet: '.tracking_daily_stat()\n .iter()\n .filter(|row| {', + reason: 'tracking_daily_stat 已有 by_tracking_daily_stat_scope_day / event_day 索引,analytics 查询不应整表过滤', + }, + { + 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.rs', + snippet: '.custom_world_profile()\n .iter()\n .filter(|profile| {', + reason: 'custom_world_profile Published 同步已有 by_custom_world_profile_publication_status 索引', + }, +]; + +const procedureResultFiles = [ + 'server-rs/crates/module-puzzle/src/application.rs', + 'server-rs/crates/module-big-fish/src/domain.rs', + 'server-rs/crates/spacetime-module/src/match3d/types.rs', + 'server-rs/crates/spacetime-module/src/square_hole/types.rs', + 'server-rs/crates/spacetime-module/src/visual_novel.rs', + 'server-rs/crates/spacetime-module/src/bark_battle/types.rs', +]; + +const mapperCompatibilityFiles = [ + 'server-rs/crates/spacetime-client/src/mapper.rs', + 'server-rs/crates/spacetime-client/src/lib.rs', +]; + +const bigFishRuntimeFiles = [ + 'server-rs/crates/module-big-fish/src/commands.rs', + 'server-rs/crates/spacetime-module/src/big_fish/runtime.rs', + 'server-rs/crates/spacetime-module/src/big_fish/session.rs', +]; + +const legacyMapperPatterns = [ + { + pattern: /\b[A-Za-z0-9_]*JsonRecord\b/u, + reason: 'spacetime-client mapper 不应保留旧 ProcedureResult JSON 兼容 Record', + }, + { + pattern: /\bCompatibleBigFish[A-Za-z0-9_]*\b/u, + reason: 'spacetime-client mapper 不应保留 BigFish 旧 JSON 兼容结构', + }, + { + pattern: /\bmap_[A-Za-z0-9_]*_json\b/u, + reason: 'spacetime-client mapper 不应再通过 map_*_json 反序列化 procedure payload', + }, + { + pattern: /serde_json::from_str::<[A-Za-z0-9_:]*JsonRecord/u, + reason: 'spacetime-client mapper 不应把 procedure result 再反序列化为 JsonRecord', + }, + { + pattern: /\b(?:items|run|work|session|event|feedback)_json:\s*Some\(/u, + reason: 'mapper 测试与兼容路径不应再构造旧 procedure JSON 字符串字段', + }, +]; + +const typedProcedurePayloadFieldPattern = + /\b(?:row|session|work|item|items|run|event|feedback)_json:\s*Option/gu; + +const failures = []; + +for (const rule of forbiddenSnippets) { + const content = readUtf8(rule.file); + if (content === null) { + continue; + } + if (content.includes(rule.snippet)) { + failures.push(`${rule.file}: ${rule.reason}`); + } +} + +for (const file of procedureResultFiles) { + const content = readUtf8(file); + if (content === null) { + continue; + } + const resultBlocks = content.match(/pub struct [A-Za-z0-9_]*ProcedureResult\s*\{[\s\S]*?\n\}/g) ?? []; + for (const block of resultBlocks) { + const jsonFields = block.match(typedProcedurePayloadFieldPattern); + if (jsonFields?.length) { + const name = block.match(/pub struct ([A-Za-z0-9_]+)/)?.[1] ?? 'ProcedureResult'; + failures.push(`${file}: ${name} 仍通过 ${jsonFields.join(', ')} 跨层返回 JSON 字符串`); + } + } +} + +for (const file of mapperCompatibilityFiles) { + const content = readUtf8(file); + if (content === null) { + continue; + } + for (const rule of legacyMapperPatterns) { + if (rule.pattern.test(content)) { + failures.push(`${file}: ${rule.reason}`); + } + } +} + +for (const file of bigFishRuntimeFiles) { + const content = readUtf8(file); + if (content === null) { + continue; + } + const resultBlocks = content.match(/pub struct [A-Za-z0-9_]*ProcedureResult\s*\{[\s\S]*?\n\}/g) ?? []; + for (const block of resultBlocks) { + const jsonFields = block.match(typedProcedurePayloadFieldPattern); + if (jsonFields?.length) { + const name = block.match(/pub struct ([A-Za-z0-9_]+)/)?.[1] ?? 'ProcedureResult'; + failures.push(`${file}: ${name} 仍通过 ${jsonFields.join(', ')} 跨层返回 JSON 字符串`); + } + } +} + +if (failures.length > 0) { + console.error('SpacetimeDB runtime access 检查失败:'); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exit(1); +} + +console.log('SpacetimeDB runtime access 检查通过。'); diff --git a/scripts/container-compose.mjs b/scripts/container-compose.mjs new file mode 100644 index 00000000..0ee92af5 --- /dev/null +++ b/scripts/container-compose.mjs @@ -0,0 +1,99 @@ +import {spawn} from 'node:child_process'; +import {copyFileSync, existsSync} from 'node:fs'; +import path from 'node:path'; + +const [, , rawCommand = 'help', ...args] = process.argv; +const command = rawCommand.trim(); +const printComposeConfig = args.includes('--print'); +const passthroughArgs = args.filter((arg) => arg !== '--print'); +const projectRoot = process.cwd(); +const composeFile = path.join('deploy', 'container', 'docker-compose.loadtest.yml'); +const envExamplePath = path.join('deploy', 'container', 'api-server.env.example'); +const envPath = path.join('deploy', 'container', 'api-server.env'); + +const supportedCommands = new Set(['init', 'build', 'up', 'down', 'logs', 'ps', 'config', 'k6']); + +if (command === 'help' || !supportedCommands.has(command)) { + printHelp(command !== 'help'); + process.exit(command === 'help' ? 0 : 1); +} + +if (command === 'init') { + ensureEnvFile(); + process.exit(0); +} + +if (!existsSync(envPath)) { + ensureEnvFile(); + console.error('[container] 请先检查 deploy/container/api-server.env 中的 SpacetimeDB 地址、库名和 token。'); + process.exit(1); +} + +const composeArgs = buildComposeArgs(command, passthroughArgs); +const child = spawn('docker', composeArgs, { + cwd: projectRoot, + env: process.env, + stdio: 'inherit', + shell: false, +}); + +child.on('error', (error) => { + console.error(`[container] docker compose 启动失败: ${error.message}`); + console.error('[container] 请确认 Docker Desktop 或 Docker Engine 已安装,并且 docker 在 PATH 中。'); + process.exit(1); +}); + +child.on('exit', (code, signal) => { + if (signal) { + console.error(`[container] docker compose 被信号终止: ${signal}`); + process.exit(1); + } + process.exit(code ?? 0); +}); + +function buildComposeArgs(selectedCommand, extraArgs) { + const baseArgs = ['compose', '-f', composeFile]; + switch (selectedCommand) { + case 'build': + return [...baseArgs, 'build', ...extraArgs]; + case 'up': + return [...baseArgs, 'up', '-d', ...extraArgs]; + case 'down': + return [...baseArgs, 'down', ...extraArgs]; + case 'logs': + return [...baseArgs, 'logs', ...extraArgs]; + case 'ps': + return [...baseArgs, 'ps', ...extraArgs]; + case 'config': + return [...baseArgs, 'config', ...(printComposeConfig ? [] : ['--quiet']), ...extraArgs]; + case 'k6': + return [...baseArgs, '--profile', 'loadtest', 'run', '--rm', 'k6', ...extraArgs]; + default: + throw new Error(`unsupported command: ${selectedCommand}`); + } +} + +function ensureEnvFile() { + if (existsSync(envPath)) { + console.log(`[container] 已存在 ${envPath}`); + return; + } + copyFileSync(envExamplePath, envPath); + console.log(`[container] 已从 ${envExamplePath} 生成 ${envPath}`); +} + +function printHelp(isError) { + const output = isError ? console.error : console.log; + output(`Usage: npm run container: -- [docker compose args] + +Commands: + container:init 生成 deploy/container/api-server.env + container:build 构建 api-server 容器镜像 + container:up 后台启动 api-server + nginx + otelcol + container:down 停止并清理容器 + container:logs 查看容器日志 + container:ps 查看容器状态 + container:config 校验 compose 配置,传 -- --print 可展开完整配置 + container:k6 在 compose 网络内运行 k6 +`); +} diff --git a/scripts/dev-utils.mjs b/scripts/dev-utils.mjs index 39d12fcf..e3e24402 100644 --- a/scripts/dev-utils.mjs +++ b/scripts/dev-utils.mjs @@ -2,6 +2,13 @@ import {existsSync, mkdirSync, readFileSync} from 'node:fs'; import {dirname, isAbsolute, resolve} from 'node:path'; export const LOCAL_ENV_FILES = ['.env', '.env.local', '.env.secrets.local']; +const LOCAL_ENV_OVERRIDE_KEYS = new Set([ + 'SMS_AUTH_ENABLED', + 'SMS_AUTH_PROVIDER', + 'SMS_AUTH_MOCK_VERIFY_CODE', + 'WECHAT_AUTH_ENABLED', + 'WECHAT_AUTH_PROVIDER', +]); export function buildProtectedEnvKeys(baseEnv) { return new Set( @@ -29,7 +36,7 @@ export function loadEnvFile(path, target, protectedKeys) { } const [, key, rawValue] = match; - if (protectedKeys.has(key)) { + if (protectedKeys.has(key) && !LOCAL_ENV_OVERRIDE_KEYS.has(key)) { continue; } diff --git a/scripts/dev-utils.test.ts b/scripts/dev-utils.test.ts index a9f3b902..aeabfcee 100644 --- a/scripts/dev-utils.test.ts +++ b/scripts/dev-utils.test.ts @@ -68,6 +68,26 @@ describe('dev utils env merge', () => { ); }); + test('本地认证开关覆盖外层 shell 旧值', () => { + withTempEnvFiles( + { + '.env.local': [ + 'SMS_AUTH_ENABLED=true', + 'SMS_AUTH_PROVIDER=aliyun', + ].join('\n'), + }, + (_env, tempDir) => { + const env = mergeApiServerEnv(tempDir, { + SMS_AUTH_ENABLED: 'false', + SMS_AUTH_PROVIDER: 'mock', + }); + + expect(env.SMS_AUTH_ENABLED).toBe('true'); + expect(env.SMS_AUTH_PROVIDER).toBe('aliyun'); + }, + ); + }); + test('空外层 shell 变量不会遮蔽本地私密配置', () => { withTempEnvFiles( { diff --git a/scripts/generate-child-motion-demo-assets.mjs b/scripts/generate-child-motion-demo-assets.mjs index 9f1a6690..e3a81fbb 100644 --- a/scripts/generate-child-motion-demo-assets.mjs +++ b/scripts/generate-child-motion-demo-assets.mjs @@ -158,6 +158,26 @@ const assetDefinitions = [ chromaKeyNote, ].join(''), }, + { + id: 'character-outline-only-v3', + output: 'picture-book-character-outline-v3.png', + sourceOutput: 'picture-book-character-outline-v2.png', + sourceDirectory: 'asset', + transparent: true, + localPostprocess: 'character-outline-only', + prompt: + '本地后处理资源:基于 character-outline-v2 提取用户角色外轮廓,只保留浅青白描边,中间完全透明,不保留原有半透明材质、填充和明暗变化。', + }, + { + id: 'character-outline-white-v4', + output: 'picture-book-character-outline-v4.png', + sourceOutput: 'picture-book-character-outline-v2.png', + sourceDirectory: 'asset', + transparent: true, + localPostprocess: 'character-outline-white-thin', + prompt: + '本地后处理资源:基于 character-outline-v2 提取用户角色外轮廓,先弱化耳朵、手指、脚趾等细碎凸起,再输出更细的白色描边,中间完全透明。', + }, { id: 'hud-strip', output: 'picture-book-hud-strip-v2.png', @@ -601,6 +621,16 @@ const assetDefinitions = [ chromaKeyNote, ].join(''), }, + { + id: 'wave-cat-body-guide-v7', + output: 'picture-book-wave-cat-body-guide-v7.png', + sourceOutput: 'picture-book-wave-cat-body-guide-v6.png', + sourceDirectory: 'asset', + transparent: true, + localPostprocess: 'remove-cat-body-shoulder-dots', + prompt: + '本地后处理资源:基于 wave-cat-body-guide-v6 去除身体左右两侧不协调的小圆点,保留猫头、身体、透明边界和整体水彩风格。', + }, { id: 'wave-cat-arm-guide-v6', output: 'picture-book-wave-cat-arm-guide-v6.png', @@ -632,6 +662,37 @@ const assetDefinitions = [ chromaKeyNote, ].join(''), }, + { + id: 'wave-cat-arm-guide-v7', + output: 'picture-book-wave-cat-arm-guide-v7.png', + sourceOutput: 'picture-book-wave-cat-arm-guide-v7-source.png', + size: '1024x1024', + transparent: true, + transparencyCleanup: 'cat-guide', + useWaveCatHeadReference: true, + useWaveCatArmReference: true, + layoutNormalization: { + canvasWidth: 1024, + canvasHeight: 1024, + fit: 'contain', + fillWidth: 0.74, + fillHeight: 0.88, + anchorY: 'bottom', + padding: 20, + }, + prompt: [ + '请在参考手臂资源的基础上重新绘制儿童动作互动游戏猫咪挥手动画用的单侧手臂资源,严格作为可动纸偶拆件。只画一条橘白猫手臂:底部是肩膀连接端,向左上方弯曲,末端是一只简化圆猫手。', + '关键修改:末端圆猫爪必须正面对镜头,像在对观众挥手。圆爪正面轮廓要清楚可见,不要转成侧面,不要转向画面内侧或角色中心,不要画成握拳或背面。可以用浅奶油白圆形爪面、柔和高光和非常淡的短弧线表现正面对镜头。', + '猫手必须像多啦A梦式圆手或软玩具圆爪:一个完整圆润圆爪,不画分开的手指,不画尖爪,不画黑色或深色爪垫。若需要爪面细节,只允许非常浅的桃色小圆面或柔和弧线,不能变成真实动物爪垫。', + '手臂短而厚实,像小猫上肢,不要成人类长手臂。资源必须适合网页左右镜像复用和围绕肩部连接点旋转:肩膀连接端在画面底部偏内侧,圆手在画面上方,四周留透明空白。', + '颜色参考输入猫猫头和参考手臂:浅奶油白和淡橘色为主体,少量浅橘斑纹,柔和暖棕或浅橘棕描边;不要纯黑粗描边。', + '请避免粉色大背景、避免主体外侧彩色光晕,主体贴纸外轮廓之外必须直接是纯色背景。资源自身保持清晰不透明,半透明效果由网页 CSS 控制。', + '不要画猫头、躯干、另一只手臂、完整动物、腿、脚、文字、数字、按钮、面板、水印、真实照片质感、黑色、灰色、黑白毛、黑灰重阴影或深色大面积毛。', + styleReferenceNote, + noStretchNote, + chromaKeyNote, + ].join(''), + }, ]; const args = new Map(); @@ -811,6 +872,12 @@ function buildRequestBody(asset, size) { path.join(assetDir, 'picture-book-wave-cat-head-guide-v2.png'), ); } + if (asset.useWaveCatArmReference) { + pushReferenceImage( + body, + path.join(assetDir, 'picture-book-wave-cat-arm-guide-v6.png'), + ); + } return body; } @@ -864,6 +931,9 @@ function outputPathFor(asset) { } function sourceOutputPathFor(asset) { + if (asset.sourceDirectory === 'asset') { + return path.join(assetDir, asset.sourceOutput || asset.output); + } return path.join(intermediateDir, asset.sourceOutput || asset.output); } @@ -1038,6 +1108,92 @@ function removeCharacterOutlineChromaKey(sourcePath, finalPath) { } } +function createCharacterOutlineOnlyIndicator(sourcePath, finalPath) { + const script = [ + 'from PIL import Image, ImageChops, ImageFilter', + 'import sys', + 'source, out = sys.argv[1], sys.argv[2]', + 'im = Image.open(source).convert("RGBA")', + 'alpha = im.getchannel("A")', + 'mask = alpha.point(lambda v: 255 if v > 24 else 0)', + 'mask = mask.filter(ImageFilter.MaxFilter(5)).filter(ImageFilter.MinFilter(5))', + 'outer = mask.filter(ImageFilter.MaxFilter(47))', + 'inner = mask.filter(ImageFilter.MinFilter(47))', + 'stroke = ImageChops.subtract(outer, inner)', + 'stroke = stroke.filter(ImageFilter.GaussianBlur(0.45))', + 'glow = stroke.filter(ImageFilter.GaussianBlur(3.0)).point(lambda v: int(v * 0.34))', + 'result = Image.new("RGBA", im.size, (0, 0, 0, 0))', + 'glow_layer = Image.new("RGBA", im.size, (91, 205, 197, 0))', + 'glow_layer.putalpha(glow)', + 'line_layer = Image.new("RGBA", im.size, (224, 255, 247, 0))', + 'line_layer.putalpha(stroke.point(lambda v: min(235, int(v * 0.92))))', + 'result.alpha_composite(glow_layer)', + 'result.alpha_composite(line_layer)', + 'result.save(out)', + ].join('\n'); + + const result = spawnSync('python', ['-c', script, sourcePath, finalPath], { + cwd: repoRoot, + encoding: 'utf8', + }); + + if (result.status !== 0) { + throw new Error( + `Failed to create outline-only character indicator: ${(result.stderr || result.stdout).trim()}`, + ); + } +} + +function createWhiteCharacterOutlineIndicator(sourcePath, finalPath) { + const script = [ + 'from pathlib import Path', + 'import cv2', + 'import numpy as np', + 'from PIL import Image', + 'import sys', + 'source, out = Path(sys.argv[1]), Path(sys.argv[2])', + 'rgba = np.array(Image.open(source).convert("RGBA"))', + 'alpha = rgba[:, :, 3]', + 'mask = (alpha > 24).astype(np.uint8) * 255', + 'contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)', + 'body = np.zeros_like(mask)', + 'if contours:', + ' largest = max(contours, key=cv2.contourArea)', + ' cv2.drawContours(body, [largest], -1, 255, thickness=cv2.FILLED)', + 'open_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (25, 25))', + 'close_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (35, 35))', + 'body = cv2.morphologyEx(body, cv2.MORPH_OPEN, open_kernel, iterations=1)', + 'body = cv2.morphologyEx(body, cv2.MORPH_CLOSE, close_kernel, iterations=1)', + 'body = cv2.GaussianBlur(body, (0, 0), 7.0)', + '_, body = cv2.threshold(body, 92, 255, cv2.THRESH_BINARY)', + 'body = cv2.GaussianBlur(body, (0, 0), 1.4)', + '_, body = cv2.threshold(body, 64, 255, cv2.THRESH_BINARY)', + 'line_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15))', + 'outer = cv2.dilate(body, line_kernel, iterations=1)', + 'inner = cv2.erode(body, line_kernel, iterations=1)', + 'stroke = cv2.subtract(outer, inner)', + 'stroke = cv2.GaussianBlur(stroke, (0, 0), 0.55)', + 'glow = cv2.GaussianBlur(stroke, (0, 0), 2.2)', + 'result = np.zeros((mask.shape[0], mask.shape[1], 4), dtype=np.uint8)', + 'glow_alpha = np.clip(glow.astype(np.float32) * 0.22, 0, 70).astype(np.uint8)', + 'line_alpha = np.clip(stroke.astype(np.float32) * 0.78, 0, 205).astype(np.uint8)', + 'result[:, :, 0:3] = 255', + 'result[:, :, 3] = np.maximum(glow_alpha, line_alpha)', + 'Image.fromarray(result, "RGBA").save(out)', + ].join('\n'); + + const result = spawnSync('python', ['-c', script, sourcePath, finalPath], { + cwd: repoRoot, + encoding: 'utf8', + }); + + if (result.status !== 0) { + throw new Error( + `Failed to create thin white character indicator: ${(result.stderr || result.stdout).trim()}`, + ); + } +} + function removeCatGuideChromaKey(sourcePath, finalPath) { const script = [ 'from collections import deque', @@ -1253,6 +1409,50 @@ function scrubChromaFringe(finalPath) { } } +function removeCatBodyShoulderDots(sourcePath, finalPath) { + const script = [ + 'from pathlib import Path', + 'import cv2', + 'import numpy as np', + 'from PIL import Image', + 'source, out = Path(__import__("sys").argv[1]), Path(__import__("sys").argv[2])', + 'rgba = np.array(Image.open(source).convert("RGBA"))', + 'rgb = rgba[:, :, :3].copy()', + 'alpha = rgba[:, :, 3]', + 'opaque = alpha > 10', + 'known = opaque.astype(np.uint8)', + 'unknown = (1 - known).astype(np.uint8)', + '_, labels = cv2.distanceTransformWithLabels(unknown, cv2.DIST_L2, 5, labelType=cv2.DIST_LABEL_PIXEL)', + 'flat_known_indices = np.flatnonzero(known.reshape(-1))', + 'filled_rgb = rgb.copy().reshape(-1, 3)', + 'labels_flat = labels.reshape(-1)', + 'unknown_flat = unknown.reshape(-1).astype(bool)', + 'if flat_known_indices.size > 0 and unknown_flat.any():', + ' nearest_known_flat_index = flat_known_indices[np.maximum(labels_flat[unknown_flat] - 1, 0)]', + ' filled_rgb[unknown_flat] = filled_rgb[nearest_known_flat_index]', + 'filled_rgb = filled_rgb.reshape(rgb.shape)', + 'bgr = cv2.cvtColor(filled_rgb, cv2.COLOR_RGB2BGR)', + 'mask = np.zeros(alpha.shape, dtype=np.uint8)', + 'cv2.ellipse(mask, (383, 763), (23, 26), 0, 0, 360, 255, -1)', + 'cv2.ellipse(mask, (648, 762), (23, 26), 0, 0, 360, 255, -1)', + 'mask = cv2.bitwise_and(mask, opaque.astype(np.uint8) * 255)', + 'repaired = cv2.inpaint(bgr, mask, 7, cv2.INPAINT_TELEA)', + 'repaired_rgb = cv2.cvtColor(repaired, cv2.COLOR_BGR2RGB)', + 'Image.fromarray(np.dstack([repaired_rgb, alpha]), "RGBA").save(out)', + ].join('\n'); + + const result = spawnSync('python', ['-c', script, sourcePath, finalPath], { + cwd: repoRoot, + encoding: 'utf8', + }); + + if (result.status !== 0) { + throw new Error( + `Failed to remove cat body shoulder dots: ${(result.stderr || result.stdout).trim()}`, + ); + } +} + function writeOpaquePng(sourcePath, outputPath) { const result = spawnSync( 'python', @@ -1291,6 +1491,54 @@ async function generateAsset(asset, env, size, force) { } if (args.has('--postprocess-only')) { + if (asset.localPostprocess === 'character-outline-white-thin') { + const sourcePath = sourceOutputPathFor(asset); + if (!existsSync(sourcePath)) { + throw new Error(`Missing source image for postprocess-only: ${sourcePath}`); + } + mkdirSync(assetDir, { recursive: true }); + createWhiteCharacterOutlineIndicator(sourcePath, finalPath); + return { + id: asset.id, + ok: true, + file: finalPath, + sourceFile: sourcePath, + postprocessedOnly: true, + }; + } + + if (asset.localPostprocess === 'character-outline-only') { + const sourcePath = sourceOutputPathFor(asset); + if (!existsSync(sourcePath)) { + throw new Error(`Missing source image for postprocess-only: ${sourcePath}`); + } + mkdirSync(assetDir, { recursive: true }); + createCharacterOutlineOnlyIndicator(sourcePath, finalPath); + return { + id: asset.id, + ok: true, + file: finalPath, + sourceFile: sourcePath, + postprocessedOnly: true, + }; + } + + if (asset.localPostprocess === 'remove-cat-body-shoulder-dots') { + const sourcePath = sourceOutputPathFor(asset); + if (!existsSync(sourcePath)) { + throw new Error(`Missing source image for postprocess-only: ${sourcePath}`); + } + mkdirSync(assetDir, { recursive: true }); + removeCatBodyShoulderDots(sourcePath, finalPath); + return { + id: asset.id, + ok: true, + file: finalPath, + sourceFile: sourcePath, + postprocessedOnly: true, + }; + } + if (!asset.transparent) { return { id: asset.id, @@ -1328,6 +1576,54 @@ async function generateAsset(asset, env, size, force) { }; } + if (asset.localPostprocess === 'character-outline-white-thin') { + const sourcePath = sourceOutputPathFor(asset); + if (!existsSync(sourcePath)) { + throw new Error(`Missing source image for local postprocess: ${sourcePath}`); + } + mkdirSync(assetDir, { recursive: true }); + createWhiteCharacterOutlineIndicator(sourcePath, finalPath); + return { + id: asset.id, + ok: true, + file: finalPath, + sourceFile: sourcePath, + postprocessedOnly: true, + }; + } + + if (asset.localPostprocess === 'character-outline-only') { + const sourcePath = sourceOutputPathFor(asset); + if (!existsSync(sourcePath)) { + throw new Error(`Missing source image for local postprocess: ${sourcePath}`); + } + mkdirSync(assetDir, { recursive: true }); + createCharacterOutlineOnlyIndicator(sourcePath, finalPath); + return { + id: asset.id, + ok: true, + file: finalPath, + sourceFile: sourcePath, + postprocessedOnly: true, + }; + } + + if (asset.localPostprocess === 'remove-cat-body-shoulder-dots') { + const sourcePath = sourceOutputPathFor(asset); + if (!existsSync(sourcePath)) { + throw new Error(`Missing source image for local postprocess: ${sourcePath}`); + } + mkdirSync(assetDir, { recursive: true }); + removeCatBodyShoulderDots(sourcePath, finalPath); + return { + id: asset.id, + ok: true, + file: finalPath, + sourceFile: sourcePath, + postprocessedOnly: true, + }; + } + const requestBody = buildRequestBody(asset, size); const payloadText = await fetchWithTimeout( buildVectorEngineImagesGenerationUrl(env.baseUrl), @@ -1427,6 +1723,7 @@ function dryRun(selectedAssets, size) { ? sourceOutputPathFor(asset) : undefined, transparent: asset.transparent, + localPostprocess: asset.localPostprocess, body: { ...body, image: body.image ? [''] : undefined, 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/scripts/generate-taonier-logo-concepts.mjs b/scripts/generate-taonier-logo-concepts.mjs index cdc2899f..2faf51b9 100644 --- a/scripts/generate-taonier-logo-concepts.mjs +++ b/scripts/generate-taonier-logo-concepts.mjs @@ -1,5 +1,7 @@ import { Buffer } from 'node:buffer'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import http from 'node:http'; +import https from 'node:https'; import path from 'node:path'; const repoRoot = process.cwd(); @@ -156,6 +158,12 @@ const handsConcepts = [ prompt: '围绕“陶泥儿”Logo 方向 03 的“上下两只手”感觉做延展。设计一个无文字扁平矢量主标:上下一对抽象软手 / 软泥掌从两侧微微合捏,中间形成一颗小圆珠或作品核。图形要像品牌符号,不像手势教学图;保留托举与成型的温柔感。禁止播放三角、聊天气泡、笑脸、眼睛、花朵、褐色主色、真实手指、复杂掌纹、碎小图标。风格:flat vector, minimal, mainstream app logo, high contrast, iconic, friendly。配色:莓红、奶白、薄荷青、少量深墨,最多 3 色。', }, + { + id: 'taonier-hands-cradle-v2', + title: '托星软掌', + prompt: + '为“陶泥儿”设计无文字扁平矢量 Logo。图形是上下两片圆润软托,托住中央一颗小星,像把灵感轻轻捏成作品。不要画具体手指,只保留抽象软掌感觉。适合 App icon,简单、亲和、醒目、小尺寸清楚。配色:珊瑚红、薄荷青、奶油白,最多三色。不要播放三角、聊天气泡、笑脸、眼睛、花朵、褐色、文字、字母、3D、碎元素。', + }, { id: 'taonier-hands-soft-bowl', title: '创意托碗', @@ -176,6 +184,464 @@ const handsConcepts = [ }, ]; +const broadConcepts = [ + { + id: 'taonier-broad-clay-dot-crown', + title: '泥点皇冠', + prompt: + '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品是 AI UGC 创作与轻休闲互动内容平台,用户用“泥点”驱动 AI,把一句脑洞、一张图或一个梗捏成小游戏和可分享作品。本方向把“泥点”做成核心品牌符号:3 到 5 个圆润泥点自然聚合,形成一个像皇冠、火苗、作品星核之间的抽象主轮廓,表达很多灵感汇聚成精品作品。整体必须像成熟 App 主标,亲和、明亮、可注册感强,小尺寸清楚。避免播放三角、聊天气泡、笑脸、真实陶艺、褐色陶土主色、人物、手、复杂碎点。风格:flat vector logo, bold simple silhouette, modern consumer app, warm, memorable, scalable, solid colors。配色:珊瑚红、奶油白、青绿色、少量金色,最多 4 色。无文字、无字母、无水印、无 3D、无厚阴影、无玻璃高光。', + }, + { + id: 'taonier-broad-soft-portal', + title: '软泥入口', + prompt: + '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品把 AI 创作、UGC、小游戏、视觉小说、拼图和轻互动作品放在同一平台内,核心感觉是“打开一个软软的创作入口,进去就能造作品”。图形主体是一枚被捏开的柔软入口/门洞,外轮廓像软泥被拉开,中心留出干净负形作品核或小星点。图形要完整、抽象、主流,不像播放器、不像聊天框、不像眼睛。风格:flat vector brand mark, simple, iconic, friendly premium, strong silhouette, app icon ready。配色使用亮珊瑚、薄荷青、奶油白、深墨中的 3 色。禁止中文字、英文字母、真实门、真实陶土、3D、复杂纹理、碎小装饰、UI 按钮。', + }, + { + id: 'taonier-broad-work-embryo', + title: '作品胚芽', + prompt: + '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。品牌隐喻不是传统陶艺,而是“灵感胚胎被 AI 塑形成可玩的作品”。图形主体是一颗圆润的作品胚芽:外形像软泥种子、游戏棋子和小宇宙的结合,内部只有一条柔软切面和一个小星点负形。整体高级、温柔、年轻,适合平台主 Logo 和 App icon。避免植物叶子过强、教育儿童感、播放按钮、聊天气泡、笑脸、循环箭头、褐色主色。风格:flat vector, premium friendly app logo, minimal, bold, clear at 32px, solid colors。配色:湖蓝或青绿主色,珊瑚橙点缀,奶白负形,最多 3 色。无文字、无字母、无水印、无 3D、无照片质感。', + }, + { + id: 'taonier-broad-game-mold', + title: '游戏模芯', + prompt: + '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品不是工具后台,而是能把脑洞生成拼图、抓大鹅、视觉小说、文字游戏等互动作品的平台。本方向用“游戏模芯”做符号:一个圆润软泥主形中嵌入极简十字方向键或小方块负形,但不要画传统手柄,不要出现播放三角。图形要表达可玩、轻休闲、低门槛创作,同时保持品牌主标感。风格:flat vector logo, simple geometric, friendly, playful but mature, app icon, high contrast。配色:珊瑚红、青绿、奶油白、深墨,最多 4 色。禁止文字、字母、水印、3D、复杂按钮、真实手柄、聊天气泡、笑脸、儿童玩具感。', + }, + { + id: 'taonier-broad-tao-negative', + title: '陶字负形', + prompt: + '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。尝试从“陶”的结构提炼抽象负形,但不要直接写汉字,也不要让模型生成可读文字。图形主体是一枚圆润软泥徽标,内部用两到三块负形构成类似陶器开口、耳部、土块和作品核的抽象关系,让熟悉中文的人隐约感到“陶”,但第一眼仍是现代 App 标志。风格:flat vector brand symbol, abstract Chinese-inspired, clean, iconic, friendly premium, scalable。配色:深墨或莓红主形,奶油白负形,青绿小点缀。禁止真实汉字、书法、篆刻、传统印章、褐色陶艺、播放按钮、聊天气泡、人物、3D、水印。', + }, + { + id: 'taonier-broad-soft-totem', + title: '软体图腾', + prompt: + '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。基于 Taonier / 陶泥儿 的品牌声母感觉做一个抽象软体图腾,但不要直接画英文字母 T,也不要生成任何文字。图形由一笔连续的圆润软泥带形成稳定的竖向图腾,顶部像被轻捏出的小角,中心有一颗作品星核负形,表达“捏、造、发布”。整体要比普通字母标更独特,适合 App icon、favicon 和平台顶栏。风格:flat vector logo, bold, simple, modern, friendly, memorable, solid colors。配色:珊瑚红主形、奶油白负形、薄荷青小面积辅助。禁止文字、字母直出、播放三角、聊天气泡、笑脸、无限循环、褐色陶土、3D、复杂纹理。', + }, + { + id: 'taonier-broad-creation-spark', + title: '开捏火花', + prompt: + '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。核心动作为“开捏”:用户输入灵感,AI 立刻生成可玩的作品。图形不要画真实手,用两块极简软形挤压出中心火花,火花不是爆炸特效,而是一个稳定的四角作品星核。外轮廓要比上一轮左右括号更完整,像一个独立品牌图腾。风格:flat vector logo, iconic, minimal, high contrast, friendly, youthful, app icon ready。配色:莓红或珊瑚红主形,奶油白负形,青绿中心点缀,最多 3 色。禁止文字、字母、水印、播放三角、聊天气泡、笑脸、眼睛、真实手指、碎粒、3D、厚阴影。', + }, + { + id: 'taonier-broad-content-orbit', + title: '作品星轨', + prompt: + '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品承载多种互动内容:RPG、拼图、抓大鹅、视觉小说、文字游戏、儿童寓教于乐。图形用一个软泥圆核和两条极简短弧形成“作品星轨”,表达一个灵感生成多个作品形态;但整体必须是一个凝聚的主标,不是天文图标。风格:flat vector brand mark, simple, premium friendly, clean geometry, app icon, scalable。配色:青绿主核、珊瑚红弧线、奶油白负形、深墨小轮廓可选。禁止文字、字母、水印、真实星球、复杂轨道、科技冷硬、播放键、聊天气泡、循环箭头、3D。', + }, +]; + +const freshConcepts = [ + { + id: 'taonier-fresh-wheel-imprint', + title: '陶轮印记', + prompt: + '为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:俯视一个正在旋转的创作轮盘,圆环被轻轻压出一处缺口,像把灵感旋成作品。成熟消费级 App 主标,几何、干净、有速度感。配色:钴蓝、奶白、珊瑚红、少量深墨。不要软手、星核、聊天气泡、播放键、笑脸、真实陶艺、褐色、文字、字母、3D。', + }, + { + id: 'taonier-fresh-mold-window', + title: '模具窗格', + prompt: + '为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:一个圆角模具窗口,内部是 2x2 的不规则负形窗格,像多种小游戏和互动作品从同一个模具里生成。主流、简洁、品牌感强、小尺寸清楚。配色:深墨主形、奶油白负形、亮青绿和珊瑚小点缀。不要软手、星星、播放键、聊天气泡、脸、真实陶土、文字、字母、3D。', + }, + { + id: 'taonier-fresh-dot-dice', + title: '泥点骰面', + prompt: + '为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:一枚圆润方形骰面或游戏牌面,5 个泥点孔组成独特节奏,表达泥点、玩法和随机脑洞。不要画立体骰子,只要正面抽象符号。潮流、轻游戏、可注册。配色:象牙白底、黑色主形、荧光青、珊瑚红。不要播放键、聊天气泡、笑脸、星星、软手、褐色、文字、字母、3D。', + }, + { + id: 'taonier-fresh-pinwheel', + title: '灵感风车', + prompt: + '为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:抽象纸风车,由四片圆润色块围成旋转中心,表达简单、轻松、人人能造内容。它要像品牌主标,不像儿童玩具。配色:莓红、天蓝、薄荷、奶白、深墨。不要软泥团、手、星核、播放键、聊天气泡、笑脸、花朵、文字、字母、3D、复杂渐变。', + }, + { + id: 'taonier-fresh-pocket-world', + title: '口袋世界', + prompt: + '为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:一个抽象口袋形徽标,口袋里露出一小块世界切片或舞台切片,表示把脑洞装进口袋随手开玩。现代、亲和、平台感强。配色:青绿色主形、奶白负形、珊瑚红小块、深墨轮廓。不要软手、星核、播放键、聊天气泡、笑脸、地图图钉、真实口袋、文字、字母、3D。', + }, + { + id: 'taonier-fresh-builder-blocks', + title: '创作积木', + prompt: + '为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:三块圆角积木以不对称方式咬合,形成一个稳定主轮廓,表达 UGC 搭建、模板生成和小游戏创作。不要儿童玩具感,要成熟、潮流、清晰。配色:黑色或深紫主轮廓,珊瑚、青绿、奶白填色。不要软手、星星、播放键、聊天气泡、笑脸、褐色、文字、字母、3D。', + }, + { + id: 'taonier-fresh-stage-window', + title: '叙事舞台窗', + prompt: + '为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:一个极简舞台窗或小剧场窗口,左右两片抽象幕布形成负形中心,代表视觉小说、RPG 和互动叙事。它要是 App icon 主标,不是插画。配色:深墨、珊瑚红、奶油白、少量湖蓝。不要播放键、聊天气泡、笑脸、软手、星核、真实舞台、文字、字母、3D。', + }, + { + id: 'taonier-fresh-ribbon-knot', + title: '灵感绳结', + prompt: + '为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:一条圆润彩色泥条打成简洁绳结,像把多个创意线索系成一个作品。形状必须凝聚成单个主标,不能散。配色:珊瑚、钴蓝、薄荷、奶白,边缘干净。不要无限符号、软手、星核、播放键、聊天气泡、笑脸、褐色陶土、文字、字母、3D。', + }, + { + id: 'taonier-fresh-folded-sticker', + title: '贴纸折角', + prompt: + '为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:一张圆角贴纸或作品卡片,右上角轻轻折起,负形像一个小入口。表达 UGC、作品发布、随手开玩。成熟、潮流、极简。配色:奶白、黑、珊瑚、青绿。不要播放键、聊天气泡、笑脸、手、星星、褐色、文字、字母、3D。', + }, + { + id: 'taonier-fresh-punch-hole', + title: '印模孔洞', + prompt: + '为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:一个圆润印模形状,中间被冲出一个不规则圆孔,像从泥板里取出作品。抽象、强轮廓、可注册、小尺寸清楚。配色:黑色主形、奶白负形、荧光青小块、珊瑚红。不要播放键、聊天气泡、笑脸、手、星星、陶罐、文字、字母、3D。', + }, +]; + +const punchReferencePath = path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-fresh-concepts', + 'taonier-fresh-punch-hole.png', +); +const punch04ReferencePath = path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-punch-hole-concepts', + 'taonier-punch-color-inlay.png', +); +const paletteRefineReferencePath = path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-ref04-palette-transfer', + 'taonier-ref04-palette-transfer-warm-yellow-sparkle.png', +); +const paletteShapeReferencePath = path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-ref04-locked-color-concepts', + 'taonier-ref04-locked-warm-ink.png', +); +const sparkleRefineReferencePath = path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-ref04-warm-sparkle-v2-concepts', + 'taonier-ref04-warm-sparkle-terracotta.png', +); +const sparkleCropReferencePath = path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-ref04-palette-refine-concepts', + 'taonier-sparkle-reference-crop.png', +); +const paletteRefineV2ReferencePath = path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-ref04-palette-refine-v2-concepts', + 'taonier-ref04-palette-refine-v2-pale-cream.png', +); +const paletteRefineV4PaleButterReferencePath = path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-ref04-palette-refine-v4-concepts', + 'taonier-ref04-palette-refine-v4-pale-butter.png', +); + +const punchConcepts = [ + { + id: 'taonier-punch-locked-shape', + title: '原型锁定微调', + referenceImages: [punchReferencePath], + prompt: + '为“陶泥儿”继续打磨参考图 06 印模孔洞 logo。必须保持参考图基本造型不变:黑色圆润不规则环形主形、中央白色不规则孔洞、右上珊瑚红辅形、左下青蓝辅形。只优化比例、边缘、留白和小尺寸识别,让它更像成熟 App icon。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。', + }, + { + id: 'taonier-punch-stable-icon', + title: '稳定主标', + referenceImages: [punchReferencePath], + prompt: + '基于参考图 06 印模孔洞,为“陶泥儿”做无文字扁平矢量 logo 延展。保留黑色冲孔主形和中央不规则白洞,但让外轮廓更稳定、更像长期品牌主标。右上珊瑚红和左下青蓝辅形更克制,白底,强轮廓,小尺寸清楚。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。', + }, + { + id: 'taonier-punch-hole-balance', + title: '孔洞比例', + referenceImages: [punchReferencePath], + prompt: + '基于参考图 06 印模孔洞,为“陶泥儿”延展一个更干净的无文字 logo。核心仍是黑色圆润印模环和中央不规则白色孔洞,重点调整孔洞大小、厚薄关系和负形节奏,让黑形更有张力。珊瑚红、青蓝只作为小面积辅形。白底。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。', + }, + { + id: 'taonier-punch-color-inlay', + title: '彩色嵌合', + referenceImages: [punchReferencePath], + prompt: + '基于参考图 06 印模孔洞,为“陶泥儿”做彩色嵌合版 logo。黑色主环保持冲孔感,右上珊瑚红和左下青蓝两块辅形与主形更自然嵌合,像从泥板里取出的两片作品碎片。造型简洁、可注册、App icon 友好。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。', + }, + { + id: 'taonier-punch-mono-test', + title: '单色测试', + referenceImages: [punchReferencePath], + prompt: + '基于参考图 06 印模孔洞,为“陶泥儿”做单色极简版 logo。只保留黑色圆润冲孔主形和中央白色不规则孔洞,去掉彩色辅形。强调强轮廓、可注册、小尺寸识别和品牌符号感。白底。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。', + }, + { + id: 'taonier-punch-app-token', + title: '应用图标', + referenceImages: [punchReferencePath], + prompt: + '基于参考图 06 印模孔洞,为“陶泥儿”延展一个更完整的 App icon 核心图形。黑色不规则冲孔主形更饱满,中央白洞更清晰,珊瑚红与青蓝辅形保持年轻感但不抢主体。整体像可长期使用的品牌符号,不像插画。白底。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。', + }, +]; + +const punch04Concepts = [ + { + id: 'taonier-punch04-warm-ink-core', + title: '暖墨填芯', + referenceImages: [punch04ReferencePath], + prompt: + '基于参考图“04 彩色嵌合”为“陶泥儿”继续做 logo 延展。保持原有基本结构不变:一个圆润不规则环形主形,右上珊瑚红嵌合块,左下青蓝嵌合块,中央不规则孔洞。重点调整配色:中间黑色主形改为温暖深墨灰,不要纯黑;中央孔洞内部加入一枚很简洁的奶油色软泥种子/作品核填充,不要填满,保留留白呼吸。扁平矢量、品牌主标、小尺寸清楚。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。', + }, + { + id: 'taonier-punch04-navy-game-core', + title: '靛蓝作品核', + referenceImages: [punch04ReferencePath], + prompt: + '基于参考图“04 彩色嵌合”为“陶泥儿”设计一版配色延展。保持黑环、右上红块、左下青块的基本结构和嵌合关系,但把主形从黑色改为深靛蓝或蓝黑色,整体更年轻、更像互联网 App。中央空心区域加入一个极简浅色作品核:小圆角方块或软形小岛,不能像播放键、不能像字母。白底,扁平矢量,干净可注册。无文字、无字母、无聊天气泡、无手、无星星、无3D。', + }, + { + id: 'taonier-punch04-cream-window', + title: '奶油内窗', + referenceImages: [punch04ReferencePath], + prompt: + '基于参考图“04 彩色嵌合”为“陶泥儿”做一版更柔和的 logo。基本结构不变:主环、右上珊瑚红、左下青蓝、中央孔洞都保留。把原黑色主环调整为柔和深紫灰或墨绿色,降低硬度。中央孔洞不再是纯空白,设计成奶油色内窗,里面有两块极简小色面,表达多个作品从同一模具生成。整体仍然极简,不要复杂插画。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。', + }, + { + id: 'taonier-punch04-clay-gradient-flat', + title: '陶盒彩芯', + referenceImages: [punch04ReferencePath], + prompt: + '基于参考图“04 彩色嵌合”为“陶泥儿”做配色与中孔设计。保持 04 的基本轮廓和红青嵌合块位置。主形不要纯黑,改成深陶紫、莓紫或炭灰紫,仍保持强轮廓。中央孔洞加入一个扁平的彩色泥芯,由珊瑚、青蓝、奶白三块圆润小面组成,像作品被捏出来的内核。不要渐变高光,不要立体,不要复杂细节。无文字、无字母、无播放键、无聊天气泡、无手、无星星。', + }, + { + id: 'taonier-punch04-mint-shadow', + title: '薄荷深影', + referenceImages: [punch04ReferencePath], + prompt: + '基于参考图“04 彩色嵌合”为“陶泥儿”做一版更清爽的品牌 logo。保持 04 的三块嵌合结构不变。把中间黑色主形改成深青绿/墨绿,右上红块更偏珊瑚,左下青块更偏亮薄荷。中央空心处加入一枚小小的浅黄色或奶白圆角形,像可玩的作品胚,不要过大。整体强识别、轻休闲、App icon 友好。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。', + }, + { + id: 'taonier-punch04-negative-tile', + title: '内嵌拼片', + referenceImages: [punch04ReferencePath], + prompt: + '基于参考图“04 彩色嵌合”为“陶泥儿”做一版中间内容更明确的 logo。保持外部基本结构和红青嵌合块位置不变。主形从纯黑改为深墨蓝灰。中央不规则孔洞内部放入一个极简拼片/圆角模块组合,表示拼图、小游戏、互动作品,但必须非常简洁,不能像 UI 图标堆叠。白底,扁平矢量,主标感强。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。', + }, +]; + +const paletteRefineConcepts = [ + { + id: 'taonier-ref04-palette-refine-butter', + title: '淡黄黄油', + referenceImages: [paletteRefineReferencePath, sparkleRefineReferencePath], + prompt: + '为“陶泥儿”继续调整 REF-04 配色迁移版。必须锁定参考图一的外轮廓和分区:主形、右上红块、左下青块和中间孔洞都保持不变;把中间主形改成温暖、低饱和、很淡的黄油黄或奶油黄,不要脏黄、土黄、芥末黄或偏橙黄。中间的星星必须保持参考图二的原样:四角闪光星,带短小光芒,不能拉伸成细长十字,不能变成五角星,不能加厚底托。整体要像成熟、干净、轻松的品牌 logo。无文字、无字母、无播放键、无聊天气泡、无手、无3D。', + }, + { + id: 'taonier-ref04-palette-refine-cream', + title: '奶油淡黄', + referenceImages: [paletteRefineReferencePath, sparkleRefineReferencePath], + prompt: + '基于 REF-04 造型锁定版和四角闪光星参考图,生成一版更高级的暖黄配色。保持图一的造型完全不变,只把中间主形改成低饱和奶油淡黄,颜色要轻、透、干净,避免脏、沉、厚。中心星星完全沿用参考图二的四角闪光样式和短光芒,不要拉伸,不要变形,不要变成五角星。红块和青块保持现有位置与比例。白底、扁平、品牌标志感。无文字、无字母、无手、无播放键、无3D。', + }, + { + id: 'taonier-ref04-palette-refine-biscuit', + title: '饼干淡黄', + referenceImages: [paletteRefineReferencePath, sparkleRefineReferencePath], + prompt: + '继续基于 REF-04 造型锁定版做色彩优化。外轮廓、红青辅形、中孔边界全部锁住不变;中间主形换成更淡的饼干黄、奶油黄或浅麦黄,必须低饱和、暖而不脏。中心填充严格使用参考图二的四角闪光星和短光芒,保持原样,不许被拉长,也不许改成几何五角星。整体要简洁、轻盈、专业。无文字、无字母、无聊天气泡、无3D、无复杂阴影。', + }, + { + id: 'taonier-ref04-palette-refine-milk', + title: '牛奶暖黄', + referenceImages: [paletteRefineReferencePath, sparkleRefineReferencePath], + prompt: + '在 REF-04 锁形轮廓上做最后一轮暖黄微调。只改中间主形的颜色,把它变成接近牛奶、黄油、奶霜的浅暖黄,低饱和、柔和、干净,不要土气,不要发灰。中间星星必须保持参考图二的四角闪光星原型和短光芒,不能被拉伸,不能变瘦,不能加底托。红青两块辅形位置不动。白底,极简 logo。无文字、无字母、无手、无播放键、无3D。', + }, +]; + +const paletteRefineV2Concepts = [ + { + id: 'taonier-ref04-palette-refine-v2-soft-butter', + title: '柔和奶黄', + referenceImages: [ + paletteShapeReferencePath, + paletteRefineReferencePath, + sparkleCropReferencePath, + ], + prompt: + '为“陶泥儿”修正 REF-04 配色迁移版。严格锁定参考图一的造型和分区:不改变外轮廓、不改变右上辅形、不改变左下辅形、不改变中央孔洞边界。只做两处调整:1)把中间主形改成温暖、低饱和、淡淡的奶油黄/黄油黄,颜色要高级、轻、干净,绝对不要土黄、脏黄、芥末黄、焦糖黄、偏橙黄;2)中心空洞里的星星必须使用参考图三的原始四角闪光星和短光芒,保持饱满菱形闪光,不要拉伸成十字,不要变成五角星,不要加底托。保持白底和扁平 logo。无文字、无字母、无手、无播放键、无聊天气泡、无3D。', + }, + { + id: 'taonier-ref04-palette-refine-v2-pale-cream', + title: '浅奶油黄', + referenceImages: [ + paletteShapeReferencePath, + paletteRefineReferencePath, + sparkleCropReferencePath, + ], + prompt: + '基于三张参考图生成一版修正版 logo:参考图一只用于锁定 REF-04 造型;参考图二只用于当前粉红与薄荷青位置;参考图三用于中心星星样式。中间主形颜色改为低饱和浅奶油黄,接近柔和奶霜,不要土气、不要脏、不要高饱和。中心星星必须照参考图三,四角闪光星带短光芒,比例自然饱满,不能被压扁或拉长。外轮廓和孔洞边界不变。白底、干净、成熟品牌 logo。无文字、无字母、无3D。', + }, + { + id: 'taonier-ref04-palette-refine-v2-light-vanilla', + title: '香草淡黄', + referenceImages: [ + paletteShapeReferencePath, + paletteRefineReferencePath, + sparkleCropReferencePath, + ], + prompt: + '继续优化 REF-04 造型锁定 logo。必须保持参考图一的所有轮廓位置,只把中间原黑色区域换成温暖低饱和的香草淡黄,颜色像轻柔黄油、奶油纸、浅米黄,不能像陶土、咖啡、焦糖或芥末。中心空洞填入参考图三的星星:圆润四角闪光、短小光芒、自然比例,不要变瘦,不要拉伸,不要五角星。粉红和薄荷青辅形沿用参考图二的气质。无文字、无字母、无播放键、无聊天气泡、无手、无3D。', + }, +]; + +const paletteRefineV3Concepts = [ + { + id: 'taonier-ref04-palette-refine-v3-butter-soft', + title: '淡奶油黄', + referenceImages: [ + paletteShapeReferencePath, + paletteRefineV2ReferencePath, + sparkleCropReferencePath, + ], + prompt: + '为“陶泥儿”继续修正 REF-04 的配色迁移版。锁定参考图一的外轮廓、红块、青块和孔洞边界不动;把中间主形调成更高级的淡奶油黄、黄油白黄或柔软黄米色,颜色要更淡一点、更轻一点、更透一点,不要土黄、脏黄、焦糖黄、芥末黄,也不要偏橙偏褐。中心空洞使用参考图三的星星:必须是饱满的四角闪光星,带短小光芒,不能被拉长成细十字,不能变成五角星,也不能出现厚底托。整体保持白底、扁平、品牌 logo 感。无文字、无字母、无3D、无聊天气泡。', + }, + { + id: 'taonier-ref04-palette-refine-v3-milk-cream', + title: '奶霜淡黄', + referenceImages: [ + paletteShapeReferencePath, + paletteRefineV2ReferencePath, + sparkleCropReferencePath, + ], + prompt: + '基于三张参考图输出一版更轻的 REF-04 logo。第一张参考只负责锁定原始造型;第二张参考只负责当前配色关系;第三张参考只负责中心闪光星的样子。中间主形改成低饱和的奶霜淡黄,颜色要轻柔、通透、像淡淡的黄油和牛奶混合,不要土、不要厚、不要脏。星星保持参考图三的四角闪光星和短光芒,不许拉伸,不许变形,不许五角星化。红块和青块位置固定。无文字、无字母、无手、无播放键、无3D。', + }, + { + id: 'taonier-ref04-palette-refine-v3-soft-vanilla', + title: '香草奶黄', + referenceImages: [ + paletteShapeReferencePath, + paletteRefineV2ReferencePath, + sparkleCropReferencePath, + ], + prompt: + '继续保持 REF-04 的造型锁定,做一次更安静的暖黄修正。中间主形变成香草奶黄或浅奶油黄,必须是低饱和、柔和、高级的淡黄,不要像土黄、咖喱黄、焦糖黄或偏橙黄。中心填充沿用参考图三的四角闪光星,星体要圆润饱满,旁边的短光芒保留,但不能夸张,不能拉长。外轮廓完全不动。白底、logo 感、扁平。无文字、无字母、无聊天气泡、无3D。', + }, +]; + +const paletteRefineV4Concepts = [ + { + id: 'taonier-ref04-palette-refine-v4-cream-paper', + title: '奶油纸淡黄', + referenceImages: [ + paletteShapeReferencePath, + paletteRefineReferencePath, + sparkleCropReferencePath, + ], + prompt: + '继续用 image-2 修正“陶泥儿” REF-04 logo。参考图一只用于锁定 REF-04 原型:外轮廓、右上粉红块、左下薄荷青块、中央孔洞边界都不要重新设计;参考图二只说明当前需要修正的版本;参考图三只用于中心星星。把中间原本土黄/脏黄的主形改成温暖、低饱和、淡淡的奶油纸黄色,接近 #F3E5B4 或 #F6E9C5,颜色要轻、干净、高级,不要陶土黄、芥末黄、咖喱黄、焦糖黄、橙黄、棕黄。中心孔洞里的星星必须保持参考图三原本的四角闪光星比例:上下左右四个圆润尖角,宽高自然,不能被横向或纵向拉伸,不能变成细十字,不能变成五角星,旁边短光芒也保持短小。白底、扁平品牌 logo。无文字、无字母、无播放键、无聊天气泡、无手、无3D。', + }, + { + id: 'taonier-ref04-palette-refine-v4-warm-ivory', + title: '暖象牙淡黄', + referenceImages: [ + paletteShapeReferencePath, + paletteRefineReferencePath, + sparkleCropReferencePath, + ], + prompt: + '基于三张参考图输出一版 REF-04 精修 logo。第一张参考图的形状和分区必须优先:主形轮廓不改、粉红块和薄荷青块位置不改、中间白色孔洞不改;只把中间主形从现在偏土的黄改成暖象牙淡黄,像很淡的黄油白、奶油米白、暖白纸,低饱和、柔和、通透,不要厚重和脏感。第三张参考图的四角闪光星需要原样放进中心:星体不能被压扁、不能拉长、不能瘦成十字,短光芒不要变多。整体保持成熟、干净、可做 App icon 的扁平 logo。无文字、无字母、无3D。', + }, + { + id: 'taonier-ref04-palette-refine-v4-soft-champagne', + title: '淡香槟暖黄', + referenceImages: [ + paletteShapeReferencePath, + paletteRefineV2ReferencePath, + sparkleCropReferencePath, + ], + prompt: + '为“陶泥儿”做一版更高级的 REF-04 暖黄精修。参考图一锁定基本造型,不允许改成播放按钮、三角形、气泡或新图标;参考图二只参考淡黄的轻盈程度;参考图三锁定星星。中间主形使用低饱和淡香槟黄/奶霜黄,颜色要非常淡、温暖、干净,不能像泥土、咖喱、焦糖、芥末或橙棕。中心星星必须是参考图三那种饱满四角闪光,保留短光芒,按原始宽高比例绘制,不能拉伸、不能变形、不能五角星化。粉红和薄荷青辅形保持克制。白底、扁平、品牌主标感。无文字、无字母、无3D、无复杂阴影。', + }, + { + id: 'taonier-ref04-palette-refine-v4-pale-butter', + title: '淡黄油暖白', + referenceImages: [ + paletteShapeReferencePath, + paletteRefineV2ReferencePath, + sparkleCropReferencePath, + ], + prompt: + '继续调整 REF-04 配色版本,只修正颜色和中心星星,不重画 logo。外轮廓、三块嵌合关系、中央孔洞边界以参考图一为准;中间主形换成淡黄油暖白,像轻薄奶油、温暖米白、浅黄纸,低饱和、不土、不脏、不橙、不褐。中心孔洞填入参考图三原样的四角闪光星:星星要圆润饱满,四个尖角长度均衡,短光芒短而自然,不能拉伸成细长十字。保留白底和扁平矢量 logo 气质。禁止文字、字母、五角星、播放键、聊天气泡、手、3D。', + }, +]; + +const paletteRefineV5Concepts = [ + { + id: 'taonier-ref04-palette-refine-v5-filled-centered-spark', + title: '填心居中亮星', + referenceImages: [ + paletteRefineV4PaleButterReferencePath, + paletteShapeReferencePath, + sparkleCropReferencePath, + ], + prompt: + '根据参考图修改“陶泥儿”04 图标,保留右上粉红块、左下薄荷青块和整体软泥圆润气质。重点做三处修改:1)补全左侧外轮廓曲线,让左侧从上到下形成连续、顺滑、饱满的弧线,不能有缺口、锯齿、截断或不自然凹陷;2)把中央白色空心孔洞完全用主体同色的温暖低饱和淡奶油黄填平,不能再出现白色中孔、白色环或内窗;3)把参考星星改成明亮的黄色四角闪光星,放在整个淡黄主体的视觉中央,星星清晰、圆润、比例自然,不要五角星,不要拉伸成十字。白底、扁平品牌 logo、干净高级。无文字、无字母、无播放键、无聊天气泡、无手、无3D。', + }, + { + id: 'taonier-ref04-palette-refine-v5-smooth-left-small-spark', + title: '顺滑左弧小亮星', + referenceImages: [ + paletteRefineV4PaleButterReferencePath, + paletteShapeReferencePath, + sparkleCropReferencePath, + ], + prompt: + '继续精修“陶泥儿”04 图标。以参考图一的 04 配色和比例为基础,但不要保留中央白洞。左侧外边缘需要补成更完整、更协调的连续曲线,像一整块柔软陶泥的自然外轮廓;中间原空心区域必须填成和主形一致的淡奶油黄色,与主体融为一体。中心放一枚明亮黄色四角闪光星,星星略小、居中、干净,不带复杂底托,不是五角星,不是细长十字。粉红块和薄荷青块仍然分离在右上和左下,白色间隔保持干净。无文字、无字母、无3D。', + }, + { + id: 'taonier-ref04-palette-refine-v5-balanced-bright-spark', + title: '平衡亮星', + referenceImages: [ + paletteRefineV4PaleButterReferencePath, + paletteShapeReferencePath, + sparkleCropReferencePath, + ], + prompt: + '为“陶泥儿”输出一版更协调的 04 图标修改稿。主形是温暖、低饱和、淡淡的奶油黄色;请补齐左侧曲线,让左边外轮廓更圆润完整,整体重心更稳。中央空心区域不再留白,必须填平为同样的淡黄色主形。把四角闪光星改成更明亮、更清楚的黄色,准确放在图标中央,星体饱满,四个尖角均衡,可以有很短的小光芒但不要抢主体。保持扁平 logo 感和白底。禁止文字、字母、五角星、播放键、聊天气泡、手、3D。', + }, + { + id: 'taonier-ref04-palette-refine-v5-solid-core-no-hole', + title: '实体主形亮星', + referenceImages: [ + paletteRefineV4PaleButterReferencePath, + paletteShapeReferencePath, + sparkleCropReferencePath, + ], + prompt: + '按用户参考图修改 04 logo:把淡黄主形做成一个更完整的实体软泥形。左侧曲线补全并顺滑化,外轮廓不要破碎;原中央白色孔洞完全消失,改成与主形同色的淡奶油黄实体面;在实体面的正中央放一枚明亮黄色四角闪光星,星星比主体颜色更亮,有明确识别但不幼稚。保持右上粉红块和左下薄荷青块的年轻配色,整体干净、轻盈、品牌主标感。无文字、无字母、无内孔、无白色中窗、无五角星、无播放键、无3D。', + }, +]; + const args = new Map(); for (let index = 2; index < process.argv.length; index += 1) { const raw = process.argv[index]; @@ -201,6 +667,24 @@ const concepts = ? magicDotConcepts : style === 'hands' ? handsConcepts + : style === 'broad' + ? broadConcepts + : style === 'fresh' + ? freshConcepts + : style === 'punch' + ? punchConcepts + : style === 'punch04' + ? punch04Concepts + : style === 'palette-refine' + ? paletteRefineConcepts + : style === 'palette-refine-v2' + ? paletteRefineV2Concepts + : style === 'palette-refine-v3' + ? paletteRefineV3Concepts + : style === 'palette-refine-v4' + ? paletteRefineV4Concepts + : style === 'palette-refine-v5' + ? paletteRefineV5Concepts : dimensionalConcepts; const selectedOutputDir = style === 'flat' @@ -221,6 +705,69 @@ const selectedOutputDir = 'branding', 'taonier-logo-hands-concepts', ) + : style === 'broad' + ? path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-broad-concepts', + ) + : style === 'fresh' + ? path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-fresh-concepts', + ) + : style === 'punch' + ? path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-punch-hole-concepts', + ) + : style === 'punch04' + ? path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-punch04-color-concepts', + ) + : style === 'palette-refine' + ? path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-ref04-palette-refine-concepts', + ) + : style === 'palette-refine-v2' + ? path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-ref04-palette-refine-v2-concepts', + ) + : style === 'palette-refine-v3' + ? path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-ref04-palette-refine-v3-concepts', + ) + : style === 'palette-refine-v4' + ? path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-ref04-palette-refine-v4-concepts', + ) + : style === 'palette-refine-v5' + ? path.join( + repoRoot, + 'public', + 'branding', + 'taonier-logo-ref04-palette-refine-v5-concepts', + ) : outputDir; function readDotenv(fileName) { @@ -276,6 +823,82 @@ function buildUrl(baseUrl) { : `${baseUrl}/v1/images/generations`; } +function hasHeader(headers, targetName) { + return Object.keys(headers).some( + (name) => name.toLowerCase() === targetName.toLowerCase(), + ); +} + +async function requestBuffer(url, options, timeoutMs, redirectCount = 0) { + const body = + typeof options.body === 'string' + ? Buffer.from(options.body) + : options.body || null; + const headers = { ...(options.headers || {}) }; + if (body && !hasHeader(headers, 'content-length')) { + headers['Content-Length'] = String(body.length); + } + + return new Promise((resolve, reject) => { + const parsedUrl = new URL(url); + const transport = parsedUrl.protocol === 'http:' ? http : https; + const request = transport.request( + parsedUrl, + { + method: options.method || 'GET', + headers, + }, + (response) => { + const statusCode = response.statusCode || 0; + const location = response.headers.location; + if ( + statusCode >= 300 && + statusCode < 400 && + location && + redirectCount < 5 + ) { + response.resume(); + const redirectedUrl = new URL(location, parsedUrl).toString(); + const preserveBody = statusCode === 307 || statusCode === 308; + requestBuffer( + redirectedUrl, + preserveBody + ? options + : { + method: 'GET', + headers: options.headers, + }, + timeoutMs, + redirectCount + 1, + ) + .then(resolve) + .catch(reject); + return; + } + + const chunks = []; + response.on('data', (chunk) => chunks.push(Buffer.from(chunk))); + response.on('end', () => + resolve({ + statusCode, + headers: response.headers, + bytes: Buffer.concat(chunks), + }), + ); + }, + ); + + request.setTimeout(timeoutMs, () => { + request.destroy(new Error(`request timed out after ${timeoutMs}ms`)); + }); + request.on('error', reject); + if (body) { + request.write(body); + } + request.end(); + }); +} + function collectStringsByKey(value, targetKey, output) { if (Array.isArray(value)) { value.forEach((entry) => collectStringsByKey(entry, targetKey, output)); @@ -331,45 +954,58 @@ function inferExtensionFromBytes(bytes) { return 'png'; } +function imagePathToDataUrl(imagePath) { + if (!existsSync(imagePath)) { + throw new Error(`Reference image not found: ${imagePath}`); + } + + const bytes = readFileSync(imagePath); + const extension = path.extname(imagePath).toLowerCase(); + const mimeType = + extension === '.jpg' || extension === '.jpeg' + ? 'image/jpeg' + : extension === '.webp' + ? 'image/webp' + : 'image/png'; + return `data:${mimeType};base64,${bytes.toString('base64')}`; +} + async function fetchJson(url, options, timeoutMs) { - const abortController = new AbortController(); - const timer = setTimeout(() => abortController.abort(), timeoutMs); try { - const response = await fetch(url, { - ...options, - signal: abortController.signal, - }); - const text = await response.text(); - if (!response.ok) { - throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`); + const response = await requestBuffer(url, options, timeoutMs); + const text = response.bytes.toString('utf8'); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw new Error( + `VectorEngine ${response.statusCode}: ${text.slice(0, 600)}`, + ); } return JSON.parse(text); } catch (error) { - if (error?.name === 'AbortError') { - throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`); + if (String(error?.message || '').includes('timed out')) { + throw new Error( + `VectorEngine request timed out after ${timeoutMs}ms`, + { cause: error }, + ); } throw error; - } finally { - clearTimeout(timer); } } async function downloadUrl(url, timeoutMs) { - const abortController = new AbortController(); - const timer = setTimeout(() => abortController.abort(), timeoutMs); try { - const response = await fetch(url, { signal: abortController.signal }); - if (!response.ok) { - throw new Error(`download ${response.status}`); + const response = await requestBuffer(url, { method: 'GET' }, timeoutMs); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw new Error(`download ${response.statusCode}`); } - return Buffer.from(await response.arrayBuffer()); + return response.bytes; } catch (error) { - if (error?.name === 'AbortError') { - throw new Error(`Generated image download timed out after ${timeoutMs}ms`); + if (String(error?.message || '').includes('timed out')) { + throw new Error( + `Generated image download timed out after ${timeoutMs}ms`, + { cause: error }, + ); } throw error; - } finally { - clearTimeout(timer); } } @@ -380,6 +1016,9 @@ async function generateConcept(env, concept) { n: 1, size: '1024x1024', }; + if (concept.referenceImages?.length) { + requestBody.image = concept.referenceImages.map(imagePathToDataUrl); + } const payload = await fetchJson( buildUrl(env.baseUrl), { @@ -438,6 +1077,13 @@ if (dryRun) { prompt: concept.prompt, n: 1, size: '1024x1024', + ...(concept.referenceImages?.length + ? { + image: concept.referenceImages.map((imagePath) => + path.relative(repoRoot, imagePath), + ), + } + : {}), }, })), }, diff --git a/scripts/loadtest/README.md b/scripts/loadtest/README.md index 0b406675..ef2e0307 100644 --- a/scripts/loadtest/README.md +++ b/scripts/loadtest/README.md @@ -113,6 +113,17 @@ $env:WORKS_DATA="data/works-list.local.json" npm run loadtest:k6:works -- --summary-trend-stats="avg,min,med,p(90),p(95),p(99),max" ``` +## 50 HTTP req/s 口径 + +`k6-works-list.js` 默认一次 iteration 会依次请求两个公开列表接口:`/api/runtime/puzzle/gallery` 和 `/api/runtime/custom-world-gallery`。因此目标约 50 HTTP req/s 时,`ramping-arrival-rate` 的 `PEAK_RPS` 应设置为 `25`。如果传入 `AUTH_TOKEN` 或把 `DETAIL_RATIO` 设为大于 0,每次 iteration 的请求数会增加,需要重新折算。 + +验收目标: + +- `http_req_failed < 1%` +- `http_req_duration p95 < 2000ms` +- `dropped_iterations = 0` +- 压测窗口内 Nginx 无新增 502 + ## Smoke ```bash @@ -151,17 +162,38 @@ BASE_URL=http://127.0.0.1:8787 \ WORKS_DATA=data/works-list.local.json \ SCENARIO=spike \ START_RPS=5 \ -PEAK_RPS=100 \ -HOLD=2m \ +PEAK_RPS=25 \ +HOLD=60s \ DETAIL_RATIO=0 \ npm run loadtest:k6:works ``` 默认阈值: -- `http_req_failed < 5%` +- `http_req_failed < 1%` - `http_req_duration p95 < 2000ms` -- `works_list_shape_error_rate < 5%` +- `dropped_iterations = 0` +- `works_list_shape_error_rate < 1%` + +PowerShell: + +```powershell +$env:BASE_URL="https://genarrative.world" +$env:WORKS_DATA="data/works-list.local.json" +$env:SCENARIO="spike" +$env:START_RPS="5" +$env:PEAK_RPS="25" +$env:HOLD="60s" +$env:END_RPS="5" +$env:DETAIL_RATIO="0" +npm run loadtest:k6:works -- --summary-trend-stats="avg,min,med,p(90),p(95),p(99),max" +``` + +线上 release 回归可使用同一组环境变量: + +```bash +SCENARIO=spike START_RPS=5 PEAK_RPS=25 HOLD=60s END_RPS=5 DETAIL_RATIO=0 npm run loadtest:k6:works +``` ## 带登录态压测个人作品列表 @@ -194,9 +226,121 @@ npm run loadtest:k6:works ## 排障 - 如果公开 gallery 返回 `creation_entry_disabled` 或 503,检查本地 creation entry 配置是否禁用了对应入口。 +- 如果高压下返回 429,优先确认目标环境是否设置了 `GENARRATIVE_API_MAX_CONCURRENT_REQUESTS`。429 表示 api-server 应用层背压已生效,不等同于业务错误;继续看内存、p95、`http_req_failed` 和 OTLP / Nginx timing 判断阈值是否偏低。 +- 如果直连 `api-server` 压测出现 `connection refused` 或 status 0,说明压力已经打到 TCP 监听 / accept 层;此时同时检查 `GENARRATIVE_API_LISTEN_BACKLOG`、Nginx upstream keepalive 和是否需要在 Nginx 前置限流,不能只靠应用层背压解释。 - 如果个人作品列表返回 401,确认 `AUTH_TOKEN` 是当前 api-server 可识别的 access token。 - 如果详情全部 404,确认是否已向目标环境导入与 `WORKS_DATA` 一致的数据。 +## 压测窗口采集 + +Nginx upstream timing: + +```bash +sudo tail -f /var/log/nginx/genarrative.access.log +sudo tail -f /var/log/nginx/genarrative.error.log +``` + +api-server 与 SpacetimeDB 日志: + +```bash +sudo journalctl -u genarrative-api.service -f +sudo journalctl -u spacetimedb.service -f +``` + +api-server 的 OpenTelemetry 默认关闭。需要验证 OTLP traces / metrics / logs 时,先在服务器本机启动只监听 `127.0.0.1` 的 `otelcol-contrib` debug exporter: + +```bash +npm run otel:debug +``` + +如果要把本机数据转发给 Rider OpenTelemetry 面板,先在 Rider 的 OpenTelemetry 设置中启用固定 OTLP server port,例如 `17011`,再运行: + +```bash +RIDER_OTLP_GRPC_ENDPOINT=127.0.0.1:17011 npm run otel:rider +``` + +脚本会在 `.codex-temp/otelcol/` 生成临时 collector 配置,默认接收 api-server 发到 `http://127.0.0.1:4318` 的 OTLP HTTP 数据。需要改端口时可设置: + +- `OTELCOL_OTLP_HTTP_ENDPOINT`,默认 `127.0.0.1:4318` +- `OTELCOL_OTLP_GRPC_ENDPOINT`,默认 `127.0.0.1:4317` +- `RIDER_OTLP_GRPC_ENDPOINT`,默认 `127.0.0.1:17011` +- `OTELCOL_BIN`,默认 `otelcol-contrib` + +等价的 debug collector 配置如下: + +```yaml +receivers: + otlp: + protocols: + grpc: + endpoint: 127.0.0.1:4317 + http: + endpoint: 127.0.0.1:4318 + +exporters: + debug: + verbosity: detailed + +service: + pipelines: + traces: + receivers: [otlp] + exporters: [debug] + metrics: + receivers: [otlp] + exporters: [debug] + logs: + receivers: [otlp] + exporters: [debug] +``` + +```bash +otelcol-contrib --config /etc/otelcol-contrib/genarrative-debug.yaml +``` + +然后在 `/etc/genarrative/api-server.env` 中打开: + +```env +GENARRATIVE_OTEL_ENABLED=true +OTEL_SERVICE_NAME=genarrative-api +OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 +``` + +注意 `api-server` 当前使用 OTLP HTTP exporter,`OTEL_EXPORTER_OTLP_ENDPOINT` 必须指向 Collector 的 HTTP base endpoint `http://127.0.0.1:4318`。不要把它改成 Collector gRPC 端口 `4317`,也不要直接指向 Rider 的 gRPC 端口;Rider 只由 `npm run otel:rider` 启动的 Collector 通过 `RIDER_OTLP_GRPC_ENDPOINT` 转发。 + +OTLP logs 是远端观测增量,不替代本地日志;api-server 日志仍看 `journalctl` / `logs/api-server/`,Nginx 日志仍看文件。日志等级继续用 `GENARRATIVE_API_LOG` / `RUST_LOG` 控制,例如 `info,tower_http=info,spacetime_client=info`。 + +Rider 的 Logs 面板展示的是 OTLP log event 自身字段,不会自动把父 span 的全部 attributes 摊平到每一条日志。请求完成日志会直接携带 `request_id`、`http.request.method`、`http.route`、`url.scheme`、`url.path`、`http.response.status_code`、`status_class`、`latency_ms` 和 `slow_request`;更完整的请求链路仍在 Traces 面板中按同一个 trace/span 关联查看。 + +压测期间可在 Metrics 面板或 debug exporter 中观察进程内存指标: + +- `process.memory.usage`:进程常驻内存 / RSS。 +- `process.memory.virtual`:进程虚拟内存;Windows 当前按 `PrivateUsage` 上报,Linux 取 `VmSize`。 +- `genarrative.process.memory.private`:进程私有内存,Windows 来自 `PrivateUsage`,Linux 近似取 `/proc/self/status` 的 `VmData`。 +- `process.thread.count`:线程数。 +- `process.windows.handle.count`:Windows 句柄数。 +- `process.unix.file_descriptor.count`:Linux 文件描述符数。 +- `genarrative.http.server.response_bodies.in_flight`:Axum / Hyper 仍持有的响应 body 数;如果内存高但该值很低,说明热点不在业务 handler 生命周期内。 +- `genarrative.http.server.request_permits.available`:应用层 HTTP 背压剩余 permit 数;如果该值未接近 0,说明没有打满 `GENARRATIVE_API_MAX_CONCURRENT_REQUESTS`。 +- `genarrative.puzzle_gallery.cache.hits` / `genarrative.puzzle_gallery.cache.misses` / `genarrative.puzzle_gallery.cache.rebuilds`:拼图广场响应缓存命中、未命中和重建次数。 +- `genarrative.puzzle_gallery.cache.rebuild.duration`:拼图广场缓存重建耗时。 +- `genarrative.puzzle_gallery.cache.data_json_bytes`:拼图广场缓存内预序列化 data JSON 大小。 +- `genarrative.spacetime.read.calls` / `genarrative.spacetime.read.duration_ms`:SpacetimeDB 订阅本地 cache 读次数和耗时;`read=list_puzzle_gallery` 表示当前路径走 view / local cache,不是 procedure。 + +若 `/api/runtime/puzzle/gallery` 单接口压测出现 GB 级瞬时内存峰值,先区分“持续泄漏”和“请求期分配峰值”:关闭 OTEL 后若峰值仍复现且压测结束后回落,主因通常不是 Collector / exporter。当前拼图广场列表命中缓存时应复用 `PuzzleGalleryCache` 中的预序列化 data JSON,只按请求拼接 envelope meta,不应每个请求重新深拷贝 `PuzzleGalleryResponse` 或构造完整 `serde_json::Value`。 + +本地 Windows 直连 `api-server` 压测还要单独看 K6 的 VU / 连接模型。已验证在 250 RPS、`PREALLOCATED_VUS=300` 时,哪怕打 `/healthz` 这种小响应,也可能因为本地 300 个 Established 连接触发 `api-server` private memory 瞬时升到约 7GB,压测结束后回落到 100MB 级;同样 250 RPS 改成 `PREALLOCATED_VUS=20 MAX_VUS=40` 后,拼图广场 p95 约 9ms,峰值降到约 600MB。这个现象说明高水位主要来自本机直连连接 / 发送链路,不等价于 SpacetimeDB 或拼图 JSON 缓存泄漏。做本地容量判断时优先让 VU 接近真实并发,避免用过高预分配 VU 把测试变成 Windows 本机连接缓冲压力测试;生产仍以 Nginx upstream keepalive、系统内存和 OTLP 指标一起判断。 + +线上回归辅助命令: + +```bash +systemctl show genarrative-api.service -p LimitNOFILE -p TasksMax +cat /proc/$(pidof api-server)/limits +tr '\0' '\n' < /proc/$(pidof api-server)/environ | grep GENARRATIVE_API_MAX_CONCURRENT_REQUESTS +ss -ltnp | grep 8082 +curl -sS http://127.0.0.1:8082/healthz +``` + ## 验证命令 ```bash diff --git a/scripts/loadtest/data/works-list.sample.from-migration-1.json b/scripts/loadtest/data/works-list.sample.from-migration-1.json new file mode 100644 index 00000000..0a8b9def --- /dev/null +++ b/scripts/loadtest/data/works-list.sample.from-migration-1.json @@ -0,0 +1,218 @@ +{ + "source": "spacetime-migration-1.json", + "generatedAt": "2026-05-16T13:35:40.282Z", + "counts": { + "puzzle_work_profile": 3, + "custom_world_profile": 1, + "match3d_work_profile": 0, + "square_hole_work_profile": 0, + "visual_novel_work_profile": 0 + }, + "tables": { + "puzzle_work_profile": [ + { + "profile_id": "profile-001", + "work_id": "work-001", + "owner_user_id": "user-001", + "author_display_name": "author-001", + "cover_asset_id": "asset-001", + "cover_image_src": "/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png", + "work_title": "化学家", + "level_name": "文学家", + "summary": "几个文学家正站在山上面对着瀑布侃侃而谈", + "work_description": "一个穿着白大褂的化学家正在做酷炫的化学实验,背景是化学实验室", + "levels_json": "[{\"level_id\":\"puzzle-level-1777649242577-7\",\"level_name\":\"文学家\",\"picture_description\":\"几个文学家正站在山上面对着瀑布侃侃而谈\",\"candidates\":[{\"candidate_id\":\"puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2\",\"image_src\":\"/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png\",\"asset_id\":\"asset-1777649330373133\",\"prompt\":\"几个文学家正站在山上面对着瀑布侃侃而谈\",\"actual_prompt\":\"请生成一张高清插画。画面主体:几个文学家正站在山上面对着瀑布侃侃而谈。画面…", + "anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"化学家\",\"status\":\"Locked\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"一个穿着白大褂的化学家正在做酷炫的化学实验,背景是化学实验室\",\"status\":\"Locked\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"清晰、适合拼图切块\",\"status\":\"Inferred\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"主体轮廓、色块分区、局部细节\",\"status\":\"Inferred\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"化学家、拼图、插画;禁止标题字\",\"status\":\"I…", + "theme_tags_json": "[\"化学家\",\"拼图\",\"插画\",\"禁止标题字\"]", + "publication_status": { + "Published": [] + }, + "play_count": 1, + "like_count": 0, + "remix_count": 1, + "updated_at": { + "__timestamp_micros_since_unix_epoch__": 1777703338322544 + }, + "created_at": { + "__timestamp_micros_since_unix_epoch__": 1777648804043558 + }, + "published_at": { + "__timestamp_micros_since_unix_epoch__": 1777649364112270 + } + }, + { + "profile_id": "profile-002", + "work_id": "work-002", + "owner_user_id": "user-002", + "author_display_name": "author-002", + "work_title": "我不知道", + "level_name": "", + "summary": "你猜我是谁", + "work_description": "你猜我是谁", + "levels_json": "[{\"level_id\":\"puzzle-level-1\",\"level_name\":\"\",\"picture_description\":\"真不知道\",\"candidates\":[],\"selected_candidate_id\":null,\"cover_image_src\":null,\"cover_asset_id\":null,\"generation_status\":\"idle\"}]", + "anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"我不知道\",\"status\":\"Locked\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"真不知道\",\"status\":\"Locked\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"清晰、适合拼图切块\",\"status\":\"Inferred\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"主体轮廓、色块分区、局部细节\",\"status\":\"Inferred\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"我不知道、拼图、插画;禁止标题字\",\"status\":\"Inferred\"}}", + "theme_tags_json": "[\"我不知道\"]", + "publication_status": { + "Draft": [] + }, + "play_count": 0, + "like_count": 0, + "remix_count": 0, + "updated_at": { + "__timestamp_micros_since_unix_epoch__": 1777619351714201 + }, + "created_at": { + "__timestamp_micros_since_unix_epoch__": 1777619336673245 + } + }, + { + "profile_id": "profile-003", + "work_id": "work-003", + "owner_user_id": "user-003", + "author_display_name": "author-002", + "work_title": "", + "level_name": "", + "summary": "", + "work_description": "", + "levels_json": "[{\"level_id\":\"puzzle-level-1\",\"level_name\":\"\",\"picture_description\":\"\",\"candidates\":[],\"selected_candidate_id\":null,\"cover_image_src\":null,\"cover_asset_id\":null,\"generation_status\":\"idle\"}]", + "anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"\",\"status\":\"Missing\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"\",\"status\":\"Missing\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"\",\"status\":\"Missing\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"\",\"status\":\"Missing\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"\",\"status\":\"Missing\"}}", + "theme_tags_json": "[\"拼图\",\"插画\",\"清晰构图\"]", + "publication_status": { + "Draft": [] + }, + "play_count": 0, + "like_count": 0, + "remix_count": 0, + "updated_at": { + "__timestamp_micros_since_unix_epoch__": 1777622285252380 + }, + "created_at": { + "__timestamp_micros_since_unix_epoch__": 1777622285252380 + } + } + ], + "custom_world_profile": [ + { + "profile_id": "profile-081", + "owner_user_id": "user-002", + "author_display_name": "author-012", + "author_public_user_code": "author-code-001", + "world_name": "青春飞扬校园", + "summary_text": "在现代校园中,玩家摆脱内卷,追求真实成长", + "subtitle": "反内卷的自由学习之旅", + "profile_payload_json": "{\"anchorContent\":null,\"anchorPack\":null,\"attributeSchema\":{\"generatedFrom\":{\"conflictCore\":\"与传统教育模式的冲突\",\"settingSummary\":\"在现代校园中,玩家摆脱内卷,追求真实成长\",\"tone\":\"积极向上,充满活力与创新\",\"worldName\":\"青春飞扬校园\",\"worldType\":\"CUSTOM\"},\"id\":\"schema:rpg-agent:1e15b44d:v1\",\"schemaVersion\":1,\"slots\":[{\"name\":\"知识储备\",\"slotId\":\"axis_a\"},{\"name\":\"创新思维\",\"slotId\":\"axis_b\"},{\"name\":\"社交能力\",\"slotId\":\"axis_c\"},{\"name\":\"抗压能力\",\"slotId\":\"axis_d\"},{\"name\":\"自我认知\",\"slotId\":\"axis_e\"},{\"name\":\"团队协作\",\"slotId\":\"axis_f\"}],\"worldId\":\"custom:青春飞扬校…", + "publication_status": { + "Draft": [] + }, + "play_count": 0, + "like_count": 0, + "remix_count": 0, + "updated_at": { + "__timestamp_micros_since_unix_epoch__": 1777532006629209 + }, + "created_at": { + "__timestamp_micros_since_unix_epoch__": 1777531745887256 + } + } + ], + "match3d_work_profile": [], + "square_hole_work_profile": [], + "visual_novel_work_profile": [] + }, + "profileIds": { + "puzzle": [ + "profile-001", + "profile-002", + "profile-003" + ], + "customWorld": [ + "profile-081" + ], + "match3d": [], + "squareHole": [], + "bigFish": [], + "visualNovel": [] + }, + "workIds": { + "puzzle": [ + "work-001", + "work-002", + "work-003" + ], + "customWorld": [], + "match3d": [], + "squareHole": [], + "bigFish": [], + "visualNovel": [] + }, + "normalizedWorks": [ + { + "type": "puzzle", + "workId": "work-001", + "profileId": "profile-001", + "ownerUserId": "user-001", + "title": "化学家", + "subtitle": "几个文学家正站在山上面对着瀑布侃侃而谈", + "publicationStatus": { + "Published": [] + }, + "playCount": 1, + "likeCount": 0, + "remixCount": 1, + "coverImageSrc": "/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png", + "updatedAt": { + "__timestamp_micros_since_unix_epoch__": 1777703338322544 + } + }, + { + "type": "puzzle", + "workId": "work-002", + "profileId": "profile-002", + "ownerUserId": "user-002", + "title": "我不知道", + "subtitle": "你猜我是谁", + "publicationStatus": { + "Draft": [] + }, + "playCount": 0, + "likeCount": 0, + "remixCount": 0, + "updatedAt": { + "__timestamp_micros_since_unix_epoch__": 1777619351714201 + } + }, + { + "type": "puzzle", + "workId": "work-003", + "profileId": "profile-003", + "ownerUserId": "user-003", + "title": "", + "subtitle": "", + "publicationStatus": { + "Draft": [] + }, + "playCount": 0, + "likeCount": 0, + "remixCount": 0, + "updatedAt": { + "__timestamp_micros_since_unix_epoch__": 1777622285252380 + } + }, + { + "type": "customWorld", + "profileId": "profile-081", + "ownerUserId": "user-002", + "title": "青春飞扬校园", + "subtitle": "反内卷的自由学习之旅", + "publicationStatus": { + "Draft": [] + }, + "playCount": 0, + "likeCount": 0, + "remixCount": 0, + "updatedAt": { + "__timestamp_micros_since_unix_epoch__": 1777532006629209 + } + } + ] +} diff --git a/scripts/loadtest/k6-works-list.js b/scripts/loadtest/k6-works-list.js index 45e51a82..67d6abd0 100644 --- a/scripts/loadtest/k6-works-list.js +++ b/scripts/loadtest/k6-works-list.js @@ -56,20 +56,22 @@ const scenarioOptions = { scenarios: { spike: { executor: 'ramping-arrival-rate', + startRate: Number(__ENV.START_RPS || 5), preAllocatedVUs: Number(__ENV.PREALLOCATED_VUS || 50), maxVUs: Number(__ENV.MAX_VUS || 200), timeUnit: '1s', stages: [ - { target: Number(__ENV.START_RPS || 5), duration: __ENV.RAMP_UP || '30s' }, - { target: Number(__ENV.PEAK_RPS || 100), duration: __ENV.HOLD || '2m' }, + { target: Number(__ENV.PEAK_RPS || 25), duration: __ENV.RAMP_UP || '30s' }, + { target: Number(__ENV.PEAK_RPS || 25), duration: __ENV.HOLD || '2m' }, { target: Number(__ENV.END_RPS || 5), duration: __ENV.RAMP_DOWN || '30s' }, ], }, }, thresholds: { - http_req_failed: ['rate<0.05'], + http_req_failed: ['rate<0.01'], http_req_duration: ['p(95)<2000'], - works_list_shape_error_rate: ['rate<0.05'], + dropped_iterations: ['count==0'], + works_list_shape_error_rate: ['rate<0.01'], }, }, }; diff --git a/scripts/run-otelcol.mjs b/scripts/run-otelcol.mjs new file mode 100644 index 00000000..d070bfdc --- /dev/null +++ b/scripts/run-otelcol.mjs @@ -0,0 +1,119 @@ +import {spawn} from 'node:child_process'; +import {mkdirSync, writeFileSync} from 'node:fs'; +import path from 'node:path'; + +const [, , rawMode = 'debug', ...args] = process.argv; +const mode = rawMode.trim(); +const printConfigOnly = args.includes('--print-config'); + +const supportedModes = new Set(['debug', 'rider']); +if (!supportedModes.has(mode)) { + console.error('[otelcol] mode must be one of: debug, rider'); + process.exit(1); +} + +const otlpHttpEndpoint = readEnv('OTELCOL_OTLP_HTTP_ENDPOINT', '127.0.0.1:4318'); +const otlpGrpcEndpoint = readEnv('OTELCOL_OTLP_GRPC_ENDPOINT', '127.0.0.1:4317'); +const riderEndpoint = readEnv('RIDER_OTLP_GRPC_ENDPOINT', '127.0.0.1:17011'); +const debugVerbosity = readEnv('OTELCOL_DEBUG_VERBOSITY', 'detailed'); +const otelcolBin = readEnv('OTELCOL_BIN', 'otelcol-contrib'); + +const configText = buildConfig(mode); +const configDir = path.resolve('.codex-temp', 'otelcol'); +const configPath = path.join(configDir, `genarrative-${mode}.yaml`); +mkdirSync(configDir, {recursive: true}); +writeFileSync(configPath, configText, 'utf8'); + +console.log(`[otelcol] wrote ${configPath}`); +console.log(`[otelcol] receiving OTLP HTTP at http://${otlpHttpEndpoint}`); +console.log(`[otelcol] receiving OTLP gRPC at ${otlpGrpcEndpoint}`); +if (mode === 'rider') { + console.log(`[otelcol] forwarding traces/metrics/logs to Rider OTLP gRPC at ${riderEndpoint}`); +} +console.log( + '[otelcol] api-server env: GENARRATIVE_OTEL_ENABLED=true OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318' +); + +if (printConfigOnly) { + console.log(configText); + process.exit(0); +} + +const child = spawn(otelcolBin, ['--config', configPath], { + cwd: process.cwd(), + env: process.env, + stdio: 'inherit', +}); + +const stopChild = () => { + if (!child.killed) { + child.kill(); + } +}; + +for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) { + process.on(signal, () => { + stopChild(); + process.exit(130); + }); +} + +process.on('exit', stopChild); + +child.on('error', (error) => { + console.error(`[otelcol] failed to start ${otelcolBin}: ${error.message}`); + console.error('[otelcol] install otelcol-contrib and make sure it is on PATH, or set OTELCOL_BIN.'); + process.exit(1); +}); + +child.on('exit', (code, signal) => { + if (signal) { + console.error(`[otelcol] exited by signal: ${signal}`); + process.exit(1); + } + process.exit(code ?? 0); +}); + +function readEnv(key, fallback) { + const value = process.env[key]?.trim(); + return value ? value : fallback; +} + +function buildConfig(selectedMode) { + const exporters = + selectedMode === 'rider' + ? ` otlp/rider: + endpoint: ${riderEndpoint} + tls: + insecure: true + debug: + verbosity: ${debugVerbosity}` + : ` debug: + verbosity: ${debugVerbosity}`; + + const pipelineExporters = selectedMode === 'rider' ? '[otlp/rider, debug]' : '[debug]'; + + return `receivers: + otlp: + protocols: + grpc: + endpoint: ${otlpGrpcEndpoint} + http: + endpoint: ${otlpHttpEndpoint} + +exporters: +${exporters} + +service: + pipelines: + traces: + receivers: [otlp] + exporters: ${pipelineExporters} + metrics: + receivers: [otlp] + exporters: ${pipelineExporters} + logs: + receivers: [otlp] + exporters: ${pipelineExporters} +`; +} diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 74415c0e..a74d29db 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -105,6 +105,7 @@ dependencies = [ "module-square-hole", "module-story", "module-visual-novel", + "opentelemetry", "platform-agent", "platform-auth", "platform-llm", @@ -118,6 +119,7 @@ dependencies = [ "shared-contracts", "shared-kernel", "shared-logging", + "socket2 0.6.3", "spacetime-client", "time", "tokio", @@ -129,6 +131,7 @@ dependencies = [ "urlencoding", "uuid", "webp", + "windows-sys 0.61.2", "zip", ] @@ -1761,6 +1764,7 @@ dependencies = [ "platform-auth", "serde", "serde_json", + "sha2", "shared-kernel", "time", "tokio", @@ -2070,6 +2074,90 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "opentelemetry" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "opentelemetry-appender-tracing" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef6a1ac5ca3accf562b8c306fa8483c85f4390f768185ab775f242f7fe8fdcc2" +dependencies = [ + "opentelemetry", + "tracing", + "tracing-core", + "tracing-opentelemetry", + "tracing-subscriber", +] + +[[package]] +name = "opentelemetry-http" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" +dependencies = [ + "async-trait", + "bytes", + "http 1.4.0", + "opentelemetry", + "reqwest 0.12.28", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f69cd6acbb9af919df949cd1ec9e5e7fdc2ef15d234b6b795aaa525cc02f71f" +dependencies = [ + "http 1.4.0", + "opentelemetry", + "opentelemetry-http", + "opentelemetry-proto", + "opentelemetry_sdk", + "prost", + "reqwest 0.12.28", + "thiserror 2.0.18", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f" +dependencies = [ + "opentelemetry", + "opentelemetry_sdk", + "prost", + "tonic", + "tonic-prost", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "rand 0.9.4", + "thiserror 2.0.18", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -2151,6 +2239,26 @@ dependencies = [ "indexmap 2.14.0", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2320,6 +2428,29 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "protobuf" version = "3.7.2" @@ -2622,6 +2753,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "futures-channel", "futures-core", "futures-util", "http 1.4.0", @@ -3036,6 +3168,12 @@ dependencies = [ name = "shared-logging" version = "0.1.0" dependencies = [ + "opentelemetry", + "opentelemetry-appender-tracing", + "opentelemetry-otlp", + "opentelemetry_sdk", + "tracing", + "tracing-opentelemetry", "tracing-subscriber", ] @@ -3130,6 +3268,7 @@ dependencies = [ "module-square-hole", "module-story", "module-visual-novel", + "opentelemetry", "serde", "serde_json", "shared-contracts", @@ -3137,6 +3276,7 @@ dependencies = [ "spacetimedb-sdk", "time", "tokio", + "tracing", ] [[package]] @@ -3807,6 +3947,38 @@ version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bytes", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project", + "sync_wrapper 1.0.2", + "tokio-stream", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + [[package]] name = "tower" version = "0.5.3" @@ -3898,6 +4070,22 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-opentelemetry" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac28f2d093c6c477eaa76b23525478f38de514fa9aeb1285738d4b97a9552fc" +dependencies = [ + "js-sys", + "opentelemetry", + "smallvec", + "tracing", + "tracing-core", + "tracing-log", + "tracing-subscriber", + "web-time", +] + [[package]] name = "tracing-subscriber" version = "0.3.23" diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 3a6ea980..bddf6c17 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -100,6 +100,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" serde_urlencoded = "0.7" sha2 = "0.10" +socket2 = "0.6" spacetimedb = "2.2.0" spacetimedb-sdk = "2.2.0" spacetimedb-lib = { version = "2.2.0", default-features = false } @@ -110,7 +111,13 @@ tokio-tungstenite = "0.27" tower = "0.5" tower-http = "0.6" tracing = "0.1" +opentelemetry = "0.31" +opentelemetry-appender-tracing = { version = "0.31", default-features = false, features = ["experimental_use_tracing_span_context"] } +opentelemetry-otlp = { version = "0.31", default-features = false, features = ["http-proto", "reqwest-blocking-client", "trace", "metrics", "logs"] } +opentelemetry_sdk = { version = "0.31", default-features = false, features = ["trace", "metrics", "logs"] } +tracing-opentelemetry = { version = "0.32", default-features = false } tracing-subscriber = "0.3" +windows-sys = "0.61" url = "2" urlencoding = "2" uuid = "1" diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index 90ab2c7b..ce4ef1e6 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -11,6 +11,7 @@ base64 = { workspace = true } bytes = { workspace = true } dotenvy = { workspace = true } image = { workspace = true, features = ["jpeg", "png", "webp"] } +http-body-util = { workspace = true } reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] } webp = { workspace = true } module-ai = { workspace = true } @@ -43,18 +44,23 @@ sha2 = { workspace = true } shared-contracts = { workspace = true, features = ["oss-contracts"] } shared-kernel = { workspace = true } shared-logging = { workspace = true } +socket2 = { workspace = true } spacetime-client = { workspace = true } -tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time", "sync"] } tokio-stream = { workspace = true } futures-util = { workspace = true } time = { workspace = true, features = ["formatting"] } tower-http = { workspace = true, features = ["trace"] } tracing = { workspace = true } +opentelemetry = { workspace = true } url = { workspace = true } urlencoding = { workspace = true } uuid = { workspace = true, features = ["v4"] } zip = { workspace = true, features = ["deflate"] } +[target.'cfg(windows)'.dependencies] +windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_System_ProcessStatus", "Win32_System_Threading"] } + [dev-dependencies] base64 = { workspace = true } hmac = { workspace = true } diff --git a/server-rs/crates/api-server/src/api_response.rs b/server-rs/crates/api-server/src/api_response.rs index 35a8bc64..c9e7ffee 100644 --- a/server-rs/crates/api-server/src/api_response.rs +++ b/server-rs/crates/api-server/src/api_response.rs @@ -1,4 +1,13 @@ -use axum::Json; +use std::convert::Infallible; + +use axum::{ + Json, + body::Body, + http::{HeaderValue, header}, + response::{IntoResponse, Response}, +}; +use bytes::Bytes; +use futures_util::stream; use serde::Serialize; use serde_json::Value; #[cfg(test)] @@ -32,6 +41,30 @@ where Json(serde_json::to_value(data).unwrap_or(Value::Null)) } +pub fn json_success_data_bytes_response( + request_context: Option<&RequestContext>, + data_json: Bytes, +) -> Response { + if let Some(context) = request_context + && context.wants_envelope() + { + let meta = serde_json::to_vec(&build_api_response_meta(Some(context))) + .map(Bytes::from) + .unwrap_or_else(|_| Bytes::from_static(b"null")); + let chunks = [ + Bytes::from_static(b"{\"ok\":true,\"data\":"), + data_json, + Bytes::from_static(b",\"error\":null,\"meta\":"), + meta, + Bytes::from_static(b"}"), + ]; + let stream = stream::iter(chunks.into_iter().map(Ok::)); + return json_body_response(Body::from_stream(stream)); + } + + json_bytes_response(data_json) +} + pub fn json_error_body( request_context: Option<&RequestContext>, error: &ApiErrorPayload, @@ -65,6 +98,19 @@ fn build_api_response_meta(request_context: Option<&RequestContext>) -> ApiRespo ) } +fn json_bytes_response(bytes: Bytes) -> Response { + json_body_response(Body::from(bytes)) +} + +fn json_body_response(body: Body) -> Response { + let mut response = body.into_response(); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json; charset=utf-8"), + ); + response +} + #[cfg(test)] mod tests { use super::*; @@ -106,6 +152,31 @@ mod tests { assert!(body.get("meta").is_none()); } + #[tokio::test] + async fn success_response_streams_cached_data_inside_standard_envelope() { + use http_body_util::BodyExt; + + let request_context = build_request_context(true); + let response = json_success_data_bytes_response( + Some(&request_context), + Bytes::from_static(br#"{"items":[]}"#), + ); + let body = response + .into_body() + .collect() + .await + .expect("response body should collect") + .to_bytes(); + let payload: Value = serde_json::from_slice(&body).expect("body should be json"); + + assert_eq!(payload["ok"], Value::Bool(true)); + assert_eq!(payload["data"]["items"], Value::Array(Vec::new())); + assert_eq!( + payload["meta"]["requestId"], + Value::String("req-test".to_string()) + ); + } + #[test] fn error_body_returns_legacy_shape_without_envelope_header() { let request_context = build_request_context(false); diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 17956263..ec886eb2 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -11,10 +11,11 @@ use tower_http::{ classify::ServerErrorsFailureClass, trace::{DefaultOnRequest, TraceLayer}, }; -use tracing::{Level, Span, error, info, info_span, warn}; +use tracing::{Level, Span, error, info_span}; use crate::{ auth::{AuthenticatedAccessToken, require_bearer_auth}, + backpressure::limit_concurrent_requests, creation_entry_config::require_creation_entry_route_enabled, error_middleware::normalize_error_response, modules, @@ -22,6 +23,7 @@ use crate::{ response_headers::propagate_request_id_header, runtime_inventory::get_runtime_inventory_state, state::AppState, + telemetry::record_http_observability, tracking::record_route_tracking_event_after_success, vector_engine_audio_generation::{ create_background_music_task, create_sound_effect_task, @@ -42,8 +44,6 @@ use crate::{ // 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。 pub fn build_router(state: AppState) -> Router { - let slow_request_threshold_ms = state.config.slow_request_threshold_ms; - Router::new() .merge(modules::admin::router(state.clone())) .merge(modules::health::router(state.clone())) @@ -77,6 +77,11 @@ pub fn build_router(state: AppState) -> Router { state.clone(), require_creation_entry_route_enabled, )) + // HTTP 背压在业务路由外侧快拒绝,避免过载请求继续占用 SpacetimeDB facade 与业务执行资源。 + .layer(middleware::from_fn_with_state( + state.clone(), + limit_concurrent_requests, + )) // 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。 .layer(middleware::from_fn(normalize_error_response)) // 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。 @@ -86,47 +91,55 @@ pub fn build_router(state: AppState) -> Router { state.clone(), record_api_tracking_after_success, )) + // HTTP 指标与请求完成日志放在 tracing span 内侧,日志事件可以继承当前 trace/span context。 + .layer(middleware::from_fn_with_state( + state.clone(), + record_http_observability, + )) // 当前阶段先统一挂接 HTTP tracing,后续 request_id、响应头与错误中间件继续在这里扩展。 .layer( TraceLayer::new_for_http() .make_span_with(|request: &Request| { let request_id = resolve_request_id(request).unwrap_or_else(|| "unknown".to_string()); + let route = crate::telemetry::observability_route(request.uri().path()); + let scheme = crate::telemetry::resolve_request_scheme(request.headers()); + let span_name = format!("{} {}", request.method(), route); info_span!( "http.request", + otel.kind = "server", + otel.name = %span_name, + otel.status_code = tracing::field::Empty, + http.response.status_code = tracing::field::Empty, method = %request.method(), - uri = %request.uri(), + http.request.method = %request.method(), + http.route = %route, + url.scheme = %scheme, + url.path = %request.uri().path(), request_id = %request_id, + status = tracing::field::Empty, + latency_ms = tracing::field::Empty, ) }) .on_request(DefaultOnRequest::new().level(Level::INFO)) .on_response( - move |response: &axum::response::Response, - latency: std::time::Duration, - span: &Span| { + |response: &axum::response::Response, + latency: std::time::Duration, + span: &Span| { let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64; let status = response.status().as_u16(); - let slow_request = latency_ms >= slow_request_threshold_ms; span.record("status", status); + span.record("http.response.status_code", status); + span.record( + "otel.status_code", + if response.status().is_server_error() { + "ERROR" + } else { + "OK" + }, + ); span.record("latency_ms", latency_ms); - if slow_request { - warn!( - parent: span, - status, - latency_ms, - slow_request = true, - "http request completed slowly" - ); - } else { - info!( - parent: span, - status, - latency_ms, - slow_request = false, - "http request completed" - ); - } }, ) .on_failure( diff --git a/server-rs/crates/api-server/src/assets.rs b/server-rs/crates/api-server/src/assets.rs index 8b3afd6b..33d46ae5 100644 --- a/server-rs/crates/api-server/src/assets.rs +++ b/server-rs/crates/api-server/src/assets.rs @@ -752,10 +752,14 @@ mod tests { }; use hmac::{Hmac, Mac}; use http_body_util::BodyExt; + use platform_auth::{ + AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, + }; use reqwest::{Method, multipart}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use shared_kernel::new_uuid_simple_string; + use time::OffsetDateTime; use tower::ServiceExt; use crate::{app::build_router, config::AppConfig, state::AppState}; @@ -873,13 +877,17 @@ mod tests { ..AppConfig::default() }; - let app = build_router(AppState::new(config).expect("state should build")); + let state = AppState::new(config).expect("state should build"); + let token = + seed_authenticated_token(&state, "13800138120", "sess_assets_direct_upload").await; + let app = build_router(state); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/assets/direct-upload-tickets") + .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .header("x-request-id", "req-oss-ticket") .header("x-genarrative-response-envelope", "1") @@ -1693,6 +1701,33 @@ mod tests { Ok(fields) } + async fn seed_authenticated_token( + state: &AppState, + phone_number: &str, + session_seed: &str, + ) -> String { + let user = state + .seed_test_phone_user_with_password(phone_number, "secret123") + .await; + let claims = AccessTokenClaims::from_input( + AccessTokenClaimsInput { + user_id: user.id.clone(), + session_id: state.seed_test_refresh_session_for_user(&user, session_seed), + provider: AuthProvider::Password, + roles: vec!["user".to_string()], + token_version: user.token_version, + phone_verified: true, + binding_status: BindingStatus::Active, + display_name: Some(user.display_name.clone()), + }, + state.auth_jwt_config(), + OffsetDateTime::now_utc(), + ) + .expect("claims should build"); + + sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign") + } + fn build_object_url( config: &AppConfig, object_key: &str, diff --git a/server-rs/crates/api-server/src/backpressure.rs b/server-rs/crates/api-server/src/backpressure.rs new file mode 100644 index 00000000..6f9c5122 --- /dev/null +++ b/server-rs/crates/api-server/src/backpressure.rs @@ -0,0 +1,245 @@ +use std::sync::Arc; + +use axum::{ + body::Body, + extract::{Request, State}, + http::{HeaderValue, StatusCode, header::RETRY_AFTER}, + middleware::Next, + response::Response, +}; +use http_body_util::BodyExt; +use tokio::sync::{OwnedSemaphorePermit, TryAcquireError}; + +use crate::{ + http_error::AppError, + request_context::RequestContext, + state::{AppState, HttpRequestPermitPool}, +}; + +pub async fn limit_concurrent_requests( + State(state): State, + request: Request, + next: Next, +) -> Response { + if should_bypass_backpressure(&request) { + return next.run(request).await; + } + + let Some(permit_pool) = state.http_request_permit_pool() else { + return next.run(request).await; + }; + + match acquire_http_request_permit(permit_pool) { + Ok(permit) => hold_permit_until_response_body_dropped(next.run(request).await, permit), + Err(_) => reject_overloaded_request(&request), + } +} + +fn acquire_http_request_permit( + permit_pool: Arc, +) -> Result { + match permit_pool.clone().try_acquire_owned() { + Ok(permit) => { + crate::telemetry::update_http_request_permits_available(permit_pool.available_permits()); + Ok(HttpRequestPermitGuard { + permit: Some(permit), + permit_pool, + }) + } + Err(error) => { + crate::telemetry::update_http_request_permits_available(permit_pool.available_permits()); + Err(error) + } + } +} + +fn hold_permit_until_response_body_dropped( + response: Response, + permit: HttpRequestPermitGuard, +) -> Response { + response.map(|body| { + Body::new(body.map_frame(move |frame| { + let _permit_guard = &permit; + frame + })) + }) +} + +struct HttpRequestPermitGuard { + permit: Option, + permit_pool: Arc, +} + +impl Drop for HttpRequestPermitGuard { + fn drop(&mut self) { + drop(self.permit.take()); + crate::telemetry::update_http_request_permits_available(self.permit_pool.available_permits()); + } +} + +fn reject_overloaded_request(request: &Request) -> Response { + let request_context = request.extensions().get::().cloned(); + let mut response = AppError::from_status(StatusCode::TOO_MANY_REQUESTS) + .with_message("服务繁忙,请稍后重试") + .into_response_with_context(request_context.as_ref()); + response + .headers_mut() + .insert(RETRY_AFTER, HeaderValue::from_static("1")); + response +} + +fn should_bypass_backpressure(request: &Request) -> bool { + request.uri().path() == "/healthz" +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use axum::{ + Router, + body::Body, + extract::Extension, + http::{Request, StatusCode, header::RETRY_AFTER}, + middleware, + routing::get, + }; + use tokio::sync::Notify; + use tower::ServiceExt; + + use crate::{config::AppConfig, state::AppState}; + + use super::limit_concurrent_requests; + + #[derive(Clone)] + struct HeldRequestGate { + entered: Arc, + release: Arc, + } + + async fn held_request(Extension(gate): Extension) -> &'static str { + gate.entered.notify_one(); + gate.release.notified().await; + "ok" + } + + async fn fast_request() -> &'static str { + "ok" + } + + fn test_request(path: &str) -> Request { + Request::builder() + .uri(path) + .body(Body::empty()) + .expect("test request should build") + } + + fn build_test_app(max_concurrent_requests: usize, gate: HeldRequestGate) -> Router { + let mut config = AppConfig::default(); + config.max_concurrent_requests = Some(max_concurrent_requests); + let state = AppState::new(config).expect("state should build"); + + Router::new() + .route("/held", get(held_request)) + .route("/fast", get(fast_request)) + .route("/healthz", get(fast_request)) + .layer(middleware::from_fn_with_state( + state.clone(), + limit_concurrent_requests, + )) + .layer(Extension(gate)) + .with_state(state) + } + + #[tokio::test] + async fn returns_429_when_concurrency_permits_are_exhausted() { + let gate = HeldRequestGate { + entered: Arc::new(Notify::new()), + release: Arc::new(Notify::new()), + }; + let app = build_test_app(1, gate.clone()); + let entered = gate.entered.notified(); + + let held_response = tokio::spawn(app.clone().oneshot(test_request("/held"))); + entered.await; + + let rejected_response = app + .clone() + .oneshot(test_request("/fast")) + .await + .expect("rejected request should complete"); + assert_eq!(rejected_response.status(), StatusCode::TOO_MANY_REQUESTS); + assert_eq!( + rejected_response + .headers() + .get(RETRY_AFTER) + .and_then(|value| value.to_str().ok()), + Some("1") + ); + + gate.release.notify_one(); + let completed_response = held_response + .await + .expect("held request task should join") + .expect("held request should complete"); + assert_eq!(completed_response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn healthz_bypasses_concurrency_backpressure() { + let gate = HeldRequestGate { + entered: Arc::new(Notify::new()), + release: Arc::new(Notify::new()), + }; + let app = build_test_app(1, gate.clone()); + let entered = gate.entered.notified(); + + let held_response = tokio::spawn(app.clone().oneshot(test_request("/held"))); + entered.await; + + let health_response = app + .clone() + .oneshot(test_request("/healthz")) + .await + .expect("healthz request should complete"); + assert_eq!(health_response.status(), StatusCode::OK); + + gate.release.notify_one(); + let completed_response = held_response + .await + .expect("held request task should join") + .expect("held request should complete"); + assert_eq!(completed_response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn permit_is_held_until_response_body_is_dropped() { + let gate = HeldRequestGate { + entered: Arc::new(Notify::new()), + release: Arc::new(Notify::new()), + }; + let app = build_test_app(1, gate); + + let first_response = app + .clone() + .oneshot(test_request("/fast")) + .await + .expect("first request should complete"); + assert_eq!(first_response.status(), StatusCode::OK); + + let rejected_response = app + .clone() + .oneshot(test_request("/fast")) + .await + .expect("second request should complete"); + assert_eq!(rejected_response.status(), StatusCode::TOO_MANY_REQUESTS); + + drop(first_response); + + let accepted_response = app + .oneshot(test_request("/fast")) + .await + .expect("third request should complete"); + assert_eq!(accepted_response.status(), StatusCode::OK); + } +} diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index b8af62a4..306d557c 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -20,7 +20,11 @@ pub(crate) const DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS: u64 = 1_000_000 pub struct AppConfig { pub bind_host: String, pub bind_port: u16, + pub listen_backlog: i32, + pub worker_threads: Option, + pub max_concurrent_requests: Option, pub log_filter: String, + pub otel_enabled: bool, pub admin_username: Option, pub admin_password: Option, pub admin_token_ttl_seconds: u64, @@ -147,7 +151,11 @@ impl Default for AppConfig { Self { bind_host: "127.0.0.1".to_string(), bind_port: 3000, + listen_backlog: 1024, + worker_threads: None, + max_concurrent_requests: None, log_filter: "info,tower_http=info".to_string(), + otel_enabled: false, admin_username: None, admin_password: None, admin_token_ttl_seconds: 4 * 60 * 60, @@ -164,11 +172,11 @@ impl Default for AppConfig { dev_password_entry_auto_register_enabled: false, sms_auth_enabled: false, sms_auth_provider: "mock".to_string(), - sms_endpoint: "dypnsapi.aliyuncs.com".to_string(), + sms_endpoint: "dysmsapi.aliyuncs.com".to_string(), sms_access_key_id: None, sms_access_key_secret: None, - sms_sign_name: "速通互联验证码".to_string(), - sms_template_code: "100001".to_string(), + sms_sign_name: "北京亓盒网络科技".to_string(), + sms_template_code: "SMS_506245486".to_string(), sms_template_param_key: "code".to_string(), sms_country_code: "86".to_string(), sms_scheme_name: None, @@ -301,6 +309,22 @@ impl AppConfig { { config.log_filter = log_filter; } + if let Some(listen_backlog) = + read_first_positive_i32_env(&["GENARRATIVE_API_LISTEN_BACKLOG"]) + { + config.listen_backlog = listen_backlog; + } + if let Some(worker_threads) = read_first_usize_env(&["GENARRATIVE_API_WORKER_THREADS"]) { + config.worker_threads = Some(worker_threads); + } + if let Some(max_concurrent_requests) = + read_first_usize_env(&["GENARRATIVE_API_MAX_CONCURRENT_REQUESTS"]) + { + config.max_concurrent_requests = Some(max_concurrent_requests); + } + if let Some(otel_enabled) = read_first_bool_env(&["GENARRATIVE_OTEL_ENABLED"]) { + config.otel_enabled = otel_enabled; + } config.admin_username = read_first_non_empty_env(&["GENARRATIVE_ADMIN_USERNAME"]); config.admin_password = read_first_non_empty_env(&["GENARRATIVE_ADMIN_PASSWORD"]); @@ -881,6 +905,14 @@ fn read_first_positive_u32_env(keys: &[&str]) -> Option { }) } +fn read_first_positive_i32_env(keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + env::var(key) + .ok() + .and_then(|value| parse_positive_i32(&value)) + }) +} + fn read_first_positive_u64_env(keys: &[&str]) -> Option { keys.iter().find_map(|key| { env::var(key) @@ -946,6 +978,16 @@ fn parse_duration_seconds(raw: &str) -> Option { } fn parse_bool(raw: &str) -> Option { + let raw = raw.trim(); + let raw = raw + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + .or_else(|| { + raw.strip_prefix('\'') + .and_then(|value| value.strip_suffix('\'')) + }) + .unwrap_or(raw); + match raw.trim().to_ascii_lowercase().as_str() { "1" | "true" | "yes" | "on" => Some(true), "0" | "false" | "no" | "off" => Some(false), @@ -971,6 +1013,15 @@ fn parse_positive_u32(raw: &str) -> Option { Some(value) } +fn parse_positive_i32(raw: &str) -> Option { + let value = raw.trim().parse::().ok()?; + if value <= 0 { + return None; + } + + Some(value) +} + fn parse_u32(raw: &str) -> Option { raw.trim().parse::().ok() } @@ -1012,7 +1063,9 @@ fn parse_positive_u16(raw: &str) -> Option { #[cfg(test)] mod tests { - use super::{AppConfig, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, LlmProvider}; + use super::{ + AppConfig, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, LlmProvider, parse_bool, + }; use std::sync::{Mutex, OnceLock}; static ENV_LOCK: OnceLock> = OnceLock::new(); @@ -1035,13 +1088,44 @@ mod tests { config.dashscope_base_url, "https://dashscope.aliyuncs.com/api/v1" ); - assert_eq!(config.sms_endpoint, "dypnsapi.aliyuncs.com"); + assert_eq!(config.sms_endpoint, "dysmsapi.aliyuncs.com"); + assert_eq!(config.sms_sign_name, "北京亓盒网络科技"); + assert_eq!(config.sms_template_code, "SMS_506245486"); + assert_eq!(config.sms_template_param_key, "code"); assert_eq!( config.wechat_authorize_endpoint, "https://open.weixin.qq.com/connect/qrconnect" ); } + #[test] + fn parse_bool_accepts_wrapped_quotes_from_shell_env() { + assert_eq!(parse_bool("\"true\""), Some(true)); + assert_eq!(parse_bool("'true'"), Some(true)); + assert_eq!(parse_bool("\"false\""), Some(false)); + assert_eq!(parse_bool("'off'"), Some(false)); + } + + #[test] + fn from_env_reads_sms_enabled_when_shell_value_keeps_quotes() { + let _guard = ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("env lock should not poison"); + + unsafe { + std::env::remove_var("SMS_AUTH_ENABLED"); + std::env::set_var("SMS_AUTH_ENABLED", "\"true\""); + } + + let config = AppConfig::from_env(); + assert!(config.sms_auth_enabled); + + unsafe { + std::env::remove_var("SMS_AUTH_ENABLED"); + } + } + #[test] fn from_env_reads_non_public_models_and_urls() { let _guard = ENV_LOCK @@ -1151,6 +1235,38 @@ mod tests { } } + #[test] + fn from_env_reads_api_runtime_performance_settings() { + let _guard = ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("env lock should not poison"); + + unsafe { + std::env::remove_var("GENARRATIVE_API_LISTEN_BACKLOG"); + std::env::remove_var("GENARRATIVE_API_WORKER_THREADS"); + std::env::remove_var("GENARRATIVE_API_MAX_CONCURRENT_REQUESTS"); + std::env::remove_var("GENARRATIVE_OTEL_ENABLED"); + std::env::set_var("GENARRATIVE_API_LISTEN_BACKLOG", "2048"); + std::env::set_var("GENARRATIVE_API_WORKER_THREADS", "6"); + std::env::set_var("GENARRATIVE_API_MAX_CONCURRENT_REQUESTS", "128"); + std::env::set_var("GENARRATIVE_OTEL_ENABLED", "true"); + } + + let config = AppConfig::from_env(); + assert_eq!(config.listen_backlog, 2048); + assert_eq!(config.worker_threads, Some(6)); + assert_eq!(config.max_concurrent_requests, Some(128)); + assert!(config.otel_enabled); + + unsafe { + std::env::remove_var("GENARRATIVE_API_LISTEN_BACKLOG"); + std::env::remove_var("GENARRATIVE_API_WORKER_THREADS"); + std::env::remove_var("GENARRATIVE_API_MAX_CONCURRENT_REQUESTS"); + std::env::remove_var("GENARRATIVE_OTEL_ENABLED"); + } + } + #[test] fn from_env_reads_wechat_pay_settings() { let _guard = ENV_LOCK diff --git a/server-rs/crates/api-server/src/edutainment_baby_object.rs b/server-rs/crates/api-server/src/edutainment_baby_object.rs index cbd0682f..0458a4e6 100644 --- a/server-rs/crates/api-server/src/edutainment_baby_object.rs +++ b/server-rs/crates/api-server/src/edutainment_baby_object.rs @@ -7,14 +7,12 @@ use axum::{ response::Response, }; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; -use futures_util::{StreamExt, stream::FuturesUnordered}; -use image::{ColorType, ImageEncoder, codecs::png::PngEncoder}; +use image::{ColorType, GenericImageView, ImageEncoder, ImageFormat, codecs::png::PngEncoder}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use crate::{ api_response::json_success_body, - character_visual_assets::try_apply_background_alpha_to_png, config::DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, http_error::AppError, openai_image_generation::{ @@ -28,6 +26,7 @@ use crate::{ const BABY_OBJECT_MATCH_PROVIDER: &str = "vector-engine-gpt-image-2"; const BABY_OBJECT_MATCH_IMAGE_SIZE: &str = "1024x1024"; const BABY_OBJECT_MATCH_BACKGROUND_IMAGE_SIZE: &str = "1536x1024"; +const BABY_OBJECT_MATCH_SHEET_GRID_SIZE: u32 = 2; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -56,53 +55,58 @@ struct BabyObjectMatchItemAssetPayload { #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum BabyObjectMatchVisualAssetKind { Background, - UiFrame, GiftBox, Basket, - SmokePuff, } impl BabyObjectMatchVisualAssetKind { fn asset_id(self) -> &'static str { match self { Self::Background => "baby-object-visual-background", - Self::UiFrame => "baby-object-visual-ui-frame", Self::GiftBox => "baby-object-visual-gift-box", Self::Basket => "baby-object-visual-basket", - Self::SmokePuff => "baby-object-visual-smoke-puff", } } fn contract_kind(self) -> &'static str { match self { Self::Background => "background", - Self::UiFrame => "ui-frame", Self::GiftBox => "gift-box", Self::Basket => "basket", - Self::SmokePuff => "smoke-puff", - } - } - - fn requires_transparency(self) -> bool { - !matches!(self, Self::Background) - } - - fn image_size(self) -> &'static str { - match self { - Self::Background => BABY_OBJECT_MATCH_BACKGROUND_IMAGE_SIZE, - Self::UiFrame | Self::GiftBox | Self::Basket | Self::SmokePuff => { - BABY_OBJECT_MATCH_IMAGE_SIZE - } } } fn failure_context(self) -> &'static str { match self { Self::Background => "宝贝识物背景环境图片生成失败", - Self::UiFrame => "宝贝识物 UI 装饰图片生成失败", Self::GiftBox => "宝贝识物礼物盒图片生成失败", Self::Basket => "宝贝识物篮子图片生成失败", - Self::SmokePuff => "宝贝识物烟雾特效图片生成失败", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum BabyObjectMatchSheetSlot { + ItemA, + ItemB, + Basket, + GiftBox, +} + +impl BabyObjectMatchSheetSlot { + const ALL: [Self; 4] = [Self::ItemA, Self::ItemB, Self::Basket, Self::GiftBox]; + + fn row(self) -> u32 { + match self { + Self::ItemA | Self::ItemB => 0, + Self::Basket | Self::GiftBox => 1, + } + } + + fn col(self) -> u32 { + match self { + Self::ItemA | Self::Basket => 0, + Self::ItemB | Self::GiftBox => 1, } } } @@ -125,6 +129,21 @@ struct BabyObjectMatchVisualAssetPayload { prompt: String, } +#[derive(Debug)] +struct BabyObjectMatchSheetAssets { + items: Vec, + basket: BabyObjectMatchVisualAssetPayload, + gift_box: BabyObjectMatchVisualAssetPayload, +} + +#[derive(Clone, Copy, Debug)] +struct BabyObjectMatchSheetCellBounds { + x0: u32, + y0: u32, + x1: u32, + y1: u32, +} + pub async fn generate_baby_object_match_assets( State(state): State, Extension(request_context): Extension, @@ -153,16 +172,21 @@ pub async fn generate_baby_object_match_assets( item_count = item_names.len(), "宝贝识物 image-2 资源生成开始" ); - let (assets, visual_package) = tokio::try_join!( - build_baby_object_match_item_assets(&http_client, &settings, item_names.as_slice()), - build_baby_object_match_visual_package(&http_client, &settings, item_names.as_slice()), - ) - .map_err(|error| baby_object_match_error_response(&request_context, error))?; + let (sheet_assets, background_asset, theme_prompt) = + build_baby_object_match_optimized_assets(&http_client, &settings, item_names.as_slice()) + .await + .map_err(|error| baby_object_match_error_response(&request_context, error))?; tracing::info!( elapsed_ms = request_started_at.elapsed().as_millis() as u64, "宝贝识物 image-2 资源生成完成" ); + let assets = sheet_assets.items; + let visual_package = BabyObjectMatchVisualPackagePayload { + theme_prompt, + assets: vec![background_asset, sheet_assets.gift_box, sheet_assets.basket], + }; + Ok(json_success_body( Some(&request_context), GenerateBabyObjectMatchAssetsResponse { @@ -190,18 +214,58 @@ fn normalize_item_names(item_names: Vec) -> Result, AppError Ok(normalized) } -fn build_baby_object_match_item_prompt(item_name: &str) -> String { +async fn build_baby_object_match_optimized_assets( + http_client: &reqwest::Client, + settings: &OpenAiImageSettings, + item_names: &[String], +) -> Result< + ( + BabyObjectMatchSheetAssets, + BabyObjectMatchVisualAssetPayload, + String, + ), + AppError, +> { + let theme_prompt = build_baby_object_match_visual_theme_prompt(item_names); + let sheet_prompt = build_baby_object_match_sheet_prompt(item_names, theme_prompt.as_str()); + let background_prompt = build_baby_object_match_visual_asset_prompt( + BabyObjectMatchVisualAssetKind::Background, + item_names, + theme_prompt.as_str(), + ); + + let (sheet_assets, background_asset) = tokio::try_join!( + build_baby_object_match_sheet_assets( + http_client, + settings, + item_names, + sheet_prompt.as_str() + ), + build_baby_object_match_background_asset(http_client, settings, background_prompt.as_str()), + )?; + + Ok((sheet_assets, background_asset, theme_prompt)) +} + +fn build_baby_object_match_sheet_prompt(item_names: &[String], theme_prompt: &str) -> String { + let item_a = item_names.first().map(String::as_str).unwrap_or_default(); + let item_b = item_names.get(1).map(String::as_str).unwrap_or_default(); + format!( - "为儿童动作 Demo 玩法“宝贝识物”生成物品素材。关键词:{item_name}。\n\ - 风格必须与寓教于乐板块统一:明亮、温暖、卡通绘本质感,适合 4-8 岁儿童,物体边缘清晰,色彩干净,能自然放在草地舞台插画中。\n\ - 画面只允许出现一个围绕关键词“{item_name}”的单一物品主体,不要生成组合物、多个物体、人物、手、篮子、礼物盒或玩法 UI。\n\ - 不要生成背景、场景、氛围渲染、阴影地面、文字、水印、边框或按钮。背景必须是纯白或直接透明,便于服务端做透明抠图。\n\ - 输出为居中完整物品,留少量透明安全边距,最终素材将作为透明 PNG 进入游戏。" + "{theme_prompt}\n\ + 生成一张 1024x1024 的 2x2 游戏素材 sheet,严格均匀分成四格,但画面中不要绘制网格线、文字、标签或编号。\n\ + 四格内容必须固定为:左上格是单一物品“{item_a}”;右上格是单一物品“{item_b}”;左下格是游戏左右两侧复用的大号篮子;右下格是游戏中央使用的大号礼物盒。\n\ + 风格必须与寓教于乐板块统一:明亮、温暖、卡通绘本质感,适合 4-8 岁儿童,边缘清晰,色彩干净,能自然放在同一套游戏场景中。\n\ + 物品格只允许出现围绕对应关键词的单一主体,不能出现组合物、多个物体、人物、手、篮子、礼物盒、文字、水印或 UI。\n\ + 篮子格只生成一个主体饱满、开口清晰、可读性高的大号篮子,不能放入待分类物品,不能出现文字、人物、手或礼物盒,手柄和篮口镂空处不要留下白底描边或毛边。\n\ + 礼物盒格只生成一个主体饱满、中心构图的大号礼物盒,不能出现篮子、待分类物品、人物、手或文字。\n\ + 每格背景必须是统一纯白或接近纯白的干净背景,无场景、无阴影地面、无氛围渲染、无按钮、无边框,便于服务端将背景抠成透明 PNG。\n\ + 每个主体必须完整居中,四周保留安全留白,不得跨格、贴边或越界。" ) } -fn build_baby_object_match_negative_prompt() -> &'static str { - "背景,场景,草地,天空,房间,光效氛围,多个物品,组合套装,人物,手,篮子,礼物盒,包装文字,标签文字,水印,Logo,UI,按钮,边框,真实照片风,复杂投影" +fn build_baby_object_match_sheet_negative_prompt() -> &'static str { + "文字,数字,水印,Logo,网格线,标签,按钮,UI,人物,手,复杂背景,场景,草地,天空,房间,光效氛围,多个物品,组合套装,物体跨格,贴边,越界,真实照片风,复杂投影" } fn with_baby_object_match_image_timeout(mut settings: OpenAiImageSettings) -> OpenAiImageSettings { @@ -211,155 +275,133 @@ fn with_baby_object_match_image_timeout(mut settings: OpenAiImageSettings) -> Op settings } -async fn build_baby_object_match_item_assets( +async fn build_baby_object_match_sheet_assets( http_client: &reqwest::Client, settings: &OpenAiImageSettings, item_names: &[String], -) -> Result, AppError> { - let mut pending = FuturesUnordered::new(); + prompt: &str, +) -> Result { + let asset_started_at = Instant::now(); + tracing::info!("宝贝识物 image-2 2x2 素材 sheet 生成开始"); + let generated = create_openai_image_generation( + http_client, + settings, + prompt, + Some(build_baby_object_match_sheet_negative_prompt()), + BABY_OBJECT_MATCH_IMAGE_SIZE, + 1, + &[], + "宝贝识物 2x2 素材 sheet 生成失败", + ) + .await?; + let generated_image = generated.images.into_iter().next().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "宝贝识物 2x2 素材 sheet 生成没有返回图片。", + })) + })?; + let sliced = slice_baby_object_match_sheet(&generated_image)?; + tracing::info!( + elapsed_ms = asset_started_at.elapsed().as_millis() as u64, + "宝贝识物 image-2 2x2 素材 sheet 生成完成" + ); - // 中文注释:两个物品图互不依赖,并发生成可缩短创作等待时间。 - for (index, item_name) in item_names.iter().cloned().enumerate() { - let prompt = build_baby_object_match_item_prompt(item_name.as_str()); - pending.push(async move { - let asset_started_at = Instant::now(); - tracing::info!( - asset_kind = "item", - item_index = index + 1, - item_name = %item_name, - "宝贝识物 image-2 物品资源生成开始" - ); - let generated = create_openai_image_generation( - http_client, - settings, - prompt.as_str(), - Some(build_baby_object_match_negative_prompt()), - BABY_OBJECT_MATCH_IMAGE_SIZE, - 1, - &[], - "宝贝识物物品图片生成失败", - ) - .await?; - let generated_image = generated.images.into_iter().next().ok_or_else(|| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "vector-engine", - "message": "宝贝识物物品图片生成没有返回图片。", - })) - })?; - let image_src = build_transparent_png_data_url(generated_image)?; - tracing::info!( - asset_kind = "item", - item_index = index + 1, - item_name = %item_name, - elapsed_ms = asset_started_at.elapsed().as_millis() as u64, - "宝贝识物 image-2 物品资源生成完成" - ); - - Ok::<_, AppError>(BabyObjectMatchItemAssetPayload { - item_id: format!("baby-object-item-{}", index + 1), - item_name, - image_src, + Ok(BabyObjectMatchSheetAssets { + items: vec![ + BabyObjectMatchItemAssetPayload { + item_id: "baby-object-item-1".to_string(), + item_name: item_names.first().cloned().unwrap_or_default(), + image_src: sliced + .slot_data_url(BabyObjectMatchSheetSlot::ItemA) + .to_string(), asset_object_id: None, generation_provider: BABY_OBJECT_MATCH_PROVIDER.to_string(), - prompt, - }) - }); - } - - let mut assets = Vec::with_capacity(item_names.len()); - while let Some(result) = pending.next().await { - assets.push(result?); - } - assets.sort_by_key(|asset| asset.item_id.clone()); - - Ok(assets) + prompt: prompt.to_string(), + }, + BabyObjectMatchItemAssetPayload { + item_id: "baby-object-item-2".to_string(), + item_name: item_names.get(1).cloned().unwrap_or_default(), + image_src: sliced + .slot_data_url(BabyObjectMatchSheetSlot::ItemB) + .to_string(), + asset_object_id: None, + generation_provider: BABY_OBJECT_MATCH_PROVIDER.to_string(), + prompt: prompt.to_string(), + }, + ], + basket: BabyObjectMatchVisualAssetPayload { + asset_id: BabyObjectMatchVisualAssetKind::Basket + .asset_id() + .to_string(), + asset_kind: BabyObjectMatchVisualAssetKind::Basket + .contract_kind() + .to_string(), + image_src: sliced + .slot_data_url(BabyObjectMatchSheetSlot::Basket) + .to_string(), + asset_object_id: None, + generation_provider: BABY_OBJECT_MATCH_PROVIDER.to_string(), + prompt: prompt.to_string(), + }, + gift_box: BabyObjectMatchVisualAssetPayload { + asset_id: BabyObjectMatchVisualAssetKind::GiftBox + .asset_id() + .to_string(), + asset_kind: BabyObjectMatchVisualAssetKind::GiftBox + .contract_kind() + .to_string(), + image_src: sliced + .slot_data_url(BabyObjectMatchSheetSlot::GiftBox) + .to_string(), + asset_object_id: None, + generation_provider: BABY_OBJECT_MATCH_PROVIDER.to_string(), + prompt: prompt.to_string(), + }, + }) } -async fn build_baby_object_match_visual_package( +async fn build_baby_object_match_background_asset( http_client: &reqwest::Client, settings: &OpenAiImageSettings, - item_names: &[String], -) -> Result { - let package_started_at = Instant::now(); - let theme_prompt = build_baby_object_match_visual_theme_prompt(item_names); - let kinds = [ - BabyObjectMatchVisualAssetKind::Background, - BabyObjectMatchVisualAssetKind::UiFrame, - BabyObjectMatchVisualAssetKind::GiftBox, - BabyObjectMatchVisualAssetKind::Basket, - BabyObjectMatchVisualAssetKind::SmokePuff, - ]; - let mut pending = FuturesUnordered::new(); + prompt: &str, +) -> Result { + let asset_started_at = Instant::now(); + let kind = BabyObjectMatchVisualAssetKind::Background; tracing::info!( - asset_count = kinds.len(), - "宝贝识物 image-2 视觉主题包生成开始" + asset_kind = kind.contract_kind(), + "宝贝识物 image-2 场景资源生成开始" + ); + let generated = create_openai_image_generation( + http_client, + settings, + prompt, + Some(build_baby_object_match_visual_negative_prompt(kind)), + BABY_OBJECT_MATCH_BACKGROUND_IMAGE_SIZE, + 1, + &[], + kind.failure_context(), + ) + .await?; + let generated_image = generated.images.into_iter().next().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": format!("{}:VectorEngine 没有返回图片。", kind.failure_context()), + })) + })?; + let image_src = build_png_data_url(generated_image)?; + tracing::info!( + asset_kind = kind.contract_kind(), + elapsed_ms = asset_started_at.elapsed().as_millis() as u64, + "宝贝识物 image-2 场景资源生成完成" ); - for kind in kinds.iter().copied() { - let prompt = build_baby_object_match_visual_asset_prompt(kind, item_names, &theme_prompt); - pending.push(async move { - let asset_started_at = Instant::now(); - let asset_kind = kind.contract_kind(); - tracing::info!(asset_kind, "宝贝识物 image-2 视觉资源生成开始"); - let generated = create_openai_image_generation( - http_client, - settings, - prompt.as_str(), - Some(build_baby_object_match_visual_negative_prompt(kind)), - kind.image_size(), - 1, - &[], - kind.failure_context(), - ) - .await?; - let generated_image = generated.images.into_iter().next().ok_or_else(|| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "vector-engine", - "message": format!("{}:VectorEngine 没有返回图片。", kind.failure_context()), - })) - })?; - let image_src = if kind.requires_transparency() { - build_transparent_png_data_url(generated_image)? - } else { - build_png_data_url(generated_image)? - }; - tracing::info!( - asset_kind, - elapsed_ms = asset_started_at.elapsed().as_millis() as u64, - "宝贝识物 image-2 视觉资源生成完成" - ); - - Ok::<_, AppError>(BabyObjectMatchVisualAssetPayload { - asset_id: kind.asset_id().to_string(), - asset_kind: asset_kind.to_string(), - image_src, - asset_object_id: None, - generation_provider: BABY_OBJECT_MATCH_PROVIDER.to_string(), - prompt, - }) - }); - } - - let mut assets = Vec::with_capacity(kinds.len()); - while let Some(result) = pending.next().await { - assets.push(result?); - } - assets.sort_by_key(|asset| match asset.asset_kind.as_str() { - "background" => 0, - "ui-frame" => 1, - "gift-box" => 2, - "basket" => 3, - "smoke-puff" => 4, - _ => 5, - }); - tracing::info!( - elapsed_ms = package_started_at.elapsed().as_millis() as u64, - "宝贝识物 image-2 视觉主题包生成完成" - ); - - Ok(BabyObjectMatchVisualPackagePayload { - theme_prompt, - assets, + Ok(BabyObjectMatchVisualAssetPayload { + asset_id: kind.asset_id().to_string(), + asset_kind: kind.contract_kind().to_string(), + image_src, + asset_object_id: None, + generation_provider: BABY_OBJECT_MATCH_PROVIDER.to_string(), + prompt: prompt.to_string(), }) } @@ -453,11 +495,6 @@ fn build_baby_object_match_visual_asset_prompt( 生成游戏背景环境图。背景需要根据关键词主题匹配环境,例如水果可偏果园自然,动漫角色或玩具可偏动漫玩具主题。\n\ 保持中间、屏幕中下方和底部左右篮子区域清爽,给放大后的礼物盒、中央物品和左右大篮子预留足够空间,不能画入礼物盒、篮子、物品、人物、文字或操作 UI。" ), - BabyObjectMatchVisualAssetKind::UiFrame => format!( - "{base}\n\ - 生成透明 PNG 的 UI 装饰框资源,用于字幕条和计数器的风格化包装。\n\ - 只生成柔和装饰边框、贴纸感边缘和少量主题点缀,不生成任何文字、数字、按钮、图标说明或大面积背景。背景需要纯白或透明友好,便于抠图。" - ), BabyObjectMatchVisualAssetKind::GiftBox => format!( "{base}\n\ 生成透明 PNG 的大号礼物盒资源。礼物盒会在游戏中以约 2 倍视觉尺寸展示,需要主体饱满、轮廓清晰、中心构图、边缘安全留白少,打开动画时可被烟雾遮罩后移除。\n\ @@ -466,12 +503,7 @@ fn build_baby_object_match_visual_asset_prompt( BabyObjectMatchVisualAssetKind::Basket => format!( "{base}\n\ 生成透明 PNG 的大号篮子资源,游戏左右两侧会复用同一个篮子造型并以约 1.5 倍视觉尺寸展示。篮子主体要饱满、开口清晰、可读性高、边缘安全留白少。\n\ - 篮子要与关键词主题匹配,可以有主题色和贴纸感边缘,但不能出现任何文字、礼物盒、人物、手或待分类物品。背景需要纯白或透明友好,便于抠图。" - ), - BabyObjectMatchVisualAssetKind::SmokePuff => format!( - "{base}\n\ - 生成透明 PNG 的烟雾弹出特效资源,用于礼物盒打开瞬间。画面只允许出现一团柔和、圆润、儿童绘本风的云朵烟雾和少量主题色星点,不要生成礼物盒、篮子、物品、人物、手、文字或 UI。\n\ - 烟雾需要中心构图、边缘柔和、透明边界干净,适合覆盖礼物盒打开区域并衬托中央物品弹出。背景需要纯白或透明友好,便于抠图。" + 篮子要与关键词主题匹配,可以有主题色和贴纸感边缘,但不能出现任何文字、礼物盒、人物、手或待分类物品。背景需要纯白或透明友好,便于抠图,手柄和篮口边缘不要留下白底描边或毛边。" ), } } @@ -483,29 +515,372 @@ fn build_baby_object_match_visual_negative_prompt( BabyObjectMatchVisualAssetKind::Background => { "文字,数字,水印,Logo,按钮,说明面板,人物,手,礼物盒,篮子,中心物品,复杂前景遮挡,真实照片风,暗黑风" } - BabyObjectMatchVisualAssetKind::UiFrame => { - "文字,数字,水印,Logo,按钮,复杂面板,大面积实心背景,人物,手,礼物盒,篮子,物品主体,真实照片风" - } BabyObjectMatchVisualAssetKind::GiftBox => { "文字,数字,水印,Logo,人物,手,篮子,待分类物品,大面积背景,场景,真实照片风" } BabyObjectMatchVisualAssetKind::Basket => { "文字,数字,水印,Logo,人物,手,礼物盒,待分类物品,大面积背景,场景,真实照片风" } - BabyObjectMatchVisualAssetKind::SmokePuff => { - "文字,数字,水印,Logo,人物,手,礼物盒,篮子,待分类物品,大面积背景,场景,真实照片风,硬边爆炸,火焰" + } +} + +struct BabyObjectMatchSlicedSheet { + item_a: String, + item_b: String, + basket: String, + gift_box: String, +} + +impl BabyObjectMatchSlicedSheet { + fn slot_data_url(&self, slot: BabyObjectMatchSheetSlot) -> &str { + match slot { + BabyObjectMatchSheetSlot::ItemA => self.item_a.as_str(), + BabyObjectMatchSheetSlot::ItemB => self.item_b.as_str(), + BabyObjectMatchSheetSlot::Basket => self.basket.as_str(), + BabyObjectMatchSheetSlot::GiftBox => self.gift_box.as_str(), } } } -fn build_transparent_png_data_url(image: DownloadedOpenAiImage) -> Result { +fn slice_baby_object_match_sheet( + image: &DownloadedOpenAiImage, +) -> Result { let png_bytes = normalize_generated_image_to_png(image.bytes.as_slice())?; - let transparent_png_bytes = - try_apply_background_alpha_to_png(png_bytes.as_slice()).unwrap_or(png_bytes); - Ok(format!( - "data:image/png;base64,{}", - BASE64_STANDARD.encode(transparent_png_bytes) - )) + let source = image::load_from_memory_with_format(png_bytes.as_slice(), ImageFormat::Png) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": format!("解析宝贝识物 2x2 素材 sheet 失败:{error}"), + })) + })?; + let source = apply_baby_object_match_sheet_background_alpha(source); + let (width, height) = source.dimensions(); + if width < BABY_OBJECT_MATCH_SHEET_GRID_SIZE || height < BABY_OBJECT_MATCH_SHEET_GRID_SIZE { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "宝贝识物 2x2 素材 sheet 尺寸过小,无法切割。", + })), + ); + } + + let mut data_urls = Vec::with_capacity(BabyObjectMatchSheetSlot::ALL.len()); + for slot in BabyObjectMatchSheetSlot::ALL { + let (crop_x, crop_y, crop_width, crop_height) = + resolve_baby_object_match_sheet_cell_crop(&source, slot); + let cropped = source.crop_imm(crop_x, crop_y, crop_width, crop_height); + data_urls.push(encode_baby_object_match_dynamic_image_data_url( + cropped, + slot == BabyObjectMatchSheetSlot::Basket, + )?); + } + + Ok(BabyObjectMatchSlicedSheet { + item_a: data_urls.remove(0), + item_b: data_urls.remove(0), + basket: data_urls.remove(0), + gift_box: data_urls.remove(0), + }) +} + +fn resolve_baby_object_match_sheet_cell_crop( + source: &image::DynamicImage, + slot: BabyObjectMatchSheetSlot, +) -> (u32, u32, u32, u32) { + let (image_width, image_height) = source.dimensions(); + let cell = resolve_baby_object_match_sheet_cell_bounds( + image_width, + image_height, + slot.row(), + slot.col(), + ); + let Some(foreground) = detect_baby_object_match_sheet_foreground_bounds(source, cell) else { + return cell.to_crop_tuple(); + }; + + let pad_x = (cell.width() / 14).clamp(6, 24); + let pad_y = (cell.height() / 14).clamp(6, 24); + BabyObjectMatchSheetCellBounds { + 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), + } + .to_crop_tuple() +} + +fn resolve_baby_object_match_sheet_cell_bounds( + image_width: u32, + image_height: u32, + row: u32, + col: u32, +) -> BabyObjectMatchSheetCellBounds { + let cell_x0 = col.saturating_mul(image_width) / BABY_OBJECT_MATCH_SHEET_GRID_SIZE; + let cell_x1 = + (col.saturating_add(1)).saturating_mul(image_width) / BABY_OBJECT_MATCH_SHEET_GRID_SIZE; + let cell_y0 = row.saturating_mul(image_height) / BABY_OBJECT_MATCH_SHEET_GRID_SIZE; + let cell_y1 = + (row.saturating_add(1)).saturating_mul(image_height) / BABY_OBJECT_MATCH_SHEET_GRID_SIZE; + + BabyObjectMatchSheetCellBounds { + 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_baby_object_match_sheet_foreground_bounds( + source: &image::DynamicImage, + cell: BabyObjectMatchSheetCellBounds, +) -> Option { + let mut foreground: Option = None; + let mut foreground_pixels = 0u32; + + for y in cell.y0..cell.y1 { + for x in cell.x0..cell.x1 { + let pixel = source.get_pixel(x, y).0; + if pixel[3] <= 24 { + continue; + } + foreground_pixels = foreground_pixels.saturating_add(1); + foreground = Some(match foreground { + Some(bounds) => BabyObjectMatchSheetCellBounds { + 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 => BabyObjectMatchSheetCellBounds { + x0: x, + y0: y, + x1: x.saturating_add(1), + y1: y.saturating_add(1), + }, + }); + } + } + + let min_foreground_pixels = (cell.area() / 360).clamp(16, 240); + foreground.filter(|bounds| { + foreground_pixels >= min_foreground_pixels && bounds.width() > 2 && bounds.height() > 2 + }) +} + +fn apply_baby_object_match_sheet_background_alpha( + source: image::DynamicImage, +) -> image::DynamicImage { + let mut image = source.to_rgba8(); + let (width, height) = image.dimensions(); + if width == 0 || height == 0 { + return image::DynamicImage::ImageRgba8(image); + } + + for slot in BabyObjectMatchSheetSlot::ALL { + let cell = + resolve_baby_object_match_sheet_cell_bounds(width, height, slot.row(), slot.col()); + remove_baby_object_match_sheet_cell_background( + image.as_mut(), + width as usize, + height as usize, + cell, + ); + } + + image::DynamicImage::ImageRgba8(image) +} + +fn remove_baby_object_match_sheet_cell_background( + pixels: &mut [u8], + image_width: usize, + image_height: usize, + cell: BabyObjectMatchSheetCellBounds, +) -> bool { + let pixel_count = image_width.saturating_mul(image_height); + if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) { + return false; + } + + let background = + sample_baby_object_match_sheet_cell_background(pixels, image_width, image_height, cell); + let mut background_mask = vec![0u8; pixel_count]; + let mut queue = Vec::::new(); + let mut queue_index = 0usize; + let mut changed = false; + + let mut seed_background_pixel = |x: u32, y: u32| { + let pixel_index = y as usize * image_width + x as usize; + if background_mask[pixel_index] != 0 { + return; + } + let offset = pixel_index * 4; + let pixel = [ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]; + if !is_baby_object_match_sheet_background_pixel(pixel, background) { + return; + } + background_mask[pixel_index] = 1; + queue.push(pixel_index); + }; + + for x in cell.x0..cell.x1 { + seed_background_pixel(x, cell.y0); + seed_background_pixel(x, cell.y1.saturating_sub(1)); + } + for y in cell.y0.saturating_add(1)..cell.y1.saturating_sub(1) { + seed_background_pixel(cell.x0, y); + seed_background_pixel(cell.x1.saturating_sub(1), y); + } + + while queue_index < queue.len() { + let pixel_index = queue[queue_index]; + queue_index += 1; + + let x = (pixel_index % image_width) as u32; + let y = (pixel_index / image_width) as u32; + let neighbors = [ + (x.saturating_sub(1), y, x > cell.x0), + (x.saturating_add(1), y, x + 1 < cell.x1), + (x, y.saturating_sub(1), y > cell.y0), + (x, y.saturating_add(1), y + 1 < cell.y1), + ]; + + for (next_x, next_y, within_cell) in neighbors { + if !within_cell || next_x as usize >= image_width || next_y as usize >= image_height { + continue; + } + let next_pixel_index = next_y as usize * image_width + next_x as usize; + 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_baby_object_match_sheet_background_pixel(pixel, background) { + continue; + } + background_mask[next_pixel_index] = 1; + queue.push(next_pixel_index); + } + } + + for y in cell.y0..cell.y1 { + for x in cell.x0..cell.x1 { + let pixel_index = y as usize * image_width + x as usize; + 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; + } + } + } + + changed +} + +fn sample_baby_object_match_sheet_cell_background( + pixels: &[u8], + image_width: usize, + image_height: usize, + cell: BabyObjectMatchSheetCellBounds, +) -> [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) { + if y as usize >= image_height { + continue; + } + for x in start_x..start_x.saturating_add(sample_size).min(cell.x1) { + if x as usize >= image_width { + continue; + } + let offset = (y as usize * image_width + x as usize) * 4; + totals[0] = totals[0].saturating_add(pixels[offset] as u32); + totals[1] = totals[1].saturating_add(pixels[offset + 1] as u32); + totals[2] = totals[2].saturating_add(pixels[offset + 2] as u32); + totals[3] = totals[3].saturating_add(pixels[offset + 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 is_baby_object_match_sheet_background_pixel(pixel: [u8; 4], background: [u8; 4]) -> bool { + if pixel[3] <= 32 { + return true; + } + if background[3] <= 32 { + return pixel[3] <= 48; + } + + 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(); + if color_diff <= 58 { + return true; + } + + let background_luminance = background[0] as u16 + background[1] as u16 + background[2] as u16; + let pixel_luminance = pixel[0] as u16 + pixel[1] as u16 + pixel[2] as u16; + background_luminance >= 720 && pixel_luminance >= 720 && color_diff <= 96 +} + +impl BabyObjectMatchSheetCellBounds { + 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 build_png_data_url(image: DownloadedOpenAiImage) -> Result { @@ -540,6 +915,61 @@ fn normalize_generated_image_to_png(source: &[u8]) -> Result, AppError> Ok(encoded) } +fn encode_baby_object_match_dynamic_image_data_url( + image: image::DynamicImage, + clean_white_edge_matte: bool, +) -> Result { + let mut rgba_image = image.to_rgba8(); + if clean_white_edge_matte { + remove_baby_object_match_basket_white_matte(&mut rgba_image); + } + let (width, height) = rgba_image.dimensions(); + let mut encoded = Vec::new(); + let encoder = PngEncoder::new(&mut encoded); + encoder + .write_image(rgba_image.as_raw(), width, height, ColorType::Rgba8.into()) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": format!("编码宝贝识物 2x2 素材切图失败:{error}"), + })) + })?; + Ok(format!( + "data:image/png;base64,{}", + BASE64_STANDARD.encode(encoded) + )) +} + +fn remove_baby_object_match_basket_white_matte(image: &mut image::RgbaImage) -> bool { + let mut changed = false; + for pixel in image.pixels_mut() { + let [red, green, blue, alpha] = pixel.0; + if alpha <= 24 { + continue; + } + + let max_channel = red.max(green).max(blue); + let min_channel = red.min(green).min(blue); + let luminance = red as u16 + green as u16 + blue as u16; + let channel_spread = max_channel.saturating_sub(min_channel); + if luminance < 690 || channel_spread > 42 { + continue; + } + + let next_alpha = if luminance >= 735 && channel_spread <= 30 { + 0 + } else { + ((alpha as f32) * 0.18).round() as u8 + }; + if next_alpha != alpha { + pixel.0[3] = next_alpha; + changed = true; + } + } + + changed +} + fn baby_object_match_error_response(request_context: &RequestContext, error: AppError) -> Response { error.into_response_with_context(Some(request_context)) } @@ -549,15 +979,21 @@ mod tests { use super::*; #[test] - fn prompt_locks_single_transparent_object_constraints() { - let prompt = build_baby_object_match_item_prompt("苹果"); + fn sheet_prompt_locks_two_by_two_asset_layout() { + let names = vec!["苹果".to_string(), "香蕉".to_string()]; + let theme_prompt = build_baby_object_match_visual_theme_prompt(names.as_slice()); + let prompt = build_baby_object_match_sheet_prompt(names.as_slice(), theme_prompt.as_str()); assert!(prompt.contains("苹果")); + assert!(prompt.contains("香蕉")); + assert!(prompt.contains("2x2")); + assert!(prompt.contains("左上格")); + assert!(prompt.contains("右上格")); + assert!(prompt.contains("左下格")); + assert!(prompt.contains("右下格")); assert!(prompt.contains("卡通绘本")); - assert!(prompt.contains("单一物品")); - assert!(prompt.contains("不要生成背景")); - assert!(prompt.contains("透明 PNG")); - assert!(prompt.contains("纯白或直接透明")); + assert!(prompt.contains("白底描边")); + assert!(prompt.contains("纯白")); } #[test] @@ -622,21 +1058,82 @@ mod tests { } #[test] - fn normalizes_png_to_transparent_png_data_url() { - let mut source = Vec::new(); - let pixels = vec![255u8; 4 * 2 * 2]; - let encoder = PngEncoder::new(&mut source); + fn slices_two_by_two_sheet_into_transparent_asset_data_urls() { + let width = 96; + let height = 96; + let mut sheet = + image::RgbaImage::from_pixel(width, height, image::Rgba([255, 255, 255, 255])); + let slots = [ + (8..40, 8..40, [220, 32, 48, 255]), + (56..88, 8..40, [250, 210, 70, 255]), + (8..40, 56..88, [42, 142, 92, 255]), + (56..88, 56..88, [92, 120, 230, 255]), + ]; + for (xs, ys, color) in slots { + for y in ys { + for x in xs.clone() { + sheet.put_pixel(x, y, image::Rgba(color)); + } + } + } + let mut bytes = Vec::new(); + let encoder = PngEncoder::new(&mut bytes); encoder - .write_image(pixels.as_slice(), 2, 2, ColorType::Rgba8.into()) - .expect("test png should encode"); + .write_image(sheet.as_raw(), width, height, ColorType::Rgba8.into()) + .expect("test sheet should encode"); - let image_src = build_transparent_png_data_url(DownloadedOpenAiImage { - bytes: source, + let sliced = slice_baby_object_match_sheet(&DownloadedOpenAiImage { + bytes, mime_type: "image/png".to_string(), extension: "png".to_string(), }) - .expect("test png should normalize"); + .expect("sheet should slice"); - assert!(image_src.starts_with("data:image/png;base64,")); + for slot in BabyObjectMatchSheetSlot::ALL { + let data_url = sliced.slot_data_url(slot); + assert!(data_url.starts_with("data:image/png;base64,")); + let payload = data_url + .strip_prefix("data:image/png;base64,") + .expect("data url should include png prefix"); + let png_bytes = BASE64_STANDARD + .decode(payload) + .expect("data url should decode"); + let decoded = image::load_from_memory(png_bytes.as_slice()) + .expect("slice should decode") + .to_rgba8(); + assert!( + decoded.pixels().any(|pixel| pixel[3] == 0), + "sheet white background should become transparent" + ); + assert!( + decoded.pixels().any(|pixel| pixel[3] > 200), + "slice should keep foreground pixels" + ); + } + } + + #[test] + fn basket_white_matte_cleanup_removes_enclosed_white_handle_fill() { + let mut basket = image::RgbaImage::from_pixel(48, 48, image::Rgba([0, 0, 0, 0])); + for y in 12..36 { + for x in 8..40 { + basket.put_pixel(x, y, image::Rgba([186, 92, 24, 255])); + } + } + for y in 4..14 { + for x in 12..20 { + basket.put_pixel(x, y, image::Rgba([252, 251, 246, 255])); + } + } + for y in 4..14 { + for x in 28..36 { + basket.put_pixel(x, y, image::Rgba([249, 248, 242, 255])); + } + } + + assert!(remove_baby_object_match_basket_white_matte(&mut basket)); + assert_eq!(basket.get_pixel(16, 8).0[3], 0); + assert_eq!(basket.get_pixel(32, 8).0[3], 0); + assert_eq!(basket.get_pixel(20, 20).0[3], 255); } } 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/main.rs b/server-rs/crates/api-server/src/main.rs index db6d0d28..665f3526 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -13,6 +13,7 @@ mod auth_payload; mod auth_public_user; mod auth_session; mod auth_sessions; +mod backpressure; mod bark_battle; mod big_fish; mod big_fish_agent_turn; @@ -55,9 +56,11 @@ mod password_management; mod phone_auth; mod platform_errors; mod profile_identity; +mod process_metrics; mod prompt; mod puzzle; mod puzzle_agent_turn; +mod puzzle_gallery_cache; mod refresh_session; mod registration_reward; mod request_context; @@ -75,6 +78,7 @@ mod square_hole_agent_turn; mod state; mod story_battles; mod story_sessions; +mod telemetry; mod tracking; mod vector_engine_audio_generation; mod visual_novel; @@ -85,8 +89,15 @@ mod wechat_provider; mod work_author; mod work_play_tracking; -use shared_logging::init_tracing; -use std::{collections::HashSet, env, fs, io, panic, thread, time::Duration}; +use shared_logging::{OtelConfig, init_tracing}; +use socket2::{Domain, Protocol, Socket, Type}; +use std::{ + collections::HashSet, + env, fs, io, + net::{SocketAddr, TcpListener as StdTcpListener}, + panic, thread, + time::Duration, +}; use tokio::net::TcpListener; use tokio::runtime::Builder as TokioRuntimeBuilder; use tokio::time::timeout; @@ -103,12 +114,18 @@ fn main() -> Result<(), io::Error> { .name("api-server-bootstrap".to_string()) .stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES) .spawn(|| { - TokioRuntimeBuilder::new_multi_thread() + load_local_env_files(); + let config = AppConfig::from_env(); + let mut runtime_builder = TokioRuntimeBuilder::new_multi_thread(); + runtime_builder .enable_all() .thread_name("api-server-worker") - .thread_stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES) - .build()? - .block_on(run_server()) + .thread_stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES); + if let Some(worker_threads) = config.worker_threads { + runtime_builder.worker_threads(worker_threads); + } + + runtime_builder.build()?.block_on(run_server(config)) })?; match server_thread.join() { @@ -117,28 +134,52 @@ fn main() -> Result<(), io::Error> { } } -async fn run_server() -> Result<(), io::Error> { - // 运行本地开发与联调时,优先从仓库根目录加载本地变量。 - // 只尊重外层 shell 先注入的变量;后续本地文件需要能覆盖前序本地文件。 - load_local_env_files(); - - // 统一先从配置对象读取监听地址,避免后续把环境变量读取散落到入口和路由层。 - let config = AppConfig::from_env(); - init_tracing(&config.log_filter)?; +async fn run_server(config: AppConfig) -> Result<(), io::Error> { + init_tracing( + &config.log_filter, + OtelConfig { + enabled: config.otel_enabled, + }, + )?; + process_metrics::register_process_metrics(); + telemetry::register_http_runtime_metrics(); let bind_address = config.bind_socket_addr(); - let listener = TcpListener::bind(bind_address).await?; + let listen_backlog = config.listen_backlog; + let worker_threads = config.worker_threads; + let otel_enabled = config.otel_enabled; + let listener = build_tcp_listener(bind_address, listen_backlog)?; let state = restore_app_state_for_startup(config) .await .map_err(|error| std::io::Error::other(format!("初始化应用状态失败:{error}")))?; + state.puzzle_gallery_cache().spawn_cleanup_task(); let router = build_router(state); - info!(%bind_address, "api-server 已完成 tracing 初始化并开始监听"); + info!( + %bind_address, + listen_backlog, + worker_threads = worker_threads.unwrap_or(0), + otel_enabled, + "api-server 已完成 tracing 初始化并开始监听" + ); axum::serve(listener, router).await } +fn build_tcp_listener( + bind_address: SocketAddr, + listen_backlog: i32, +) -> Result { + let domain = Domain::for_address(bind_address); + let socket = Socket::new(domain, Type::STREAM, Some(Protocol::TCP))?; + socket.set_reuse_address(true)?; + socket.set_nonblocking(true)?; + socket.bind(&bind_address.into())?; + socket.listen(listen_backlog)?; + TcpListener::from_std(StdTcpListener::from(socket)) +} + async fn restore_app_state_for_startup( config: AppConfig, ) -> Result { diff --git a/server-rs/crates/api-server/src/match3d.rs b/server-rs/crates/api-server/src/match3d.rs index 4d42df69..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,4251 +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; - 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); - } - } - - // 中文注释:单图被前景边界收紧后,浅绿框可能正好贴在 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; - } - } - - 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 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, @@ -7099,1671 +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_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(), - }), - 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_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.generated_item_assets[0].image_src.as_deref(), - Some("/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png") - ); - } -} +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/mappers.rs b/server-rs/crates/api-server/src/match3d/mappers.rs index 3bf0da7a..983159a8 100644 --- a/server-rs/crates/api-server/src/match3d/mappers.rs +++ b/server-rs/crates/api-server/src/match3d/mappers.rs @@ -32,6 +32,10 @@ pub(super) fn map_match3d_agent_session_response_with_assets( ) -> Match3DAgentSessionSnapshotResponse { let mut response = map_match3d_agent_session_response(session); if let Some(draft) = response.draft.as_mut() { + if generated_item_assets.is_empty() { + return response; + } + draft.generated_item_assets = generated_item_assets .iter() .cloned() @@ -129,7 +133,15 @@ pub(super) fn map_match3d_config_response( pub(super) fn map_match3d_draft_response( draft: Match3DResultDraftRecord, ) -> Match3DResultDraftResponse { - Match3DResultDraftResponse { + // 中文注释:session draft 自身也可能携带生成素材快照,不能只依赖 work detail 回读补齐 UI 背景和容器图。 + let generated_item_assets = parse_match3d_generated_item_assets( + draft.generated_item_assets_json.as_deref(), + ) + .into_iter() + .map(Match3DGeneratedItemAsset::from) + .collect::>(); + let background_asset = find_match3d_generated_background_asset(&generated_item_assets); + let mut response = Match3DResultDraftResponse { profile_id: draft.profile_id, game_name: draft.game_name, theme_text: draft.theme_text, @@ -147,8 +159,24 @@ pub(super) fn map_match3d_draft_response( background_image_src: None, background_image_object_key: None, generated_background_asset: None, - generated_item_assets: Vec::new(), + generated_item_assets: generated_item_assets + .iter() + .cloned() + .map(map_match3d_generated_item_asset_for_agent) + .collect(), + }; + + if response + .cover_image_src + .as_deref() + .map(str::trim) + .unwrap_or_default() + .is_empty() + { + response.cover_image_src = resolve_match3d_default_cover_image_src(&generated_item_assets); } + apply_match3d_background_asset_to_agent_draft(&mut response, background_asset); + response } pub(super) fn map_match3d_generated_item_asset_for_agent( @@ -365,6 +393,45 @@ pub(super) fn build_match3d_work_profile_record_with_assets( item } +fn match3d_text_present(value: Option<&String>) -> bool { + value.is_some_and(|value| !value.trim().is_empty()) +} + +fn match3d_item_asset_has_image(asset: &Match3DGeneratedItemAssetJson) -> bool { + match3d_text_present(asset.image_src.as_ref()) + || match3d_text_present(asset.image_object_key.as_ref()) + || asset.image_views.iter().any(|view| { + match3d_text_present(view.image_src.as_ref()) + || match3d_text_present(view.image_object_key.as_ref()) + }) +} + +fn match3d_background_asset_has_image(asset: &Match3DGeneratedBackgroundAsset) -> bool { + match3d_text_present(asset.image_src.as_ref()) + || match3d_text_present(asset.image_object_key.as_ref()) + || match3d_text_present(asset.container_image_src.as_ref()) + || match3d_text_present(asset.container_image_object_key.as_ref()) +} + +fn resolve_match3d_work_generation_status( + item: &Match3DWorkProfileRecord, + assets: &[Match3DGeneratedItemAssetJson], + background_asset: Option<&Match3DGeneratedBackgroundAsset>, +) -> Option { + if item.publication_status.eq_ignore_ascii_case("published") { + return Some("ready".to_string()); + } + + if assets.is_empty() + || !assets.iter().any(match3d_item_asset_has_image) + || !background_asset.is_some_and(match3d_background_asset_has_image) + { + return Some("generating".to_string()); + } + + Some("ready".to_string()) +} + pub(super) fn map_match3d_message_response( message: Match3DAgentMessageRecord, ) -> Match3DAgentMessageResponse { @@ -383,6 +450,11 @@ pub(super) fn map_match3d_work_summary_response( let generated_item_asset_json = parse_match3d_generated_item_assets(item.generated_item_assets_json.as_deref()); let background_asset = find_match3d_generated_background_asset_json(&generated_item_asset_json); + let generation_status = resolve_match3d_work_generation_status( + &item, + &generated_item_asset_json, + background_asset.as_ref(), + ); let generated_background_asset = background_asset .clone() .map(map_match3d_background_asset_for_work); @@ -408,6 +480,7 @@ pub(super) fn map_match3d_work_summary_response( updated_at: item.updated_at, published_at: item.published_at, publish_ready: item.publish_ready, + generation_status, background_prompt: background_asset.as_ref().map(|asset| asset.prompt.clone()), background_image_src: background_asset .as_ref() 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/process_metrics.rs b/server-rs/crates/api-server/src/process_metrics.rs new file mode 100644 index 00000000..5f27c8b8 --- /dev/null +++ b/server-rs/crates/api-server/src/process_metrics.rs @@ -0,0 +1,306 @@ +use std::sync::OnceLock; + +use opentelemetry::global; +use tracing::warn; + +// 进程指标只描述 api-server 自身,不携带请求、用户或作品维度,避免 OTLP 指标高基数膨胀。 +pub(crate) fn register_process_metrics() { + static REGISTERED: OnceLock<()> = OnceLock::new(); + REGISTERED.get_or_init(register_process_metrics_once); +} + +fn register_process_metrics_once() { + let meter = global::meter("genarrative-api"); + + meter + .i64_observable_up_down_counter("process.memory.usage") + .with_unit("By") + .with_description("api-server process physical memory usage") + .with_callback(|observer| { + let Some(snapshot) = ProcessMetricsSnapshot::collect() else { + return; + }; + observer.observe(to_i64(snapshot.rss_bytes), &[]); + }) + .build(); + + meter + .i64_observable_up_down_counter("process.memory.virtual") + .with_unit("By") + .with_description("api-server committed virtual memory") + .with_callback(|observer| { + let Some(snapshot) = ProcessMetricsSnapshot::collect() else { + return; + }; + if let Some(virtual_bytes) = snapshot.virtual_bytes { + observer.observe(to_i64(virtual_bytes), &[]); + } + }) + .build(); + + meter + .i64_observable_up_down_counter("genarrative.process.memory.private") + .with_unit("By") + .with_description("api-server private memory for local diagnostics") + .with_callback(|observer| { + let Some(snapshot) = ProcessMetricsSnapshot::collect() else { + return; + }; + if let Some(private_bytes) = snapshot.private_bytes { + observer.observe(to_i64(private_bytes), &[]); + } + }) + .build(); + + meter + .i64_observable_up_down_counter("process.thread.count") + .with_unit("{thread}") + .with_description("api-server process thread count") + .with_callback(|observer| { + let Some(snapshot) = ProcessMetricsSnapshot::collect() else { + return; + }; + observer.observe(to_i64(snapshot.thread_count), &[]); + }) + .build(); + + meter + .i64_observable_up_down_counter("process.windows.handle.count") + .with_unit("{handle}") + .with_description("api-server process handle count on Windows") + .with_callback(|observer| { + let Some(snapshot) = ProcessMetricsSnapshot::collect() else { + return; + }; + if let Some(handle_count) = snapshot.windows_handle_count { + observer.observe(to_i64(handle_count), &[]); + } + }) + .build(); + + meter + .i64_observable_up_down_counter("process.unix.file_descriptor.count") + .with_unit("{file_descriptor}") + .with_description("api-server process file descriptor count on Unix") + .with_callback(|observer| { + let Some(snapshot) = ProcessMetricsSnapshot::collect() else { + return; + }; + if let Some(fd_count) = snapshot.unix_fd_count { + observer.observe(to_i64(fd_count), &[]); + } + }) + .build(); +} + +fn to_i64(value: u64) -> i64 { + value.min(i64::MAX as u64) as i64 +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ProcessMetricsSnapshot { + rss_bytes: u64, + private_bytes: Option, + virtual_bytes: Option, + thread_count: u64, + windows_handle_count: Option, + unix_fd_count: Option, +} + +impl ProcessMetricsSnapshot { + fn collect() -> Option { + collect_process_metrics() + .inspect_err(|error| { + warn!(%error, "采集 api-server 进程内存指标失败"); + }) + .ok() + } +} + +#[cfg(windows)] +fn collect_process_metrics() -> Result { + use windows_sys::Win32::{ + System::{ + ProcessStatus::{GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS_EX}, + Threading::{GetCurrentProcess, GetCurrentProcessId, GetProcessHandleCount}, + }, + }; + + let handle = unsafe { GetCurrentProcess() }; + let mut counters = PROCESS_MEMORY_COUNTERS_EX { + cb: std::mem::size_of::() as u32, + ..Default::default() + }; + let ok = unsafe { + GetProcessMemoryInfo( + handle, + std::ptr::addr_of_mut!(counters).cast(), + counters.cb, + ) + }; + if ok == 0 { + return Err("GetProcessMemoryInfo returned false".to_string()); + } + + let mut handle_count = 0_u32; + let handle_count = if unsafe { GetProcessHandleCount(handle, &mut handle_count) } == 0 { + None + } else { + Some(u64::from(handle_count)) + }; + + Ok(ProcessMetricsSnapshot { + rss_bytes: counters.WorkingSetSize as u64, + private_bytes: Some(counters.PrivateUsage as u64), + virtual_bytes: Some(counters.PrivateUsage as u64), + thread_count: u64::from(unsafe { GetCurrentProcessId() }.thread_count()?), + windows_handle_count: handle_count, + unix_fd_count: None, + }) +} + +#[cfg(windows)] +trait WindowsProcessThreadCount { + fn thread_count(self) -> Result; +} + +#[cfg(windows)] +impl WindowsProcessThreadCount for u32 { + fn thread_count(self) -> Result { + use windows_sys::Win32::{ + Foundation::{CloseHandle, INVALID_HANDLE_VALUE}, + System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, PROCESSENTRY32, Process32First, Process32Next, + TH32CS_SNAPPROCESS, + }, + }; + + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; + if snapshot == INVALID_HANDLE_VALUE { + return Err("CreateToolhelp32Snapshot returned INVALID_HANDLE_VALUE".to_string()); + } + + let mut entry = PROCESSENTRY32 { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; + let mut found = None; + let mut ok = unsafe { Process32First(snapshot, &mut entry) }; + while ok != 0 { + if entry.th32ProcessID == self { + found = Some(entry.cntThreads); + break; + } + ok = unsafe { Process32Next(snapshot, &mut entry) }; + } + unsafe { + CloseHandle(snapshot); + } + + found.ok_or_else(|| format!("process {self} not found in ToolHelp snapshot")) + } +} + +#[cfg(target_os = "linux")] +fn collect_process_metrics() -> Result { + let status = std::fs::read_to_string("/proc/self/status") + .map_err(|error| format!("read /proc/self/status failed: {error}"))?; + let statm = std::fs::read_to_string("/proc/self/statm") + .map_err(|error| format!("read /proc/self/statm failed: {error}"))?; + let page_size = linux_page_size_bytes()?; + + let rss_bytes = parse_status_kb(&status, "VmRSS:") + .map(|value| value * 1024) + .or_else(|| parse_statm_pages(&statm, 1).map(|value| value * page_size)) + .ok_or_else(|| "missing VmRSS/statm resident field".to_string())?; + let virtual_bytes = parse_status_kb(&status, "VmSize:") + .map(|value| value * 1024) + .or_else(|| parse_statm_pages(&statm, 0).map(|value| value * page_size)) + .ok_or_else(|| "missing VmSize/statm size field".to_string())?; + let private_bytes = parse_status_kb(&status, "VmData:").map(|value| value * 1024); + let thread_count = parse_status_u64(&status, "Threads:") + .ok_or_else(|| "missing Threads field".to_string())?; + + Ok(ProcessMetricsSnapshot { + rss_bytes, + private_bytes, + virtual_bytes: Some(virtual_bytes), + thread_count, + windows_handle_count: None, + unix_fd_count: linux_fd_count(), + }) +} + +#[cfg(target_os = "linux")] +fn linux_page_size_bytes() -> Result { + let output = std::process::Command::new("getconf") + .arg("PAGESIZE") + .output() + .map_err(|error| format!("getconf PAGESIZE failed: {error}"))?; + if !output.status.success() { + return Err(format!("getconf PAGESIZE exited with {}", output.status)); + } + let text = String::from_utf8(output.stdout) + .map_err(|error| format!("getconf PAGESIZE output is not utf8: {error}"))?; + text.trim() + .parse::() + .map_err(|error| format!("parse PAGESIZE failed: {error}")) +} + +#[cfg(target_os = "linux")] +fn linux_fd_count() -> Option { + let entries = std::fs::read_dir("/proc/self/fd").ok()?; + Some(entries.filter_map(Result::ok).count() as u64) +} + +#[cfg(target_os = "linux")] +fn parse_status_kb(status: &str, key: &str) -> Option { + parse_status_u64(status, key) +} + +#[cfg(target_os = "linux")] +fn parse_status_u64(status: &str, key: &str) -> Option { + status.lines().find_map(|line| { + let rest = line.strip_prefix(key)?.trim(); + rest.split_whitespace().next()?.parse::().ok() + }) +} + +#[cfg(target_os = "linux")] +fn parse_statm_pages(statm: &str, index: usize) -> Option { + statm + .split_whitespace() + .nth(index)? + .parse::() + .ok() +} + +#[cfg(not(any(windows, target_os = "linux")))] +fn collect_process_metrics() -> Result { + Err("process metrics are only implemented for Windows and Linux".to_string()) +} + +#[cfg(test)] +mod tests { + #[cfg(target_os = "linux")] + use super::{parse_statm_pages, parse_status_kb, parse_status_u64}; + + #[cfg(target_os = "linux")] + #[test] + fn parses_linux_proc_status_memory_fields() { + let status = "Name:\tapi-server\nVmSize:\t 123456 kB\nVmRSS:\t 7890 kB\nVmData:\t 3456 kB\nThreads:\t37\n"; + + assert_eq!(parse_status_kb(status, "VmRSS:"), Some(7890)); + assert_eq!(parse_status_kb(status, "VmSize:"), Some(123456)); + assert_eq!(parse_status_kb(status, "VmData:"), Some(3456)); + assert_eq!(parse_status_u64(status, "Threads:"), Some(37)); + } + + #[cfg(target_os = "linux")] + #[test] + fn parses_linux_statm_pages() { + assert_eq!(parse_statm_pages("100 20 0 0 0 0 0", 0), Some(100)); + assert_eq!(parse_statm_pages("100 20 0 0 0 0 0", 1), Some(20)); + assert_eq!(parse_statm_pages("100 20", 7), None); + } +} 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.rs b/server-rs/crates/api-server/src/puzzle.rs index 65363cb6..6aef164c 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -38,7 +38,7 @@ use shared_contracts::{ PuzzleResultPreviewBlockerResponse, PuzzleResultPreviewEnvelopeResponse, PuzzleResultPreviewFindingResponse, SendPuzzleAgentMessageRequest, }, - puzzle_gallery::{PuzzleGalleryDetailResponse, PuzzleGalleryResponse}, + puzzle_gallery::PuzzleGalleryDetailResponse, puzzle_runtime::{ AdvancePuzzleNextLevelRequest, DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse, PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse, @@ -59,16 +59,16 @@ use spacetime_client::{ PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, - PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord, - PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, - PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, - PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, - PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput, - PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, - PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput, - PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput, - PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, - SpacetimeClientError, + PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, + PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, + PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, + PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, + PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, + PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord, + PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleSelectCoverImageRecordInput, + PuzzleUiBackgroundSaveRecordInput, PuzzleWorkLikeReportRecordInput, + PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, + PuzzleWorkUpsertRecordInput, SpacetimeClientError, }; use std::convert::Infallible; @@ -103,6 +103,7 @@ use crate::{ PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input, run_puzzle_agent_turn, }, + puzzle_gallery_cache::{build_puzzle_gallery_window_response, puzzle_gallery_cached_json}, request_context::RequestContext, state::AppState, vector_engine_audio_generation::{ @@ -133,6 +134,10 @@ const PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT: usize = 5; const PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL: &str = "gpt-image-2"; const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str = "移动端拼图游戏纯背景,题材氛围清晰,不包含拼图槽或 UI 元素"; +<<<<<<< Updated upstream +mod handlers; +pub(crate) use self::handlers::*; +======= pub async fn create_puzzle_agent_session( State(state): State, Extension(request_context): Extension, @@ -797,9 +802,9 @@ pub async fn execute_puzzle_agent_action( "compile_puzzle_draft", "首关拼图草稿", if ai_redraw { - "已编译首关草稿、生成首关画面并写入正式草稿。" + "已编译首关草稿、并行生成首关画面和 UI 背景并写入正式草稿。" } else { - "已编译首关草稿,并直接应用上传图片为第一关图片。" + "已编译首关草稿,并直接应用上传图片、生成 UI 背景为第一关图片。" }, session, ) @@ -1528,7 +1533,19 @@ pub async fn claim_puzzle_work_point_incentive( pub async fn list_puzzle_gallery( State(state): State, Extension(request_context): Extension, -) -> Result, Response> { +) -> Result { + if let Some(response) = state.puzzle_gallery_cache().read_fresh_response().await { + crate::telemetry::record_puzzle_gallery_cache_hit(); + return Ok(puzzle_gallery_cached_json(&request_context, response)); + } + crate::telemetry::record_puzzle_gallery_cache_miss(); + let _rebuild_guard = state.puzzle_gallery_cache().acquire_rebuild_guard().await; + if let Some(response) = state.puzzle_gallery_cache().read_fresh_response().await { + crate::telemetry::record_puzzle_gallery_cache_hit(); + return Ok(puzzle_gallery_cached_json(&request_context, response)); + } + + let rebuild_started_at = std::time::Instant::now(); let items = state .spacetime_client() .list_puzzle_gallery() @@ -1541,15 +1558,32 @@ pub async fn list_puzzle_gallery( ) })?; - Ok(json_success_body( - Some(&request_context), - PuzzleGalleryResponse { - items: items - .into_iter() - .map(|item| map_puzzle_work_summary_response(&state, item)) - .collect(), - }, - )) + let response = build_puzzle_gallery_window_response( + items + .into_iter() + .map(|item| map_puzzle_gallery_card_response(&state, item)) + .collect(), + ); + let cached_response = state + .puzzle_gallery_cache() + .store_response(response) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_GALLERY_PROVIDER, + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": PUZZLE_GALLERY_PROVIDER, + "message": format!("拼图广场缓存序列化失败:{error}"), + })), + ) + })?; + crate::telemetry::record_puzzle_gallery_cache_rebuild( + rebuild_started_at.elapsed(), + cached_response.data_json_len(), + ); + + Ok(puzzle_gallery_cached_json(&request_context, cached_response)) } pub async fn get_puzzle_gallery_detail( @@ -2108,11 +2142,16 @@ pub async fn submit_puzzle_leaderboard( }, )) } +>>>>>>> Stashed changes mod mappers; -use mappers::*; +use self::mappers::*; +<<<<<<< Updated upstream +mod draft; +use self::draft::*; +======= fn build_puzzle_form_seed_text(payload: &CreatePuzzleAgentSessionRequest) -> String { build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts { title: None, @@ -3260,20 +3299,17 @@ async fn compile_puzzle_draft_with_initial_cover( &target_level.picture_description, &draft.summary, ); - let image_level_name = if target_level.level_name.trim().is_empty() { - build_fallback_puzzle_first_level_name(&target_level.picture_description) - } else { - target_level.level_name.clone() - }; - // 中文注释:首图 prompt 只依赖画面描述,关卡名分支可以和生图分支并行;OSS 临时路径使用已有名或确定性兜底名。 - let level_name_future = - generate_puzzle_first_level_name(state, &target_level.picture_description); - // 点击生成草稿时一次性完成首图生成与正式图选定,前端只展示进度,不再承担业务编排。 + let generated_naming = + generate_puzzle_first_level_name(state, &target_level.picture_description).await; + target_level.level_name = generated_naming.level_name.clone(); + target_level.ui_background_prompt = generated_naming.ui_background_prompt.clone(); + let mut generated_metadata = generated_naming; + // 点击生成草稿时一次性完成首图生成、UI 背景生成与正式图选定,前端只展示进度,不再承担业务编排。 let candidates_future = generate_puzzle_image_candidates( state, owner_user_id.as_str(), &compiled_session.session_id, - &image_level_name, + &target_level.level_name, &image_prompt, reference_image_src, true, @@ -3281,33 +3317,26 @@ async fn compile_puzzle_draft_with_initial_cover( 1, target_level.candidates.len(), ); - let (generated_naming, candidates_result) = tokio::join!(level_name_future, candidates_future); - target_level.level_name = generated_naming.level_name.clone(); - target_level.ui_background_prompt = generated_naming.ui_background_prompt.clone(); - let mut generated_metadata = generated_naming; - let candidates = candidates_result?; - let selected_candidate_id = candidates - .iter() - .find(|candidate| candidate.record.selected) - .or_else(|| candidates.first()) - .map(|candidate| candidate.record.candidate_id.clone()) - .ok_or_else(|| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": PUZZLE_AGENT_API_BASE_PROVIDER, - "message": "拼图候选图生成结果为空", - })) - })?; - if let Some(refined_naming) = generate_puzzle_first_level_name_from_image( + let ui_background_future = generate_puzzle_initial_ui_background_required( state, - target_level.picture_description.as_str(), - &candidates[0].downloaded_image, - ) - .await + owner_user_id.as_str(), + compiled_session.session_id.as_str(), + &draft, + &target_level, + ); + // 中文注释:命名稳定后并行发起首关图与 UI 背景,避免两次外部生图串行等待。 + let (candidates_result, ui_background_result) = + tokio::join!(candidates_future, ui_background_future); + let mut candidates = candidates_result?; + if let Some(first_candidate) = candidates.first() + && let Some(refined_naming) = generate_puzzle_first_level_name_from_image( + state, + target_level.picture_description.as_str(), + &first_candidate.downloaded_image, + ) + .await { target_level.level_name = refined_naming.level_name; - if refined_naming.ui_background_prompt.is_some() { - target_level.ui_background_prompt = refined_naming.ui_background_prompt; - } if refined_naming.work_description.is_some() { generated_metadata.work_description = refined_naming.work_description; } @@ -3320,15 +3349,22 @@ async fn compile_puzzle_draft_with_initial_cover( let generated_level_name = target_level.level_name.clone(); let mut updated_levels = build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src); + for candidate in &mut candidates { + candidate.record.prompt = image_prompt.clone(); + } + let selected_candidate_id = candidates + .iter() + .find(|candidate| candidate.record.selected) + .or_else(|| candidates.first()) + .map(|candidate| candidate.record.candidate_id.clone()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图候选图生成结果为空", + })) + })?; // 中文注释:拼图草稿音频生成临时关闭,首版生成只补首图与 UI 背景。 - let (ui_prompt, ui_background) = generate_puzzle_initial_ui_background_required( - state, - owner_user_id.as_str(), - compiled_session.session_id.as_str(), - &draft, - &target_level, - ) - .await?; + let (ui_prompt, ui_background) = ui_background_result?; attach_puzzle_level_ui_background( &mut updated_levels, target_level.level_id.as_str(), @@ -3549,11 +3585,6 @@ async fn compile_puzzle_draft_with_uploaded_cover( &target_level.picture_description, &draft.summary, ); - let image_level_name = if target_level.level_name.trim().is_empty() { - build_fallback_puzzle_first_level_name(&target_level.picture_description) - } else { - target_level.level_name.clone() - }; // 中文注释:关闭 AI 重绘时首关图不请求 VectorEngine;上传图直接成为首关正式图候选。 let candidate_id = format!( "{}-candidate-{}", @@ -3572,21 +3603,8 @@ async fn compile_puzzle_draft_with_uploaded_cover( target_level.picture_description.as_str(), &uploaded_downloaded_image, ); - let persist_upload_future = persist_puzzle_generated_asset( - state, - owner_user_id.as_str(), - &compiled_session.session_id, - image_level_name.as_str(), - candidate_id.as_str(), - "uploaded-direct", - uploaded_downloaded_image.clone(), - current_utc_micros(), - ); - let (mut generated_naming, refined_naming, persisted_upload_result) = tokio::join!( - level_name_future, - image_level_name_future, - persist_upload_future - ); + let (mut generated_naming, refined_naming) = + tokio::join!(level_name_future, image_level_name_future); if let Some(refined_naming) = refined_naming { generated_naming.level_name = refined_naming.level_name; if refined_naming.ui_background_prompt.is_some() { @@ -3605,18 +3623,30 @@ async fn compile_puzzle_draft_with_uploaded_cover( generated_metadata.level_name = target_level.level_name.clone(); generated_metadata.ui_background_prompt = target_level.ui_background_prompt.clone(); let generated_level_name = target_level.level_name.clone(); - let persisted_upload = persisted_upload_result?; let mut updated_levels = build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src); - // 中文注释:直用上传图时同样只补 UI 背景;音频生成入口临时关闭。 - let (ui_prompt, ui_background) = generate_puzzle_initial_ui_background_required( + let persist_upload_future = persist_puzzle_generated_asset( + state, + owner_user_id.as_str(), + &compiled_session.session_id, + target_level.level_name.as_str(), + candidate_id.as_str(), + "uploaded-direct", + uploaded_downloaded_image.clone(), + current_utc_micros(), + ); + let ui_background_future = generate_puzzle_initial_ui_background_required( state, owner_user_id.as_str(), compiled_session.session_id.as_str(), &draft, &target_level, - ) - .await?; + ); + // 中文注释:直用上传图时并行完成上传图持久化与 UI 背景生成;音频生成入口临时关闭。 + let (persisted_upload_result, ui_background_result) = + tokio::join!(persist_upload_future, ui_background_future); + let persisted_upload = persisted_upload_result?; + let (ui_prompt, ui_background) = ui_background_result?; attach_puzzle_level_ui_background( &mut updated_levels, target_level.level_id.as_str(), @@ -4013,2262 +4043,17 @@ fn apply_generated_puzzle_ui_background_to_session_snapshot( session.updated_at = format_timestamp_micros(updated_at_micros); session } +>>>>>>> Stashed changes mod tags; -use tags::*; +use self::tags::*; -fn map_puzzle_generation_endpoint_error(error: AppError) -> AppError { - if error.code() == "UPSTREAM_ERROR" { - let body_text = error.body_text(); - return AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": PUZZLE_AGENT_API_BASE_PROVIDER, - "message": format!("拼图图片生成失败:{body_text}"), - })); - } +mod generation; +mod vector_engine; - error -} - -async fn generate_puzzle_image_candidates( - state: &AppState, - owner_user_id: &str, - session_id: &str, - level_name: &str, - prompt: &str, - reference_image_src: Option<&str>, - use_reference_image_edit: bool, - image_model: Option<&str>, - candidate_count: u32, - candidate_start_index: usize, -) -> Result, AppError> { - let total_started_at = Instant::now(); - let count = candidate_count.clamp(1, 1); - let resolved_model = resolve_puzzle_image_model(image_model); - let http_client = build_puzzle_image_http_client(state, resolved_model)?; - let has_reference_image = has_puzzle_reference_image(reference_image_src); - let should_use_reference_image_edit = - should_use_puzzle_reference_image_edit(reference_image_src, use_reference_image_edit); - let actual_prompt = build_puzzle_vector_engine_generation_prompt( - build_puzzle_image_prompt(level_name, prompt).as_str(), - should_use_reference_image_edit, - ); - tracing::info!( - provider = resolved_model.provider_name(), - image_model = resolved_model.request_model_name(), - session_id, - level_name, - prompt_chars = prompt.chars().count(), - actual_prompt_chars = actual_prompt.chars().count(), - has_reference_image, - use_reference_image_edit = should_use_reference_image_edit, - "拼图图片生成请求已准备" - ); - let reference_image_started_at = Instant::now(); - let reference_image = match reference_image_src - .map(str::trim) - .filter(|value| !value.is_empty()) - .filter(|_| should_use_reference_image_edit) - { - Some(source) => { - let resolved = - resolve_puzzle_reference_image_as_data_url(state, &http_client, source).await?; - tracing::info!( - provider = resolved_model.provider_name(), - image_model = resolved_model.request_model_name(), - session_id, - level_name, - reference_mime = %resolved.mime_type, - reference_bytes = resolved.bytes_len, - elapsed_ms = reference_image_started_at.elapsed().as_millis() as u64, - "拼图参考图解析完成" - ); - Some(resolved) - } - None => None, - }; - if !should_use_reference_image_edit { - tracing::info!( - provider = resolved_model.provider_name(), - image_model = resolved_model.request_model_name(), - session_id, - level_name, - has_reference_image, - use_reference_image_edit = should_use_reference_image_edit, - elapsed_ms = reference_image_started_at.elapsed().as_millis() as u64, - "拼图参考图解析跳过" - ); - } - // 中文注释:SpacetimeDB reducer 不能做外部 I/O,参考图读取与外部生图都必须停留在 api-server。 - // 中文注释:拼图作品资产统一按 1:1 正方形生成,前端运行时也按正方形棋盘切块承载。 - let settings = require_puzzle_vector_engine_settings(state)?; - let vector_engine_started_at = Instant::now(); - let generated = if should_use_reference_image_edit { - let reference_image = reference_image.as_ref().ok_or_else(|| { - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "puzzle", - "field": "referenceImageSrc", - "message": "AI 重绘需要提供参考图。", - })) - })?; - create_puzzle_vector_engine_image_edit( - &http_client, - &settings, - actual_prompt.as_str(), - PUZZLE_DEFAULT_NEGATIVE_PROMPT, - PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, - count, - reference_image, - ) - .await - } else { - create_puzzle_vector_engine_image_generation( - &http_client, - &settings, - resolved_model, - actual_prompt.as_str(), - PUZZLE_DEFAULT_NEGATIVE_PROMPT, - PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, - count, - ) - .await - } - .map_err(map_puzzle_generation_endpoint_error)?; - tracing::info!( - provider = resolved_model.provider_name(), - image_model = resolved_model.request_model_name(), - session_id, - level_name, - generated_image_count = generated.images.len(), - elapsed_ms = vector_engine_started_at.elapsed().as_millis() as u64, - "拼图 VectorEngine 生图与下载完成" - ); - let mut items = Vec::with_capacity(generated.images.len()); - - for (index, image) in generated.images.into_iter().enumerate() { - let candidate_id = format!( - "{session_id}-candidate-{}", - candidate_start_index + index + 1 - ); - let downloaded_image = image.clone(); - let persist_started_at = Instant::now(); - let asset = persist_puzzle_generated_asset( - state, - owner_user_id, - session_id, - level_name, - candidate_id.as_str(), - generated.task_id.as_str(), - image, - current_utc_micros(), - ) - .await - .map_err(map_puzzle_generation_endpoint_error)?; - tracing::info!( - provider = resolved_model.provider_name(), - image_model = resolved_model.request_model_name(), - session_id, - level_name, - candidate_id = %candidate_id, - image_bytes = downloaded_image.bytes.len(), - image_mime = %downloaded_image.mime_type, - elapsed_ms = persist_started_at.elapsed().as_millis() as u64, - "拼图生成图片已写入 OSS 与资产索引" - ); - items.push(GeneratedPuzzleImageCandidate { - record: PuzzleGeneratedImageCandidateRecord { - candidate_id, - image_src: asset.image_src, - asset_id: asset.asset_id, - prompt: prompt.to_string(), - actual_prompt: Some(actual_prompt.clone()), - source_type: resolved_model.candidate_source_type().to_string(), - // 单图生成结果总是直接成为当前正式图。 - selected: index == 0, - }, - downloaded_image, - }); - } - - tracing::info!( - provider = resolved_model.provider_name(), - image_model = resolved_model.request_model_name(), - session_id, - level_name, - candidate_count = items.len(), - has_reference_image, - elapsed_ms = total_started_at.elapsed().as_millis() as u64, - "拼图图片候选生成完成" - ); - Ok(items) -} - -async fn generate_puzzle_ui_background_image( - state: &AppState, - owner_user_id: &str, - session_id: &str, - level_name: &str, - prompt: &str, -) -> Result { - let settings = require_openai_image_settings(state)?; - let http_client = build_openai_image_http_client(&settings)?; - let generated = create_openai_image_generation( - &http_client, - &settings, - build_puzzle_ui_background_generation_prompt(level_name, prompt).as_str(), - Some("文字、水印、按钮文字、数字、教程浮层、拼图碎片、完整拼图图像、拼图槽、棋盘、拼图区边框、物品槽、HUD、角色手指"), - "9:16", - 1, - &[], - "拼图 UI 背景图生成失败", - ) - .await?; - let image = generated.images.into_iter().next().ok_or_else(|| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": "拼图 UI 背景图生成失败:未返回图片", - })) - })?; - persist_puzzle_ui_background_image( - state, - owner_user_id, - session_id, - level_name, - generated.task_id.as_str(), - image, - ) - .await -} +use self::generation::*; +use self::vector_engine::*; #[cfg(test)] -fn build_puzzle_ui_background_request_prompt_for_test(level_name: &str, prompt: &str) -> String { - build_puzzle_ui_background_generation_prompt(level_name, prompt) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn puzzle_generated_image_size_is_square_1_1() { - assert_eq!(PUZZLE_GENERATED_IMAGE_SIZE, "1024*1024"); - assert_eq!(PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, "1024x1024"); - } - - #[test] - fn puzzle_vector_engine_request_uses_gpt_image_2_all_and_reference_images() { - let body = build_puzzle_vector_engine_image_request_body( - PuzzleImageModel::Gemini31FlashPreview, - "一只猫在雨夜灯牌下回头。", - PUZZLE_DEFAULT_NEGATIVE_PROMPT, - PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, - 4, - ); - - assert_eq!(body["model"], VECTOR_ENGINE_GPT_IMAGE_2_MODEL); - assert_eq!(body["size"], PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE); - assert_eq!(body["n"], 1); - assert!(body.get("official_fallback").is_none()); - assert!(body.get("image").is_none()); - assert!( - body["prompt"] - .as_str() - .unwrap_or_default() - .contains("文字水印") - ); - } - - #[test] - fn puzzle_vector_engine_edit_url_uses_images_edits_endpoint() { - let settings = PuzzleVectorEngineSettings { - base_url: "https://vector.example/v1".to_string(), - api_key: "test-key".to_string(), - }; - - assert_eq!( - puzzle_vector_engine_images_edit_url(&settings), - "https://vector.example/v1/images/edits" - ); - } - - #[test] - fn puzzle_vector_engine_edit_response_decodes_b64_image() { - let images = puzzle_images_from_base64( - "edit-1".to_string(), - vec![BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest")], - 1, - ); - - assert_eq!(images.images.len(), 1); - assert_eq!(images.images[0].mime_type, "image/png"); - assert_eq!(images.images[0].extension, "png"); - } - - #[test] - fn puzzle_vector_engine_prompt_strongly_uses_reference_image() { - let prompt = build_puzzle_vector_engine_generation_prompt("请生成雨夜猫街。", true); - - assert!(prompt.contains("参考图作为第一优先级")); - assert!(prompt.contains("严格保留参考图的主要主体、构图关系、视角、姿态、配色和光影氛围")); - assert!(prompt.contains("请生成雨夜猫街。")); - } - - #[test] - fn puzzle_vector_engine_prompt_keeps_text_only_prompt_unchanged() { - let prompt = build_puzzle_vector_engine_generation_prompt("请生成雨夜猫街。", false); - - assert_eq!(prompt, "请生成雨夜猫街。"); - } - - #[test] - fn puzzle_reference_image_edit_requires_ai_redraw() { - assert!(!should_use_puzzle_reference_image_edit(None, true)); - assert!(!should_use_puzzle_reference_image_edit( - Some("data:image/png;base64,abcd"), - false - )); - assert!(should_use_puzzle_reference_image_edit( - Some("data:image/png;base64,abcd"), - true - )); - } - - #[test] - fn puzzle_reference_image_sources_are_deduped_and_limited() { - let sources = collect_puzzle_reference_image_sources( - Some("data:image/png;base64,a"), - &[ - "data:image/png;base64,a".to_string(), - "data:image/png;base64,b".to_string(), - "data:image/png;base64,c".to_string(), - "data:image/png;base64,d".to_string(), - "data:image/png;base64,e".to_string(), - "data:image/png;base64,f".to_string(), - ], - ); - - assert_eq!(sources.len(), 5); - assert_eq!(sources[0], "data:image/png;base64,a"); - assert_eq!(sources[1], "data:image/png;base64,b"); - assert!(!sources.contains(&"data:image/png;base64,f".to_string())); - } - - #[test] - fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() { - let error = map_puzzle_vector_engine_request_error( - "创建拼图 VectorEngine 图片生成任务失败:operation timed out".to_string(), - ); - - let response = error.into_response(); - assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT); - } - - #[test] - fn puzzle_vector_engine_reqwest_error_maps_to_bad_gateway() { - let error = match reqwest::Client::new().get("http://[::1").build() { - Ok(_) => panic!("invalid url should fail request build"), - Err(error) => error, - }; - let app_error = map_puzzle_vector_engine_reqwest_error( - "创建拼图 VectorEngine 图片编辑任务失败", - "https://api.vectorengine.ai/v1/images/edits", - error, - ); - - let response = app_error.into_response(); - assert_eq!(response.status(), StatusCode::BAD_GATEWAY); - } - - #[test] - fn puzzle_compile_error_preserves_vector_engine_unavailable_status() { - let error = map_puzzle_compile_error(SpacetimeClientError::Runtime( - "VECTOR_ENGINE_API_KEY 未配置".to_string(), - )); - - let response = error.into_response(); - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); - } - - #[tokio::test] - async fn puzzle_compile_error_normalizes_legacy_apimart_image_message() { - let error = map_puzzle_compile_error(SpacetimeClientError::Runtime( - "APIMart 图片生成密钥未配置".to_string(), - )); - - let response = error.into_response(); - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); - let body = response.into_body(); - let bytes = axum::body::to_bytes(body, usize::MAX) - .await - .expect("body bytes should read"); - let payload: Value = - serde_json::from_slice(&bytes).expect("error response should be valid json"); - assert_eq!( - payload["error"]["details"]["provider"], - Value::String(VECTOR_ENGINE_PROVIDER.to_string()) - ); - assert_eq!( - payload["error"]["details"]["message"], - Value::String("VectorEngine 图片生成密钥未配置".to_string()) - ); - } - - #[test] - fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() { - let levels_json = serde_json::to_string(&vec![json!({ - "level_id": "puzzle-level-1", - "level_name": "雨夜猫街", - "picture_description": "一只猫在雨夜灯牌下回头。", - "candidates": [], - "selected_candidate_id": null, - "cover_image_src": null, - "cover_asset_id": null, - "generation_status": "idle", - })]) - .expect("levels json"); - let payload = ExecutePuzzleAgentActionRequest { - action: "generate_puzzle_images".to_string(), - prompt_text: None, - reference_image_src: None, - reference_image_srcs: Vec::new(), - image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()), - ai_redraw: None, - candidate_count: Some(1), - candidate_id: None, - level_id: Some("puzzle-level-1".to_string()), - work_title: Some("暖灯猫街作品".to_string()), - work_description: Some("一套雨夜猫街主题拼图。".to_string()), - picture_description: None, - level_name: None, - summary: Some("当前关卡画面。".to_string()), - theme_tags: Some(vec!["猫咪".to_string(), "雨夜".to_string()]), - levels_json: Some(levels_json.clone()), - }; - - let session = build_puzzle_session_snapshot_from_action_payload( - "puzzle-session-1", - &payload, - Some(levels_json.as_str()), - 1_713_686_401_234_567, - ) - .expect("fallback session"); - - let draft = session.draft.expect("draft"); - assert_eq!(session.stage, "ready_to_publish"); - assert_eq!(draft.work_title, "暖灯猫街作品"); - assert_eq!(draft.theme_tags, vec!["猫咪", "雨夜"]); - assert_eq!(draft.levels[0].level_id, "puzzle-level-1"); - assert_eq!( - draft.levels[0].picture_description, - "一只猫在雨夜灯牌下回头。" - ); - } - - #[test] - fn puzzle_first_level_name_parser_accepts_json_and_normalizes_text() { - assert_eq!( - parse_puzzle_first_level_name_from_text(r#"{"levelName":"雨夜猫街"}"#), - Some("雨夜猫街".to_string()) - ); - assert_eq!( - parse_puzzle_first_level_name_from_text("1. 《暖灯猫街》"), - Some("暖灯猫街".to_string()) - ); - assert_eq!( - parse_puzzle_first_level_name_from_text(r#"{"levelName":"雨夜猫街画面"}"#), - Some("雨夜猫街".to_string()) - ); - assert_eq!( - parse_puzzle_first_level_name_from_text(r#"{"levelNam"#), - None - ); - } - - #[test] - fn puzzle_level_naming_parser_accepts_metadata_and_ui_background_prompt() { - let naming = parse_puzzle_level_naming_from_text( - r#"{"levelName":"雨夜猫街","workDescription":"在湿润灯牌与猫影之间完成一套雨夜街角拼图。","workTags":["雨夜","猫咪","灯牌","街角","暖色","插画"],"uiBackgroundPrompt":"雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次"}"#, - ) - .expect("naming should parse"); - - assert_eq!(naming.level_name, "雨夜猫街"); - assert_eq!( - naming.work_description.as_deref(), - Some("在湿润灯牌与猫影之间完成一套雨夜街角拼图") - ); - assert_eq!(naming.work_tags.len(), module_puzzle::PUZZLE_MAX_TAG_COUNT); - assert!(naming.work_tags.contains(&"雨夜".to_string())); - assert!(naming.work_tags.contains(&"猫咪".to_string())); - assert!(naming.work_tags.contains(&"灯牌".to_string())); - assert_eq!( - naming.ui_background_prompt.as_deref(), - Some("雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次") - ); - } - - #[test] - fn puzzle_level_naming_parser_filters_forbidden_ui_prompt_words() { - let naming = parse_puzzle_level_naming_from_text( - r#"{"levelName":"雨夜猫街","uiBackgroundPrompt":"雨夜老街背景,中央不要出现拼图槽、棋盘、HUD、按钮、文字或水印,保留暖色灯光"}"#, - ) - .expect("naming should parse"); - let prompt = naming - .ui_background_prompt - .as_deref() - .expect("prompt should parse"); - - assert!(!prompt.contains("拼图槽")); - assert!(!prompt.contains("棋盘")); - assert!(!prompt.contains("HUD")); - assert!(!prompt.contains("按钮")); - assert!(!prompt.contains("文字")); - assert!(!prompt.contains("水印")); - } - - #[test] - fn puzzle_first_level_name_fallback_uses_picture_keywords() { - assert_eq!( - build_fallback_puzzle_first_level_name("一只猫在雨夜灯牌下回头。"), - "雨夜猫街" - ); - assert_eq!( - build_fallback_puzzle_first_level_name("看不出关键词的抽象色块。"), - "奇境初见" - ); - } - - #[test] - fn puzzle_level_name_image_data_url_downsizes_generated_image() { - let image = image::DynamicImage::ImageRgb8(image::RgbImage::new(4, 4)); - let mut cursor = std::io::Cursor::new(Vec::new()); - image - .write_to(&mut cursor, ImageFormat::Png) - .expect("test image should encode"); - let downloaded = PuzzleDownloadedImage { - extension: "png".to_string(), - mime_type: "image/png".to_string(), - bytes: cursor.into_inner(), - }; - - let data_url = build_puzzle_level_name_image_data_url(&downloaded) - .expect("data url should be generated"); - - assert!(data_url.starts_with("data:image/png;base64,")); - assert!(data_url.len() > "data:image/png;base64,".len()); - } - - #[test] - fn puzzle_first_level_name_snapshot_defaults_work_title() { - let levels_json = serde_json::to_string(&vec![json!({ - "level_id": "puzzle-level-1", - "level_name": "猫画面", - "picture_description": "一只猫在雨夜灯牌下回头。", - "candidates": [], - "selected_candidate_id": null, - "cover_image_src": null, - "cover_asset_id": null, - "generation_status": "idle", - })]) - .expect("levels json"); - let payload = ExecutePuzzleAgentActionRequest { - action: "generate_puzzle_images".to_string(), - prompt_text: None, - reference_image_src: None, - reference_image_srcs: Vec::new(), - image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()), - ai_redraw: None, - candidate_count: Some(1), - candidate_id: None, - level_id: Some("puzzle-level-1".to_string()), - work_title: Some("猫画面".to_string()), - work_description: None, - picture_description: None, - level_name: None, - summary: None, - theme_tags: Some(vec![]), - levels_json: Some(levels_json.clone()), - }; - let session = build_puzzle_session_snapshot_from_action_payload( - "puzzle-session-1", - &payload, - Some(levels_json.as_str()), - 1_713_686_401_234_567, - ) - .expect("fallback session"); - - let renamed = apply_generated_puzzle_first_level_name_to_session_snapshot( - session, - "puzzle-level-1", - "雨夜猫街", - "猫画面", - 1_713_686_401_234_568, - ); - let draft = renamed.draft.expect("draft"); - assert_eq!(draft.level_name, "雨夜猫街"); - assert_eq!(draft.work_title, "雨夜猫街"); - assert_eq!(draft.levels[0].level_name, "雨夜猫街"); - } - - #[test] - fn puzzle_initial_metadata_defaults_empty_work_description_and_tags() { - let mut session = PuzzleAgentSessionRecord { - session_id: "puzzle-session-1".to_string(), - seed_text: "画面描述:一只猫在雨夜灯牌下回头。".to_string(), - current_turn: 1, - progress_percent: 94, - stage: "ready_to_publish".to_string(), - anchor_pack: test_puzzle_anchor_pack_record(), - draft: Some(test_puzzle_draft_record()), - messages: Vec::new(), - last_assistant_reply: None, - published_profile_id: None, - suggested_actions: Vec::new(), - result_preview: None, - updated_at: "2024-01-01T00:00:00Z".to_string(), - }; - { - let draft = session.draft.as_mut().expect("draft"); - draft.work_title = "猫画面".to_string(); - draft.work_description = String::new(); - draft.summary = String::new(); - draft.theme_tags = Vec::new(); - } - let metadata = PuzzleLevelNaming { - level_name: "雨夜猫街".to_string(), - work_description: Some("在湿润灯牌与猫影之间完成一套雨夜街角拼图".to_string()), - work_tags: vec![ - "插画".to_string(), - "灯牌".to_string(), - "街角".to_string(), - "猫咪".to_string(), - "暖色".to_string(), - "雨夜".to_string(), - ], - ui_background_prompt: None, - }; - - let session = apply_generated_puzzle_initial_metadata_to_session_snapshot( - session, - &metadata, - "猫画面", - 1_713_686_401_234_568, - ); - - let draft = session.draft.expect("draft"); - assert_eq!(draft.work_title, "雨夜猫街"); - assert_eq!( - draft.work_description, - "在湿润灯牌与猫影之间完成一套雨夜街角拼图" - ); - assert_eq!(draft.summary, draft.work_description); - assert_eq!(draft.theme_tags, metadata.work_tags); - } - - #[test] - fn puzzle_level_audio_asset_roundtrips_between_response_and_module_json() { - let level = PuzzleDraftLevelResponse { - level_id: "puzzle-level-1".to_string(), - level_name: "雨夜猫街".to_string(), - picture_description: "一只猫在雨夜灯牌下回头。".to_string(), - picture_reference: None, - ui_background_prompt: None, - ui_background_image_src: None, - ui_background_image_object_key: None, - background_music: Some(CreationAudioAsset { - task_id: "suno-task-1".to_string(), - provider: "vector-engine-suno".to_string(), - asset_object_id: Some("assetobj_1".to_string()), - asset_kind: Some("puzzle_background_music".to_string()), - audio_src: "/generated-puzzle-assets/audio.mp3".to_string(), - prompt: Some("轻快拼图音乐".to_string()), - title: Some("雨夜猫街背景音乐".to_string()), - updated_at: Some("2026-05-11T00:00:00Z".to_string()), - }), - candidates: vec![], - selected_candidate_id: None, - cover_image_src: None, - cover_asset_id: None, - generation_status: "ready".to_string(), - }; - let request_context = RequestContext::new( - "test-request".to_string(), - "PUT /api/runtime/puzzle/works/test".to_string(), - Duration::ZERO, - false, - ); - - let levels_json = serialize_puzzle_levels_response(&request_context, &[level]) - .expect("levels should serialize"); - let payload: Value = serde_json::from_str(&levels_json).expect("levels json should parse"); - assert_eq!( - payload[0]["background_music"]["audio_src"], - Value::String("/generated-puzzle-assets/audio.mp3".to_string()) - ); - assert!(payload[0]["background_music"].get("audioSrc").is_none()); - - let records = parse_puzzle_level_records_from_module_json(&levels_json) - .expect("levels should map back into records"); - let music = records[0] - .background_music - .as_ref() - .expect("background music should exist"); - assert_eq!(music.audio_src, "/generated-puzzle-assets/audio.mp3"); - assert_eq!(music.asset_kind.as_deref(), Some("puzzle_background_music")); - - let response = map_puzzle_draft_level_response(records[0].clone()); - assert_eq!( - response - .background_music - .as_ref() - .map(|asset| asset.audio_src.as_str()), - Some("/generated-puzzle-assets/audio.mp3") - ); - } - - #[test] - fn puzzle_ui_background_fields_roundtrip_between_response_and_module_json() { - let level = PuzzleDraftLevelResponse { - level_id: "puzzle-level-1".to_string(), - level_name: "雨夜猫街".to_string(), - picture_description: "一只猫在雨夜灯牌下回头。".to_string(), - picture_reference: None, - ui_background_prompt: Some("雨夜猫街竖屏拼图UI背景".to_string()), - ui_background_image_src: Some( - "/generated-puzzle-assets/session/ui/background.png".to_string(), - ), - ui_background_image_object_key: Some( - "generated-puzzle-assets/session/ui/background.png".to_string(), - ), - background_music: None, - candidates: vec![], - selected_candidate_id: None, - cover_image_src: Some("/generated-puzzle-assets/session/cover.png".to_string()), - cover_asset_id: Some("asset-1".to_string()), - generation_status: "ready".to_string(), - }; - let request_context = RequestContext::new( - "test-request".to_string(), - "PUT /api/runtime/puzzle/works/test".to_string(), - Duration::ZERO, - false, - ); - - let levels_json = serialize_puzzle_levels_response(&request_context, &[level]) - .expect("levels should serialize"); - let payload: Value = serde_json::from_str(&levels_json).expect("levels json should parse"); - assert_eq!( - payload[0]["ui_background_prompt"], - Value::String("雨夜猫街竖屏拼图UI背景".to_string()) - ); - assert!(payload[0].get("uiBackgroundPrompt").is_none()); - - let records = parse_puzzle_level_records_from_module_json(&levels_json) - .expect("levels should map back into records"); - assert_eq!( - records[0].ui_background_image_src.as_deref(), - Some("/generated-puzzle-assets/session/ui/background.png") - ); - - let response = map_puzzle_draft_level_response(records[0].clone()); - assert_eq!( - response.ui_background_image_object_key.as_deref(), - Some("generated-puzzle-assets/session/ui/background.png") - ); - } - - #[test] - fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() { - let state = AppState::new(crate::config::AppConfig::default()).expect("state should build"); - let level = PuzzleDraftLevelRecord { - level_id: "puzzle-level-1".to_string(), - level_name: "雨夜猫街".to_string(), - picture_description: "一只猫在雨夜灯牌下回头。".to_string(), - picture_reference: None, - ui_background_prompt: None, - ui_background_image_src: None, - ui_background_image_object_key: None, - background_music: None, - candidates: vec![PuzzleGeneratedImageCandidateRecord { - candidate_id: "candidate-1".to_string(), - image_src: "/generated-puzzle-assets/session/candidate-1.png".to_string(), - asset_id: "asset-1".to_string(), - prompt: "雨夜猫街".to_string(), - actual_prompt: None, - source_type: "generated".to_string(), - selected: true, - }], - selected_candidate_id: Some("candidate-1".to_string()), - cover_image_src: Some("/generated-puzzle-assets/session/cover.png".to_string()), - cover_asset_id: Some("asset-1".to_string()), - generation_status: "ready".to_string(), - }; - - let response = map_puzzle_work_summary_response( - &state, - PuzzleWorkProfileRecord { - work_id: "puzzle-work-1".to_string(), - profile_id: "puzzle-profile-1".to_string(), - owner_user_id: "user-1".to_string(), - source_session_id: Some("puzzle-session-1".to_string()), - 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()], - cover_image_src: None, - cover_asset_id: None, - publication_status: "draft".to_string(), - updated_at: "2026-05-08T00:00:00.000Z".to_string(), - published_at: None, - play_count: 0, - remix_count: 0, - like_count: 0, - recent_play_count_7d: 0, - point_incentive_total_half_points: 0, - point_incentive_claimed_points: 0, - publish_ready: false, - anchor_pack: test_puzzle_anchor_pack_record(), - levels: vec![level], - }, - ); - - assert_eq!(response.levels.len(), 1); - assert_eq!( - response.levels[0].cover_image_src.as_deref(), - Some("/generated-puzzle-assets/session/cover.png") - ); - assert_eq!( - response.levels[0].candidates[0].image_src, - "/generated-puzzle-assets/session/candidate-1.png" - ); - } - - #[test] - fn puzzle_ui_background_prompt_keeps_generated_slots_out_of_background() { - let prompt = - build_puzzle_ui_background_request_prompt_for_test("雨夜猫街", "雨夜猫街主题背景"); - - assert!(prompt.contains("9:16")); - assert!(prompt.contains("纯背景图")); - assert!(prompt.contains("不得出现拼图槽")); - assert!(prompt.contains("默认拼图槽")); - assert!(prompt.contains("文字")); - } - - #[test] - fn puzzle_initial_ui_background_prompt_prefers_ai_generated_prompt() { - let mut draft = test_puzzle_draft_record(); - draft.work_title = "模板作品名".to_string(); - draft.work_description = "模板作品描述".to_string(); - let mut target_level = draft.levels[0].clone(); - target_level.level_name = "雨夜猫街".to_string(); - let ai_prompt = - "雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次"; - target_level.ui_background_prompt = Some(ai_prompt.to_string()); - - let prompt = resolve_puzzle_initial_ui_background_prompt(&draft, &target_level); - - assert_eq!(prompt, ai_prompt); - assert!(!prompt.contains(PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER)); - } - - #[test] - fn puzzle_initial_ui_background_prompt_falls_back_to_context_template() { - let draft = test_puzzle_draft_record(); - let target_level = draft.levels[0].clone(); - - let prompt = resolve_puzzle_initial_ui_background_prompt(&draft, &target_level); - - assert!(prompt.contains("雨夜猫街")); - assert!(prompt.contains(PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER)); - } - - #[test] - fn puzzle_ui_background_initial_attach_updates_first_level_fields() { - let draft = test_puzzle_draft_record(); - let generated = GeneratedPuzzleUiBackgroundResponse { - image_src: "/generated-puzzle-assets/session/ui/background.png".to_string(), - object_key: "generated-puzzle-assets/session/ui/background.png".to_string(), - }; - let mut levels = draft.levels.clone(); - - attach_puzzle_level_ui_background( - &mut levels, - "puzzle-level-1", - "雨夜猫街移动端拼图UI背景".to_string(), - generated, - ); - - assert_eq!( - levels[0].ui_background_prompt.as_deref(), - Some("雨夜猫街移动端拼图UI背景") - ); - assert_eq!( - levels[0].ui_background_image_src.as_deref(), - Some("/generated-puzzle-assets/session/ui/background.png") - ); - assert_eq!( - levels[0].ui_background_image_object_key.as_deref(), - Some("generated-puzzle-assets/session/ui/background.png") - ); - } - - #[test] - fn puzzle_initial_draft_assets_must_include_ui_background() { - let mut draft = test_puzzle_draft_record(); - let missing_all = ensure_puzzle_initial_level_assets_ready(&draft.levels[0]) - .expect_err("缺少自动生成资产时不能把草稿标记为完成"); - assert_eq!(missing_all.status_code(), StatusCode::BAD_GATEWAY); - assert!(missing_all.body_text().contains("UI背景图")); - - draft.levels[0].ui_background_image_src = - Some("/generated-puzzle-assets/session/ui/background.png".to_string()); - ensure_puzzle_initial_level_assets_ready(&draft.levels[0]) - .expect("UI 背景存在时即可完成自动草稿资源检查"); - } - - fn test_puzzle_anchor_pack_record() -> PuzzleAnchorPackRecord { - let item = PuzzleAnchorItemRecord { - key: "visualSubject".to_string(), - label: "画面".to_string(), - value: "雨夜猫街".to_string(), - status: "confirmed".to_string(), - }; - - PuzzleAnchorPackRecord { - theme_promise: item.clone(), - visual_subject: item.clone(), - visual_mood: item.clone(), - composition_hooks: item.clone(), - tags_and_forbidden: item, - } - } - - fn test_puzzle_draft_record() -> PuzzleResultDraftRecord { - let anchor_pack = test_puzzle_anchor_pack_record(); - PuzzleResultDraftRecord { - work_title: "雨夜猫街".to_string(), - work_description: "一只猫在雨夜灯牌下回头。".to_string(), - level_name: "猫画面".to_string(), - summary: "一只猫在雨夜灯牌下回头。".to_string(), - theme_tags: vec![], - forbidden_directives: vec![], - creator_intent: None, - anchor_pack, - candidates: vec![], - selected_candidate_id: None, - cover_image_src: None, - cover_asset_id: None, - generation_status: "idle".to_string(), - levels: vec![PuzzleDraftLevelRecord { - level_id: "puzzle-level-1".to_string(), - level_name: "猫画面".to_string(), - picture_description: "一只猫在雨夜灯牌下回头。".to_string(), - picture_reference: None, - ui_background_prompt: None, - ui_background_image_src: None, - ui_background_image_object_key: None, - background_music: None, - candidates: vec![], - selected_candidate_id: None, - cover_image_src: None, - cover_asset_id: None, - generation_status: "idle".to_string(), - }], - form_draft: None, - } - } - - #[test] - fn puzzle_primary_level_update_preserves_reference_for_regeneration() { - let draft = test_puzzle_draft_record(); - let mut target_level = draft.levels[0].clone(); - target_level.level_name = "雨夜猫街".to_string(); - - let levels = build_puzzle_levels_with_primary_update( - &draft, - &target_level, - Some("data:image/png;base64,abcd"), - ); - - assert_eq!(levels[0].level_name, "雨夜猫街"); - assert_eq!( - levels[0].picture_reference.as_deref(), - Some("data:image/png;base64,abcd") - ); - } - - #[test] - fn puzzle_generated_fallback_snapshot_preserves_picture_reference() { - let anchor_pack = test_puzzle_anchor_pack_record(); - let session = PuzzleAgentSessionRecord { - session_id: "puzzle-session-1".to_string(), - seed_text: "雨夜猫街".to_string(), - current_turn: 1, - progress_percent: 0, - stage: "draft_ready".to_string(), - anchor_pack: anchor_pack.clone(), - draft: Some(test_puzzle_draft_record()), - messages: Vec::new(), - last_assistant_reply: None, - published_profile_id: None, - suggested_actions: Vec::new(), - result_preview: None, - updated_at: "2024-01-01T00:00:00Z".to_string(), - }; - let candidate = PuzzleGeneratedImageCandidateRecord { - candidate_id: "puzzle-session-1-candidate-1".to_string(), - image_src: "/generated-puzzle-assets/puzzle-session-1/1/cover.png".to_string(), - asset_id: "puzzle-cover-1".to_string(), - prompt: "雨夜猫街".to_string(), - actual_prompt: Some("雨夜猫街".to_string()), - source_type: "generated:gpt-image-2".to_string(), - selected: true, - }; - - let session = apply_generated_puzzle_candidates_to_session_snapshot( - session, - "puzzle-level-1", - vec![candidate], - Some("data:image/png;base64,abcd"), - 1_713_686_401_234_568, - ); - - let draft = session.draft.expect("draft"); - assert_eq!( - draft.levels[0].picture_reference.as_deref(), - Some("data:image/png;base64,abcd") - ); - } - - #[test] - fn freeze_boundary_sync_only_matches_freeze_invalid_operation() { - let invalid_operation = - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "spacetimedb", - "message": "操作不合法", - })); - let other_error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "spacetimedb", - "message": "泥点余额不足", - })); - - assert!(should_sync_puzzle_freeze_boundary(&invalid_operation, true)); - assert!(!should_sync_puzzle_freeze_boundary( - &invalid_operation, - false - )); - assert!(!should_sync_puzzle_freeze_boundary(&other_error, true)); - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum PuzzleImageModel { - GptImage2, - Gemini31FlashPreview, -} - -impl PuzzleImageModel { - fn provider_name(self) -> &'static str { - VECTOR_ENGINE_PROVIDER - } - - fn request_model_name(self) -> &'static str { - VECTOR_ENGINE_GPT_IMAGE_2_MODEL - } - - fn candidate_source_type(self) -> &'static str { - match self { - Self::GptImage2 => "generated:gpt-image-2", - Self::Gemini31FlashPreview => "generated:nanobanana2", - } - } -} - -struct PuzzleVectorEngineSettings { - base_url: String, - api_key: String, -} - -struct PuzzleGeneratedImages { - task_id: String, - images: Vec, -} - -struct PuzzleResolvedReferenceImage { - mime_type: String, - bytes_len: usize, - bytes: Vec, -} - -struct GeneratedPuzzleImageCandidate { - record: PuzzleGeneratedImageCandidateRecord, - downloaded_image: PuzzleDownloadedImage, -} - -impl GeneratedPuzzleImageCandidate { - fn into_record(self) -> PuzzleGeneratedImageCandidateRecord { - self.record - } -} - -trait GeneratedPuzzleImageCandidatesExt { - fn into_records(self) -> Vec; -} - -impl GeneratedPuzzleImageCandidatesExt for Vec { - fn into_records(self) -> Vec { - self.into_iter() - .map(GeneratedPuzzleImageCandidate::into_record) - .collect() - } -} - -#[derive(Clone)] -struct PuzzleDownloadedImage { - extension: String, - mime_type: String, - bytes: Vec, -} - -struct ParsedPuzzleImageDataUrl { - mime_type: String, - bytes: Vec, -} - -struct GeneratedPuzzleAssetResponse { - image_src: String, - asset_id: String, -} - -struct GeneratedPuzzleUiBackgroundResponse { - image_src: String, - object_key: String, -} - -fn resolve_puzzle_image_model(value: Option<&str>) -> PuzzleImageModel { - match value.map(str::trim).filter(|value| !value.is_empty()) { - Some(PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW) => { - tracing::warn!( - requested_model = PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW, - effective_model = VECTOR_ENGINE_GPT_IMAGE_2_MODEL, - "拼图 nanobanana2 历史选项已回落到 VectorEngine GPT-image-2-all" - ); - PuzzleImageModel::Gemini31FlashPreview - } - _ => PuzzleImageModel::GptImage2, - } -} - -fn require_puzzle_vector_engine_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_PROVIDER, - "message": "VectorEngine 图片生成地址未配置", - "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_PROVIDER, - "message": "VectorEngine 图片生成密钥未配置", - "reason": "VECTOR_ENGINE_API_KEY 未配置", - })) - })?; - - Ok(PuzzleVectorEngineSettings { - base_url: base_url.to_string(), - api_key: api_key.to_string(), - }) -} - -fn build_puzzle_image_http_client( - state: &AppState, - image_model: PuzzleImageModel, -) -> Result { - let provider = image_model.provider_name(); - let request_timeout_ms = state.config.vector_engine_image_request_timeout_ms; - - reqwest::Client::builder() - .timeout(Duration::from_millis(request_timeout_ms.max(1))) - // 中文注释:VectorEngine 的图片编辑接口是 multipart 请求;强制 HTTP/1.1 可避开部分网关对 HTTP/2 multipart 流的中断兼容问题。 - .http1_only() - .build() - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": provider, - "message": format!("构造拼图图片生成 HTTP 客户端失败:{error}"), - })) - }) -} - -fn to_puzzle_generated_image_candidate( - candidate: &PuzzleGeneratedImageCandidateRecord, -) -> PuzzleGeneratedImageCandidate { - // SpacetimeDB 模块反序列化的是 module-puzzle 的持久化结构,必须保留 snake_case 字段名;HTTP 响应层再单独映射为 camelCase。 - PuzzleGeneratedImageCandidate { - candidate_id: candidate.candidate_id.clone(), - image_src: candidate.image_src.clone(), - asset_id: candidate.asset_id.clone(), - prompt: candidate.prompt.clone(), - actual_prompt: candidate.actual_prompt.clone(), - source_type: candidate.source_type.clone(), - selected: candidate.selected, - } -} - -async fn create_puzzle_vector_engine_image_generation( - http_client: &reqwest::Client, - settings: &PuzzleVectorEngineSettings, - image_model: PuzzleImageModel, - prompt: &str, - negative_prompt: &str, - size: &str, - candidate_count: u32, -) -> Result { - let request_body = build_puzzle_vector_engine_image_request_body( - image_model, - prompt, - negative_prompt, - size, - candidate_count, - ); - let request_url = puzzle_vector_engine_images_generation_url(settings); - let request_started_at = Instant::now(); - let response = http_client - .post(request_url.as_str()) - .header( - reqwest::header::AUTHORIZATION, - format!("Bearer {}", settings.api_key), - ) - .header(reqwest::header::ACCEPT, "application/json") - .header(reqwest::header::CONTENT_TYPE, "application/json") - .json(&request_body) - .send() - .await - .map_err(|error| { - map_puzzle_vector_engine_request_error(format!( - "创建拼图 VectorEngine 图片生成任务失败:{error}" - )) - })?; - let status = response.status(); - let upstream_elapsed_ms = request_started_at.elapsed().as_millis() as u64; - tracing::info!( - provider = VECTOR_ENGINE_PROVIDER, - image_model = image_model.request_model_name(), - endpoint = %request_url, - status = status.as_u16(), - prompt_chars = prompt.chars().count(), - size, - has_reference_image = false, - elapsed_ms = upstream_elapsed_ms, - "拼图 VectorEngine 图片生成 HTTP 返回" - ); - let response_text = response.text().await.map_err(|error| { - map_puzzle_vector_engine_request_error(format!( - "读取拼图 VectorEngine 图片生成响应失败:{error}" - )) - })?; - if !status.is_success() { - return Err(map_puzzle_vector_engine_upstream_error( - status, - response_text.as_str(), - "创建拼图 VectorEngine 图片生成任务失败", - )); - } - - let payload = parse_puzzle_json_payload( - response_text.as_str(), - "解析拼图 VectorEngine 图片生成响应失败", - )?; - let image_urls = extract_puzzle_image_urls(&payload); - if !image_urls.is_empty() { - let download_started_at = Instant::now(); - let images = download_puzzle_images_from_urls( - http_client, - format!("vector-engine-{}", current_utc_micros()), - image_urls, - candidate_count, - ) - .await?; - tracing::info!( - provider = VECTOR_ENGINE_PROVIDER, - image_model = image_model.request_model_name(), - image_count = images.images.len(), - elapsed_ms = download_started_at.elapsed().as_millis() as u64, - "拼图 VectorEngine 图片下载完成" - ); - return Ok(images); - } - - Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": "拼图 VectorEngine 图片生成未返回图片地址", - })), - ) -} - -async fn create_puzzle_vector_engine_image_edit( - http_client: &reqwest::Client, - settings: &PuzzleVectorEngineSettings, - prompt: &str, - negative_prompt: &str, - size: &str, - candidate_count: u32, - reference_image: &PuzzleResolvedReferenceImage, -) -> Result { - let request_url = puzzle_vector_engine_images_edit_url(settings); - let task_id = format!("vector-engine-edit-{}", current_utc_micros()); - let file_name = format!( - "puzzle-reference.{}", - puzzle_mime_to_extension(reference_image.mime_type.as_str()) - ); - let image_part = reqwest::multipart::Part::bytes(reference_image.bytes.clone()) - .file_name(file_name) - .mime_str(reference_image.mime_type.as_str()) - .map_err(|error| { - map_puzzle_vector_engine_request_error(format!( - "构造拼图 VectorEngine 图片编辑参考图失败:{error}" - )) - })?; - let form = reqwest::multipart::Form::new() - .part("image", image_part) - .text("model", PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL.to_string()) - .text( - "prompt", - build_puzzle_vector_engine_prompt(prompt, negative_prompt), - ) - .text("n", candidate_count.clamp(1, 1).to_string()) - .text("size", size.to_string()); - let request_started_at = Instant::now(); - let response = http_client - .post(request_url.as_str()) - .header( - reqwest::header::AUTHORIZATION, - format!("Bearer {}", settings.api_key), - ) - .header(reqwest::header::ACCEPT, "application/json") - .multipart(form) - .send() - .await - .map_err(|error| { - map_puzzle_vector_engine_reqwest_error( - "创建拼图 VectorEngine 图片编辑任务失败", - &request_url, - error, - ) - })?; - let status = response.status(); - tracing::info!( - provider = VECTOR_ENGINE_PROVIDER, - image_model = PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL, - endpoint = %request_url, - status = status.as_u16(), - prompt_chars = prompt.chars().count(), - size, - reference_mime = %reference_image.mime_type, - reference_bytes = reference_image.bytes_len, - elapsed_ms = request_started_at.elapsed().as_millis() as u64, - "拼图 VectorEngine 图片编辑 HTTP 返回" - ); - let response_text = response.text().await.map_err(|error| { - map_puzzle_vector_engine_request_error(format!( - "读取拼图 VectorEngine 图片编辑响应失败:{error}" - )) - })?; - if !status.is_success() { - return Err(map_puzzle_vector_engine_upstream_error( - status, - response_text.as_str(), - "创建拼图 VectorEngine 图片编辑任务失败", - )); - } - - let payload = parse_puzzle_json_payload( - response_text.as_str(), - "解析拼图 VectorEngine 图片编辑响应失败", - )?; - let image_urls = extract_puzzle_image_urls(&payload); - if !image_urls.is_empty() { - return download_puzzle_images_from_urls(http_client, task_id, image_urls, candidate_count) - .await; - } - let b64_images = extract_puzzle_b64_images(&payload); - if !b64_images.is_empty() { - return Ok(puzzle_images_from_base64( - task_id, - b64_images, - candidate_count, - )); - } - - Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": "拼图 VectorEngine 图片编辑未返回图片", - })), - ) -} - -fn build_puzzle_vector_engine_image_request_body( - image_model: PuzzleImageModel, - prompt: &str, - negative_prompt: &str, - size: &str, - candidate_count: u32, -) -> Value { - Value::Object(Map::from_iter([ - ( - "model".to_string(), - Value::String(image_model.request_model_name().to_string()), - ), - ( - "prompt".to_string(), - Value::String(build_puzzle_vector_engine_prompt(prompt, negative_prompt)), - ), - ("n".to_string(), json!(candidate_count.clamp(1, 1))), - ("size".to_string(), Value::String(size.to_string())), - ])) -} - -fn build_puzzle_vector_engine_generation_prompt(prompt: &str, has_reference_image: bool) -> String { - let prompt = prompt.trim(); - if !has_reference_image { - return prompt.to_string(); - } - - format!( - concat!( - "请以随请求提供的参考图作为第一优先级生成依据,严格保留参考图的主要主体、构图关系、视角、姿态、配色和光影氛围;", - "允许按下面文字要求做风格化和细节增强,但不要改成与参考图无关的新画面。\n", - "{prompt}" - ), - prompt = prompt, - ) -} - -fn has_puzzle_reference_image(reference_image_src: Option<&str>) -> bool { - reference_image_src - .map(str::trim) - .map(|value| !value.is_empty()) - .unwrap_or(false) -} - -fn collect_puzzle_reference_image_sources( - legacy_reference_image_src: Option<&str>, - reference_image_srcs: &[String], -) -> Vec { - let mut sources = Vec::new(); - for source in legacy_reference_image_src - .into_iter() - .chain(reference_image_srcs.iter().map(String::as_str)) - { - 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() >= PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT { - break; - } - } - sources -} - -fn has_puzzle_reference_images( - legacy_reference_image_src: Option<&str>, - reference_image_srcs: &[String], -) -> bool { - !collect_puzzle_reference_image_sources(legacy_reference_image_src, reference_image_srcs) - .is_empty() -} - -fn should_use_puzzle_reference_image_edit( - reference_image_src: Option<&str>, - use_reference_image_edit: bool, -) -> bool { - use_reference_image_edit && has_puzzle_reference_image(reference_image_src) -} - -fn build_puzzle_vector_engine_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}") -} - -fn puzzle_vector_engine_images_generation_url(settings: &PuzzleVectorEngineSettings) -> String { - if settings.base_url.ends_with("/v1") { - format!("{}/images/generations", settings.base_url) - } else { - format!("{}/v1/images/generations", settings.base_url) - } -} - -fn puzzle_vector_engine_images_edit_url(settings: &PuzzleVectorEngineSettings) -> String { - if settings.base_url.ends_with("/v1") { - format!("{}/images/edits", settings.base_url) - } else { - format!("{}/v1/images/edits", settings.base_url) - } -} - -async fn download_puzzle_images_from_urls( - http_client: &reqwest::Client, - task_id: String, - image_urls: Vec, - candidate_count: u32, -) -> Result { - let mut images = Vec::with_capacity(candidate_count.clamp(1, 1) as usize); - for image_url in image_urls - .into_iter() - .take(candidate_count.clamp(1, 1) as usize) - { - images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?); - } - Ok(PuzzleGeneratedImages { task_id, images }) -} - -async fn resolve_puzzle_reference_image_as_data_url( - state: &AppState, - http_client: &reqwest::Client, - source: &str, -) -> Result { - let trimmed = source.trim(); - if trimmed.is_empty() { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "puzzle", - "field": "referenceImageSrc", - "message": "参考图不能为空。", - })), - ); - } - - if let Some(parsed) = parse_puzzle_image_data_url(trimmed) { - let bytes_len = parsed.bytes.len(); - if bytes_len > PUZZLE_REFERENCE_IMAGE_MAX_BYTES { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "puzzle", - "field": "referenceImageSrc", - "message": "参考图过大,请压缩后重试。", - "maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES, - "actualBytes": bytes_len, - })), - ); - } - return Ok(PuzzleResolvedReferenceImage { - mime_type: parsed.mime_type, - bytes_len, - bytes: parsed.bytes, - }); - } - - if !trimmed.starts_with('/') { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "puzzle", - "field": "referenceImageSrc", - "message": "参考图必须是 Data URL 或 /generated-* 旧路径。", - })), - ); - } - - let object_key = trimmed.trim_start_matches('/'); - if LegacyAssetPrefix::from_object_key(object_key).is_none() { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "puzzle", - "field": "referenceImageSrc", - "message": "参考图当前只支持 /generated-* 旧路径。", - })), - ); - } - - 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(OssSignedGetObjectUrlRequest { - object_key: object_key.to_string(), - expire_seconds: Some(60), - }) - .map_err(map_puzzle_asset_oss_error)?; - let response = http_client - .get(signed.signed_url) - .send() - .await - .map_err(|error| map_puzzle_image_request_error(format!("读取拼图参考图失败:{error}")))?; - let status = response.status(); - let content_type = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or("image/png") - .to_string(); - let body = response.bytes().await.map_err(|error| { - map_puzzle_image_request_error(format!("读取拼图参考图内容失败:{error}")) - })?; - if !status.is_success() { - return Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "aliyun-oss", - "message": format!("读取参考图失败,状态码:{status}"), - "objectKey": object_key, - })), - ); - } - if body.is_empty() { - return Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "aliyun-oss", - "message": "读取参考图失败:对象内容为空", - "objectKey": object_key, - })), - ); - } - - let mime_type = normalize_puzzle_downloaded_image_mime_type(content_type.as_str()); - let bytes_len = body.len(); - Ok(PuzzleResolvedReferenceImage { - mime_type, - bytes_len, - bytes: body.to_vec(), - }) -} - -async fn download_puzzle_remote_image( - http_client: &reqwest::Client, - image_url: &str, -) -> Result { - let response = http_client.get(image_url).send().await.map_err(|error| { - map_puzzle_image_request_error(format!("下载拼图正式图片失败:{error}")) - })?; - let status = response.status(); - let content_type = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or("image/jpeg") - .to_string(); - let bytes = response.bytes().await.map_err(|error| { - map_puzzle_image_request_error(format!("读取拼图正式图片内容失败:{error}")) - })?; - if !status.is_success() { - return Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "puzzle-image", - "message": "下载拼图正式图片失败", - "status": status.as_u16(), - })), - ); - } - let mime_type = normalize_puzzle_downloaded_image_mime_type(content_type.as_str()); - Ok(PuzzleDownloadedImage { - extension: puzzle_mime_to_extension(mime_type.as_str()).to_string(), - mime_type, - bytes: bytes.to_vec(), - }) -} - -async fn persist_puzzle_generated_asset( - state: &AppState, - owner_user_id: &str, - session_id: &str, - level_name: &str, - candidate_id: &str, - task_id: &str, - image: PuzzleDownloadedImage, - generated_at_micros: i64, -) -> Result { - let oss_client = state.oss_client().ok_or_else(|| { - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ - "provider": "aliyun-oss", - "reason": "OSS 未完成环境变量配置", - })) - })?; - let http_client = reqwest::Client::new(); - let asset_id = format!("asset-{generated_at_micros}"); - let put_result = oss_client - .put_object( - &http_client, - OssPutObjectRequest { - prefix: LegacyAssetPrefix::PuzzleAssets, - path_segments: vec![ - sanitize_path_segment(session_id, "session"), - sanitize_path_segment(level_name, "puzzle"), - sanitize_path_segment(candidate_id, "candidate"), - asset_id.clone(), - ], - file_name: format!("image.{}", image.extension), - content_type: Some(image.mime_type.clone()), - access: OssObjectAccess::Private, - metadata: build_puzzle_asset_metadata(owner_user_id, session_id, candidate_id), - body: image.bytes, - }, - ) - .await - .map_err(map_puzzle_asset_oss_error)?; - let head = oss_client - .head_object( - &http_client, - OssHeadObjectRequest { - object_key: put_result.object_key.clone(), - }, - ) - .await - .map_err(map_puzzle_asset_oss_error)?; - let asset_object = state - .spacetime_client() - .confirm_asset_object( - build_asset_object_upsert_input( - generate_asset_object_id(generated_at_micros), - head.bucket, - head.object_key, - AssetObjectAccessPolicy::Private, - head.content_type.or(Some(image.mime_type)), - head.content_length, - head.etag, - "puzzle_cover_image".to_string(), - Some(task_id.to_string()), - Some(owner_user_id.to_string()), - None, - Some(session_id.to_string()), - generated_at_micros, - ) - .map_err(map_puzzle_asset_field_error)?, - ) - .await; - match asset_object { - Ok(asset_object) => { - if let Err(error) = state - .spacetime_client() - .bind_asset_object_to_entity( - build_asset_entity_binding_input( - generate_asset_binding_id(generated_at_micros), - asset_object.asset_object_id, - PUZZLE_ENTITY_KIND.to_string(), - session_id.to_string(), - candidate_id.to_string(), - "puzzle_cover_image".to_string(), - Some(owner_user_id.to_string()), - None, - generated_at_micros, - ) - .map_err(map_puzzle_asset_field_error)?, - ) - .await - { - handle_puzzle_asset_spacetime_index_error( - error, - owner_user_id, - session_id, - candidate_id, - "绑定拼图资产对象到实体", - )?; - } - } - Err(error) => handle_puzzle_asset_spacetime_index_error( - error, - owner_user_id, - session_id, - candidate_id, - "确认拼图资产对象", - )?, - } - - Ok(GeneratedPuzzleAssetResponse { - image_src: put_result.legacy_public_path, - asset_id, - }) -} - -async fn persist_puzzle_ui_background_image( - state: &AppState, - owner_user_id: &str, - session_id: &str, - level_name: &str, - task_id: &str, - image: DownloadedOpenAiImage, -) -> Result { - let oss_client = state.oss_client().ok_or_else(|| { - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ - "provider": "aliyun-oss", - "reason": "OSS 未完成环境变量配置", - })) - })?; - let http_client = reqwest::Client::new(); - let put_result = oss_client - .put_object( - &http_client, - OssPutObjectRequest { - prefix: LegacyAssetPrefix::PuzzleAssets, - path_segments: vec![ - sanitize_path_segment(session_id, "session"), - sanitize_path_segment(level_name, "puzzle"), - "ui-background".to_string(), - sanitize_path_segment(task_id, "task"), - ], - file_name: format!("background.{}", image.extension), - content_type: Some(image.mime_type.clone()), - access: OssObjectAccess::Private, - metadata: build_puzzle_ui_background_asset_metadata(owner_user_id, session_id), - body: image.bytes, - }, - ) - .await - .map_err(map_puzzle_asset_oss_error)?; - Ok(GeneratedPuzzleUiBackgroundResponse { - image_src: put_result.legacy_public_path, - object_key: put_result.object_key, - }) -} - -fn handle_puzzle_asset_spacetime_index_error( - error: SpacetimeClientError, - owner_user_id: &str, - session_id: &str, - candidate_id: &str, - stage: &str, -) -> Result<(), AppError> { - if should_skip_asset_operation_billing_for_connectivity(&error) { - // 中文注释:OSS 已经持有真实图片,资产索引的 SpacetimeDB 短暂失败只影响历史检索,不应阻断本次生图展示。 - tracing::warn!( - provider = "spacetimedb", - owner_user_id, - session_id, - candidate_id, - stage, - error = %error, - "拼图图片资产索引写入因 SpacetimeDB 连接不可用而降级跳过" - ); - return Ok(()); - } - - Err(map_puzzle_asset_spacetime_error(error)) -} - -fn build_puzzle_asset_metadata( - owner_user_id: &str, - session_id: &str, - candidate_id: &str, -) -> BTreeMap { - BTreeMap::from([ - ("asset_kind".to_string(), "puzzle_cover_image".to_string()), - ("owner_user_id".to_string(), owner_user_id.to_string()), - ("entity_kind".to_string(), PUZZLE_ENTITY_KIND.to_string()), - ("entity_id".to_string(), session_id.to_string()), - ("slot".to_string(), candidate_id.to_string()), - ]) -} - -fn build_puzzle_ui_background_asset_metadata( - owner_user_id: &str, - session_id: &str, -) -> BTreeMap { - BTreeMap::from([ - ( - "asset_kind".to_string(), - "puzzle_ui_background_image".to_string(), - ), - ("owner_user_id".to_string(), owner_user_id.to_string()), - ("entity_kind".to_string(), PUZZLE_ENTITY_KIND.to_string()), - ("entity_id".to_string(), session_id.to_string()), - ("slot".to_string(), "ui_background".to_string()), - ]) -} - -fn parse_puzzle_json_payload(raw_text: &str, fallback_message: &str) -> Result { - serde_json::from_str::(raw_text).map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": format!("{fallback_message}:{error}"), - })) - }) -} - -fn parse_puzzle_image_data_url(value: &str) -> Option { - let body = value.strip_prefix("data:")?; - let (mime_type, data) = body.split_once(";base64,")?; - if !mime_type.starts_with("image/") { - return None; - } - let bytes = decode_puzzle_base64(data)?; - Some(ParsedPuzzleImageDataUrl { - mime_type: mime_type.to_string(), - bytes, - }) -} - -fn decode_puzzle_base64(value: &str) -> Option> { - let cleaned = value.trim().replace(char::is_whitespace, ""); - let mut output = Vec::with_capacity(cleaned.len() * 3 / 4); - let mut buffer = 0u32; - let mut bits = 0u8; - - for byte in cleaned.bytes() { - let value = match byte { - b'A'..=b'Z' => byte - b'A', - b'a'..=b'z' => byte - b'a' + 26, - b'0'..=b'9' => byte - b'0' + 52, - b'+' => 62, - b'/' => 63, - b'=' => break, - _ => return None, - } as u32; - buffer = (buffer << 6) | value; - bits += 6; - while bits >= 8 { - bits -= 8; - output.push(((buffer >> bits) & 0xFF) as u8); - } - } - - Some(output) -} - -fn extract_puzzle_image_urls(payload: &Value) -> Vec { - let mut urls = Vec::new(); - collect_puzzle_strings_by_key(payload, "image", &mut urls); - collect_puzzle_strings_by_key(payload, "url", &mut urls); - let mut deduped = Vec::new(); - for url in urls { - if !deduped.contains(&url) { - deduped.push(url); - } - } - deduped -} - -fn extract_puzzle_b64_images(payload: &Value) -> Vec { - let mut values = Vec::new(); - collect_puzzle_strings_by_key(payload, "b64_json", &mut values); - values -} - -fn puzzle_images_from_base64( - task_id: String, - b64_images: Vec, - candidate_count: u32, -) -> PuzzleGeneratedImages { - let images = b64_images - .into_iter() - .take(candidate_count.clamp(1, 1) as usize) - .filter_map(|raw| decode_puzzle_generated_image_base64(raw.as_str())) - .collect(); - - PuzzleGeneratedImages { task_id, images } -} - -fn decode_puzzle_generated_image_base64(raw: &str) -> Option { - let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?; - let mime_type = infer_puzzle_image_mime_type(bytes.as_slice()); - Some(PuzzleDownloadedImage { - extension: puzzle_mime_to_extension(mime_type.as_str()).to_string(), - mime_type, - bytes, - }) -} - -fn find_first_puzzle_string_by_key(payload: &Value, target_key: &str) -> Option { - let mut results = Vec::new(); - collect_puzzle_strings_by_key(payload, target_key, &mut results); - results.into_iter().next() -} - -fn collect_puzzle_strings_by_key(payload: &Value, target_key: &str, results: &mut Vec) { - match payload { - Value::Array(entries) => { - for entry in entries { - collect_puzzle_strings_by_key(entry, target_key, results); - } - } - Value::Object(object) => { - for (key, value) in object { - if key == target_key { - collect_puzzle_string_values(value, results); - } - collect_puzzle_strings_by_key(value, target_key, results); - } - } - _ => {} - } -} - -fn collect_puzzle_string_values(payload: &Value, results: &mut Vec) { - match payload { - Value::String(text) => results.push(text.to_string()), - Value::Array(items) => { - for item in items { - collect_puzzle_string_values(item, results); - } - } - _ => {} - } -} - -fn infer_puzzle_image_mime_type(bytes: &[u8]) -> String { - if bytes.starts_with(b"\x89PNG\r\n\x1A\n") { - return "image/png".to_string(); - } - if bytes.starts_with(b"\xFF\xD8\xFF") { - return "image/jpeg".to_string(); - } - if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { - return "image/webp".to_string(); - } - if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { - return "image/gif".to_string(); - } - "image/png".to_string() -} - -fn normalize_puzzle_downloaded_image_mime_type(content_type: &str) -> String { - let mime_type = content_type - .split(';') - .next() - .map(str::trim) - .unwrap_or("image/jpeg"); - match mime_type { - "image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => { - mime_type.to_string() - } - _ => "image/jpeg".to_string(), - } -} - -fn puzzle_mime_to_extension(mime_type: &str) -> &str { - match mime_type { - "image/png" => "png", - "image/webp" => "webp", - "image/gif" => "gif", - _ => "jpg", - } -} - -fn map_puzzle_image_request_error(message: String) -> AppError { - let is_timeout = is_puzzle_request_timeout_message(message.as_str()); - let status = if is_timeout { - StatusCode::GATEWAY_TIMEOUT - } else { - StatusCode::BAD_GATEWAY - }; - - AppError::from_status(status).with_details(json!({ - "provider": "puzzle-image", - "message": message, - "timeout": is_timeout, - })) -} - -fn map_puzzle_vector_engine_request_error(message: String) -> AppError { - let is_timeout = is_puzzle_request_timeout_message(message.as_str()); - let status = if is_timeout { - StatusCode::GATEWAY_TIMEOUT - } else { - StatusCode::BAD_GATEWAY - }; - - AppError::from_status(status).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": message, - "timeout": is_timeout, - })) -} - -fn map_puzzle_vector_engine_reqwest_error( - context: &str, - request_url: &str, - error: reqwest::Error, -) -> AppError { - let message = format!( - "{context}:{}", - normalize_puzzle_reqwest_error_message(&error) - ); - let is_timeout = error.is_timeout() || is_puzzle_request_timeout_message(message.as_str()); - let is_connect = error.is_connect(); - let status = if is_timeout { - StatusCode::GATEWAY_TIMEOUT - } else { - StatusCode::BAD_GATEWAY - }; - let source = error.source().map(ToString::to_string).unwrap_or_default(); - - tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, - endpoint = %request_url, - timeout = is_timeout, - connect = is_connect, - request = error.is_request(), - body = error.is_body(), - source = %source, - message = %message, - "拼图 VectorEngine 请求发送失败" - ); - - AppError::from_status(status).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": message, - "reason": resolve_puzzle_vector_engine_request_failure_reason(&error), - "endpoint": request_url, - "timeout": is_timeout, - "connect": is_connect, - "request": error.is_request(), - "body": error.is_body(), - "source": source, - })) -} - -fn normalize_puzzle_reqwest_error_message(error: &reqwest::Error) -> String { - error - .to_string() - .split_whitespace() - .collect::>() - .join(" ") -} - -fn resolve_puzzle_vector_engine_request_failure_reason(error: &reqwest::Error) -> &'static str { - if error.is_timeout() { - return "VectorEngine 图片编辑请求超时,请稍后重试或调大 VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS"; - } - if error.is_connect() { - return "无法连接 VectorEngine 图片编辑接口,请检查服务器网络、DNS、防火墙或代理配置"; - } - if error.is_body() { - return "发送 VectorEngine 图片编辑 multipart 请求体失败,请重试并检查参考图大小"; - } - "VectorEngine 图片编辑请求发送失败,请查看 source 字段中的底层网络错误" -} - -fn is_puzzle_request_timeout_message(message: &str) -> bool { - let lower = message.to_ascii_lowercase(); - lower.contains("timed out") - || lower.contains("timeout") - || lower.contains("operation timed out") - || lower.contains("deadline has elapsed") -} - -fn map_puzzle_vector_engine_upstream_error( - upstream_status: reqwest::StatusCode, - raw_text: &str, - fallback_message: &str, -) -> AppError { - let message = parse_puzzle_api_error_message(raw_text, fallback_message); - let raw_excerpt = trim_puzzle_upstream_excerpt(raw_text, 800); - tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, - upstream_status = upstream_status.as_u16(), - message = %message, - raw_excerpt = %raw_excerpt, - "拼图 VectorEngine 上游请求失败" - ); - - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "upstreamStatus": upstream_status.as_u16(), - "message": message, - "rawExcerpt": raw_excerpt, - })) -} - -fn parse_puzzle_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) - && let Some(message) = find_first_puzzle_string_by_key(&payload, "message") - { - return message; - } - fallback_message.to_string() -} - -fn trim_puzzle_upstream_excerpt(raw_text: &str, max_chars: usize) -> String { - let normalized = raw_text.split_whitespace().collect::>().join(" "); - if normalized.chars().count() <= max_chars { - return normalized; - } - - let keep_chars = max_chars.saturating_sub(3); - format!( - "{}...", - normalized.chars().take(keep_chars).collect::() - ) -} - -fn map_puzzle_asset_oss_error(error: platform_oss::OssError) -> AppError { - map_oss_error(error, "aliyun-oss") -} - -fn map_puzzle_asset_spacetime_error(error: SpacetimeClientError) -> AppError { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "spacetimedb", - "message": error.to_string(), - })) -} - -fn map_puzzle_asset_field_error(error: AssetObjectFieldError) -> AppError { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": "asset-object", - "message": error.to_string(), - })) -} - -fn sanitize_path_segment(value: &str, fallback: &str) -> String { - let sanitized = value - .trim() - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() || ('\u{4e00}'..='\u{9fff}').contains(&ch) { - ch - } else { - '-' - } - }) - .collect::() - .trim_matches('-') - .to_string(); - if sanitized.is_empty() { - fallback.to_string() - } else { - sanitized - } -} - -fn current_utc_micros() -> i64 { - let duration = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default(); - (duration.as_secs() as i64) * 1_000_000 + i64::from(duration.subsec_micros()) -} +mod tests; diff --git a/server-rs/crates/api-server/src/puzzle/draft.rs b/server-rs/crates/api-server/src/puzzle/draft.rs new file mode 100644 index 00000000..9be9db15 --- /dev/null +++ b/server-rs/crates/api-server/src/puzzle/draft.rs @@ -0,0 +1,1909 @@ +use super::*; + +pub(crate) fn build_puzzle_form_seed_text(payload: &CreatePuzzleAgentSessionRequest) -> String { + build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts { + title: None, + work_description: None, + picture_description: payload + .picture_description + .as_deref() + .or(payload.seed_text.as_deref()), + }) +} + +pub(crate) fn build_puzzle_form_seed_text_from_parts( + title: Option<&str>, + work_description: Option<&str>, + picture_description: Option<&str>, +) -> String { + build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts { + title, + work_description, + picture_description, + }) +} + +pub(crate) async fn save_puzzle_form_payload_before_compile( + state: &AppState, + request_context: &RequestContext, + session_id: &str, + owner_user_id: &str, + payload: &ExecutePuzzleAgentActionRequest, + now: i64, +) -> Result { + let seed_text = build_puzzle_form_seed_text_from_parts( + None, + None, + payload + .picture_description + .as_deref() + .or(payload.prompt_text.as_deref()), + ); + if seed_text.trim().is_empty() { + return Ok(session_id.to_string()); + } + + let save_result = state + .spacetime_client() + .save_puzzle_form_draft(PuzzleFormDraftSaveRecordInput { + session_id: session_id.to_string(), + owner_user_id: owner_user_id.to_string(), + seed_text: seed_text.clone(), + saved_at_micros: now, + }) + .await + .map(|_| ()); + match save_result { + Ok(()) => Ok(session_id.to_string()), + Err(error) if is_missing_puzzle_form_draft_procedure_error(&error) => { + create_seeded_puzzle_session_when_form_save_missing( + state, + request_context, + session_id, + owner_user_id, + seed_text, + now, + &error, + ) + .await + } + Err(error) => Err(puzzle_error_response( + request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + )), + } +} + +pub(crate) async fn create_seeded_puzzle_session_when_form_save_missing( + state: &AppState, + request_context: &RequestContext, + session_id: &str, + owner_user_id: &str, + seed_text: String, + now: i64, + original_error: &SpacetimeClientError, +) -> Result { + let current_session = state + .spacetime_client() + .get_puzzle_agent_session(session_id.to_string(), owner_user_id.to_string()) + .await + .map_err(|error| { + puzzle_error_response( + request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + if !current_session.seed_text.trim().is_empty() { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id, + owner_user_id, + error = %original_error, + "拼图表单草稿保存 procedure 缺失,沿用已有 seed_text 编译" + ); + return Ok(session_id.to_string()); + } + + // 中文注释:旧 wasm 缺自动保存 procedure 时,空 session 无法被编译;这里重建带表单 seed 的 session 保证生成主链可继续。 + let replacement_session_id = build_prefixed_uuid_id("puzzle-session-"); + let replacement = state + .spacetime_client() + .create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput { + session_id: replacement_session_id.clone(), + owner_user_id: owner_user_id.to_string(), + seed_text: seed_text.clone(), + welcome_message_id: build_prefixed_uuid_id("puzzle-message-"), + welcome_message_text: build_puzzle_welcome_text(&seed_text), + created_at_micros: now, + }) + .await + .map_err(|error| { + puzzle_error_response( + request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + old_session_id = %session_id, + new_session_id = %replacement.session_id, + owner_user_id, + error = %original_error, + "拼图表单草稿保存 procedure 缺失,已创建带表单 seed 的替代 session" + ); + Ok(replacement.session_id) +} + +pub(crate) fn select_puzzle_level_for_api( + draft: &PuzzleResultDraftRecord, + level_id: Option<&str>, +) -> Result { + let normalized_level_id = level_id.map(str::trim).filter(|value| !value.is_empty()); + if let Some(target_id) = normalized_level_id { + return draft + .levels + .iter() + .find(|level| level.level_id == target_id) + .cloned() + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": format!("拼图关卡不存在:{target_id}"), + })) + }); + } + let level = draft.levels.first().cloned(); + level.ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图草稿缺少可编辑关卡", + })) + }) +} + +pub(crate) fn parse_puzzle_level_records_from_module_json( + value: &str, +) -> Result, AppError> { + let levels: Vec = + serde_json::from_str(value).map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": format!("拼图关卡列表 JSON 非法:{error}"), + })) + })?; + Ok(levels + .into_iter() + .map(|level| PuzzleDraftLevelRecord { + level_id: level.level_id, + level_name: level.level_name, + picture_description: level.picture_description, + picture_reference: level.picture_reference, + ui_background_prompt: level.ui_background_prompt, + ui_background_image_src: level.ui_background_image_src, + ui_background_image_object_key: level.ui_background_image_object_key, + background_music: level + .background_music + .map(map_puzzle_audio_asset_domain_record), + candidates: level + .candidates + .into_iter() + .map(|candidate| PuzzleGeneratedImageCandidateRecord { + candidate_id: candidate.candidate_id, + image_src: candidate.image_src, + asset_id: candidate.asset_id, + prompt: candidate.prompt, + actual_prompt: candidate.actual_prompt, + source_type: candidate.source_type, + selected: candidate.selected, + }) + .collect(), + selected_candidate_id: level.selected_candidate_id, + cover_image_src: level.cover_image_src, + cover_asset_id: level.cover_asset_id, + generation_status: level.generation_status, + }) + .collect()) +} + +pub(crate) async fn get_puzzle_session_for_image_generation( + state: &AppState, + session_id: String, + owner_user_id: String, + payload: &ExecutePuzzleAgentActionRequest, + normalized_levels_json: Option<&str>, + now: i64, +) -> Result { + match state + .spacetime_client() + .get_puzzle_agent_session(session_id.clone(), owner_user_id.clone()) + .await + { + Ok(session) => Ok(session), + Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => { + // 中文注释:结果页已经带有当前草稿快照;Maincloud 读取 session 短暂 503 时不应阻断外部生图。 + let fallback_session = build_puzzle_session_snapshot_from_action_payload( + session_id.as_str(), + payload, + normalized_levels_json, + now, + )?; + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %session_id, + owner_user_id = %owner_user_id, + error = %error, + "拼图图片生成读取 session 因 SpacetimeDB 连接不可用而降级使用前端草稿快照" + ); + Ok(fallback_session) + } + Err(error) => Err(map_puzzle_client_error(error)), + } +} + +pub(crate) fn build_puzzle_session_snapshot_from_action_payload( + session_id: &str, + payload: &ExecutePuzzleAgentActionRequest, + normalized_levels_json: Option<&str>, + now: i64, +) -> Result { + let levels_json = normalized_levels_json.ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "spacetimedb", + "message": "SpacetimeDB 暂不可用,且请求缺少拼图关卡快照,无法继续生成图片", + })) + })?; + let levels = parse_puzzle_level_records_from_module_json(levels_json)?; + let first_level = levels.first().cloned().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图草稿缺少可编辑关卡", + })) + })?; + let work_title = payload + .work_title + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(first_level.level_name.as_str()) + .to_string(); + let work_description = payload + .work_description + .as_deref() + .map(str::trim) + .unwrap_or_default() + .to_string(); + let summary = payload + .summary + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(first_level.picture_description.as_str()) + .to_string(); + let theme_tags = payload.theme_tags.clone().unwrap_or_default(); + let anchor_pack = map_puzzle_domain_anchor_pack(module_puzzle::empty_anchor_pack()); + let draft = PuzzleResultDraftRecord { + work_title, + work_description, + level_name: first_level.level_name.clone(), + summary, + theme_tags, + forbidden_directives: Vec::new(), + creator_intent: None, + anchor_pack: anchor_pack.clone(), + candidates: first_level.candidates.clone(), + selected_candidate_id: first_level.selected_candidate_id.clone(), + cover_image_src: first_level.cover_image_src.clone(), + cover_asset_id: first_level.cover_asset_id.clone(), + generation_status: first_level.generation_status.clone(), + levels, + form_draft: None, + }; + + Ok(PuzzleAgentSessionRecord { + session_id: session_id.to_string(), + seed_text: String::new(), + current_turn: 0, + progress_percent: 94, + stage: "ready_to_publish".to_string(), + anchor_pack, + draft: Some(draft), + messages: Vec::new(), + last_assistant_reply: None, + published_profile_id: None, + suggested_actions: Vec::new(), + result_preview: None, + updated_at: format_timestamp_micros(now), + }) +} + +pub(crate) fn map_puzzle_domain_anchor_pack( + anchor_pack: module_puzzle::PuzzleAnchorPack, +) -> PuzzleAnchorPackRecord { + PuzzleAnchorPackRecord { + theme_promise: map_puzzle_domain_anchor_item(anchor_pack.theme_promise), + visual_subject: map_puzzle_domain_anchor_item(anchor_pack.visual_subject), + visual_mood: map_puzzle_domain_anchor_item(anchor_pack.visual_mood), + composition_hooks: map_puzzle_domain_anchor_item(anchor_pack.composition_hooks), + tags_and_forbidden: map_puzzle_domain_anchor_item(anchor_pack.tags_and_forbidden), + } +} + +pub(crate) fn map_puzzle_domain_anchor_item( + anchor: module_puzzle::PuzzleAnchorItem, +) -> PuzzleAnchorItemRecord { + PuzzleAnchorItemRecord { + key: anchor.key, + label: anchor.label, + value: anchor.value, + status: anchor.status.as_str().to_string(), + } +} + +pub(crate) fn serialize_puzzle_levels_response( + request_context: &RequestContext, + levels: &[PuzzleDraftLevelResponse], +) -> Result { + let payload = levels + .iter() + .map(|level| { + json!({ + "level_id": level.level_id, + "level_name": level.level_name, + "picture_description": level.picture_description, + "picture_reference": level.picture_reference, + "ui_background_prompt": level.ui_background_prompt, + "ui_background_image_src": level.ui_background_image_src, + "ui_background_image_object_key": level.ui_background_image_object_key, + "background_music": puzzle_audio_asset_response_module_json(&level.background_music), + "candidates": level + .candidates + .iter() + .map(|candidate| { + json!({ + "candidate_id": candidate.candidate_id, + "image_src": candidate.image_src, + "asset_id": candidate.asset_id, + "prompt": candidate.prompt, + "actual_prompt": candidate.actual_prompt, + "source_type": candidate.source_type, + "selected": candidate.selected, + }) + }) + .collect::>(), + "selected_candidate_id": level.selected_candidate_id, + "cover_image_src": level.cover_image_src, + "cover_asset_id": level.cover_asset_id, + "generation_status": level.generation_status, + }) + }) + .collect::>(); + serde_json::to_string(&payload).map_err(|error| { + puzzle_error_response( + request_context, + PUZZLE_WORKS_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_WORKS_PROVIDER, + "message": format!("拼图关卡列表序列化失败:{error}"), + })), + ) + }) +} + +pub(crate) fn normalize_puzzle_levels_json_for_module( + value: Option<&str>, +) -> Result, String> { + let Some(raw) = value.map(str::trim).filter(|raw| !raw.is_empty()) else { + return Ok(None); + }; + let levels: Vec = + serde_json::from_str(raw).map_err(|error| format!("拼图关卡列表 JSON 非法:{error}"))?; + let payload = levels + .iter() + .map(|level| { + json!({ + "level_id": level.level_id, + "level_name": level.level_name, + "picture_description": level.picture_description, + "picture_reference": level.picture_reference, + "ui_background_prompt": level.ui_background_prompt, + "ui_background_image_src": level.ui_background_image_src, + "ui_background_image_object_key": level.ui_background_image_object_key, + "background_music": puzzle_audio_asset_response_module_json(&level.background_music), + "candidates": level + .candidates + .iter() + .map(|candidate| { + json!({ + "candidate_id": candidate.candidate_id, + "image_src": candidate.image_src, + "asset_id": candidate.asset_id, + "prompt": candidate.prompt, + "actual_prompt": candidate.actual_prompt, + "source_type": candidate.source_type, + "selected": candidate.selected, + }) + }) + .collect::>(), + "selected_candidate_id": level.selected_candidate_id, + "cover_image_src": level.cover_image_src, + "cover_asset_id": level.cover_asset_id, + "generation_status": level.generation_status, + }) + }) + .collect::>(); + serde_json::to_string(&payload) + .map(Some) + .map_err(|error| format!("拼图关卡列表序列化失败:{error}")) +} + +pub(crate) fn build_stable_puzzle_work_ids(session_id: &str) -> (String, String) { + let stable_suffix = session_id + .strip_prefix("puzzle-session-") + .unwrap_or(session_id); + ( + format!("puzzle-work-{stable_suffix}"), + format!("puzzle-profile-{stable_suffix}"), + ) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PuzzleLevelNaming { + pub(crate) level_name: String, + pub(crate) work_description: Option, + pub(crate) work_tags: Vec, + pub(crate) ui_background_prompt: Option, +} + +impl PuzzleLevelNaming { + fn fallback(picture_description: &str) -> Self { + Self { + level_name: build_fallback_puzzle_first_level_name(picture_description), + work_description: None, + work_tags: Vec::new(), + ui_background_prompt: None, + } + } +} + +pub(crate) async fn generate_puzzle_first_level_name( + state: &AppState, + picture_description: &str, +) -> PuzzleLevelNaming { + if let Some(llm_client) = state.llm_client() { + let user_prompt = build_puzzle_first_level_name_user_prompt(picture_description); + let response = llm_client + .request_text( + LlmTextRequest::new(vec![ + LlmMessage::system(PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT), + LlmMessage::user(user_prompt), + ]) + .with_model(CREATION_TEMPLATE_LLM_MODEL) + .with_responses_api(), + ) + .await; + match response { + Ok(response) => { + if let Some(naming) = parse_puzzle_level_naming_from_text(response.content.as_str()) + { + return naming; + } + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + picture_chars = picture_description.chars().count(), + "拼图首关名模型返回非法,降级使用关键词名" + ); + } + Err(error) => { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + picture_chars = picture_description.chars().count(), + error = %error, + "拼图首关名生成失败,降级使用关键词名" + ); + } + } + } + + PuzzleLevelNaming::fallback(picture_description) +} + +pub(crate) async fn generate_puzzle_first_level_name_from_image( + state: &AppState, + picture_description: &str, + image: &PuzzleDownloadedImage, +) -> Option { + let Some(llm_client) = state.creative_agent_gpt5_client() else { + return None; + }; + let Some(image_data_url) = build_puzzle_level_name_image_data_url(image) else { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + picture_chars = picture_description.chars().count(), + "拼图首关名图片输入压缩失败,保留文本关卡名" + ); + return None; + }; + let user_text = build_puzzle_first_level_name_vision_user_text(picture_description); + let response = llm_client + .request_text( + LlmTextRequest::new(vec![ + LlmMessage::system(PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT), + LlmMessage::user_multimodal(vec![ + LlmMessageContentPart::InputText { text: user_text }, + LlmMessageContentPart::InputImage { + image_url: image_data_url, + }, + ]), + ]) + .with_model(PUZZLE_LEVEL_NAME_VISION_LLM_MODEL) + .with_max_tokens(PUZZLE_LEVEL_NAME_VISION_MAX_TOKENS), + ) + .await; + + match response { + Ok(response) => { + parse_puzzle_level_naming_from_text(response.content.as_str()).or_else(|| { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + model = PUZZLE_LEVEL_NAME_VISION_LLM_MODEL, + picture_chars = picture_description.chars().count(), + "拼图首关名视觉模型返回非法,保留文本关卡名" + ); + None + }) + } + Err(error) => { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + model = PUZZLE_LEVEL_NAME_VISION_LLM_MODEL, + picture_chars = picture_description.chars().count(), + error = %error, + "拼图首关名视觉生成失败,保留文本关卡名" + ); + None + } + } +} + +pub(crate) fn build_puzzle_level_name_image_data_url( + image: &PuzzleDownloadedImage, +) -> Option { + let bytes = resize_puzzle_level_name_image_bytes(image.bytes.as_slice()) + .unwrap_or_else(|| image.bytes.clone()); + let mime_type = if bytes.starts_with(b"\x89PNG\r\n\x1A\n") { + "image/png" + } else { + image.mime_type.as_str() + }; + Some(format!( + "data:{};base64,{}", + normalize_puzzle_downloaded_image_mime_type(mime_type), + BASE64_STANDARD.encode(bytes) + )) +} + +pub(crate) fn resize_puzzle_level_name_image_bytes(bytes: &[u8]) -> Option> { + let image = image::load_from_memory(bytes).ok()?; + let resized = image.resize( + PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE, + PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE, + image::imageops::FilterType::Triangle, + ); + let mut cursor = std::io::Cursor::new(Vec::new()); + resized.write_to(&mut cursor, ImageFormat::Png).ok()?; + Some(cursor.into_inner()) +} + +pub(crate) fn parse_puzzle_level_naming_from_text(text: &str) -> Option { + let trimmed = text.trim(); + let json_text = if let Some(start) = trimmed.find('{') + && let Some(end) = trimmed.rfind('}') + && end > start + { + &trimmed[start..=end] + } else { + trimmed + }; + let parsed = serde_json::from_str::(json_text).ok(); + if parsed.is_none() && looks_like_puzzle_json_fragment(trimmed) { + return None; + } + let raw_name = parsed + .as_ref() + .and_then(|value| value.get("levelName").and_then(Value::as_str)) + .or_else(|| { + parsed + .as_ref() + .and_then(|value| value.get("level_name").and_then(Value::as_str)) + }) + .unwrap_or(trimmed); + let level_name = normalize_puzzle_first_level_name(raw_name)?; + let work_description = parsed + .as_ref() + .and_then(parse_puzzle_generated_work_description_field); + let work_tags = parsed + .as_ref() + .and_then(parse_puzzle_generated_work_tags_field) + .unwrap_or_default(); + let ui_background_prompt = parsed + .as_ref() + .and_then(parse_puzzle_ui_background_prompt_field); + + Some(PuzzleLevelNaming { + level_name, + work_description, + work_tags, + ui_background_prompt, + }) +} + +#[cfg(test)] +pub(crate) fn parse_puzzle_first_level_name_from_text(text: &str) -> Option { + parse_puzzle_level_naming_from_text(text).map(|naming| naming.level_name) +} + +pub(crate) fn parse_puzzle_ui_background_prompt_field(value: &Value) -> Option { + value + .get("uiBackgroundPrompt") + .and_then(Value::as_str) + .or_else(|| value.get("ui_background_prompt").and_then(Value::as_str)) + .and_then(normalize_puzzle_generated_ui_background_prompt) +} + +pub(crate) fn parse_puzzle_generated_work_description_field(value: &Value) -> Option { + value + .get("workDescription") + .and_then(Value::as_str) + .or_else(|| value.get("work_description").and_then(Value::as_str)) + .and_then(normalize_puzzle_generated_work_description) +} + +pub(crate) fn normalize_puzzle_generated_work_description(value: &str) -> Option { + let normalized = value + .trim() + .trim_matches(|ch: char| { + ch.is_ascii_punctuation() + || matches!( + ch, + ',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》' + ) + }) + .split_whitespace() + .collect::>() + .join(""); + let description = normalized.chars().take(80).collect::(); + (description.chars().count() >= 8 && !looks_like_puzzle_json_field_name(&description)) + .then_some(description) +} + +pub(crate) fn parse_puzzle_generated_work_tags_field(value: &Value) -> Option> { + let tags_value = value + .get("workTags") + .or_else(|| value.get("work_tags")) + .or_else(|| value.get("themeTags")) + .or_else(|| value.get("theme_tags")) + .or_else(|| value.get("tags"))?; + let raw_tags = match tags_value { + Value::Array(items) => items + .iter() + .filter_map(Value::as_str) + .map(ToString::to_string) + .collect::>(), + Value::String(text) => text + .split([',', ',', '、', '\n', '|', '/']) + .map(ToString::to_string) + .collect::>(), + _ => Vec::new(), + }; + let tags = normalize_puzzle_generated_work_tag_candidates(raw_tags); + (tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT).then_some(tags) +} + +pub(crate) fn normalize_puzzle_generated_work_tag_candidates( + candidates: impl IntoIterator, +) -> Vec +where + S: AsRef, +{ + let mut tags = Vec::new(); + for candidate in candidates { + let normalized = normalize_puzzle_tag(candidate.as_ref()); + if normalized.is_empty() + || looks_like_puzzle_json_field_name(&normalized) + || tags.iter().any(|tag| tag == &normalized) + { + continue; + } + tags.push(normalized); + if tags.len() >= module_puzzle::PUZZLE_MAX_TAG_COUNT { + break; + } + } + tags +} + +pub(crate) fn normalize_puzzle_generated_ui_background_prompt(value: &str) -> Option { + let normalized = value + .trim() + .trim_matches(|ch: char| { + ch.is_ascii_punctuation() + || matches!( + ch, + ',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》' + ) + }) + .split_whitespace() + .collect::>() + .join(""); + let filtered = normalized + .replace("拼图槽", "") + .replace("棋盘", "") + .replace("HUD", "") + .replace("按钮", "") + .replace("文字", "") + .replace("水印", "") + .replace("数字", "") + .replace("拼图碎片", "") + .replace("完整拼图图像", "") + .replace("教程浮层", ""); + let prompt = filtered + .chars() + .take(160) + .collect::() + .trim() + .trim_matches(|ch: char| matches!(ch, ',' | '。' | '、' | ';' | ':')) + .to_string(); + if prompt.chars().count() >= 12 { + Some(prompt) + } else { + None + } +} + +pub(crate) fn normalize_puzzle_first_level_name(value: &str) -> Option { + let normalized = value + .trim() + .trim_matches(|ch: char| { + ch.is_ascii_punctuation() + || matches!( + ch, + ',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》' + ) + }) + .trim_start_matches(|ch: char| ch.is_ascii_digit() || matches!(ch, '.' | '、' | ')' | ')')) + .chars() + .filter(|ch| { + !matches!( + ch, + '#' | '"' + | '\'' + | '`' + | ' ' + | '\t' + | '\r' + | '\n' + | ',' + | '。' + | '、' + | ';' + | ':' + | '!' + | '?' + | '“' + | '”' + | '《' + | '》' + ) + }) + .take(12) + .collect::(); + let normalized = strip_puzzle_level_name_generic_words(normalized); + if normalized.chars().count() >= 2 + && !matches!( + normalized.as_str(), + "第一关" | "画面" | "拼图" | "作品" | "关卡" + ) + && !looks_like_puzzle_json_field_name(&normalized) + { + Some(normalized) + } else { + None + } +} + +pub(crate) fn looks_like_puzzle_json_field_name(value: &str) -> bool { + let normalized = value.trim().trim_matches(|ch: char| { + ch.is_ascii_punctuation() + || matches!( + ch, + ',' | '。' | '、' | ';' | ':' | '!' | '?' | '“' | '”' | '《' | '》' + ) + }); + let compact = normalized.to_ascii_lowercase().replace('_', ""); + matches!(compact.as_str(), "levelnam" | "levelname") + || [ + "levelname", + "workdescription", + "worktags", + "themetags", + "uibackgroundprompt", + ] + .iter() + .any(|field| { + compact == *field + || (compact.len() >= 6 && field.starts_with(compact.as_str())) + || compact.starts_with(field) + }) +} + +pub(crate) fn looks_like_puzzle_json_fragment(value: &str) -> bool { + let trimmed = value.trim(); + if trimmed.starts_with('{') || trimmed.starts_with('[') { + return true; + } + let lower = trimmed.to_ascii_lowercase(); + [ + "\"levelnam", + "\"levelname\"", + "\"level_name\"", + "\"workdescription\"", + "\"work_description\"", + "\"worktags\"", + "\"work_tags\"", + "\"uibackgroundprompt\"", + "\"ui_background_prompt\"", + ] + .iter() + .any(|field| lower.contains(field)) +} + +pub(crate) fn strip_puzzle_level_name_generic_words(mut value: String) -> String { + for prefix in ["第一关", "关卡名", "关卡"] { + value = value.trim_start_matches(prefix).to_string(); + } + for suffix in ["第一关", "关卡名", "关卡", "画面", "拼图", "作品"] { + value = value.trim_end_matches(suffix).to_string(); + } + value.chars().take(8).collect() +} + +pub(crate) fn build_fallback_puzzle_first_level_name(picture_description: &str) -> String { + let source = picture_description.trim(); + if source.contains("猫") && (source.contains("雨夜") || source.contains('雨')) { + return "雨夜猫街".to_string(); + } + if source.contains("猫") && source.contains('灯') { + return "暖灯猫街".to_string(); + } + for (keyword, level_name) in [ + ("雨夜", "雨夜灯街"), + ("猫", "暖灯猫街"), + ("狗", "花园小狗"), + ("神庙", "神庙遗光"), + ("遗迹", "遗迹谜光"), + ("森林", "森林秘境"), + ("城市", "霓虹城市"), + ("机械", "机械迷城"), + ("蒸汽", "蒸汽街区"), + ("海", "海岸微光"), + ("花", "花园晨光"), + ("雪", "雪境小径"), + ("龙", "龙影高塔"), + ("灯", "暖灯街角"), + ("塔", "塔顶星光"), + ] { + if source.contains(keyword) { + return level_name.to_string(); + } + } + "奇境初见".to_string() +} + +pub(crate) fn build_puzzle_levels_with_primary_update( + draft: &PuzzleResultDraftRecord, + target_level: &PuzzleDraftLevelRecord, + picture_reference: Option<&str>, +) -> Vec { + let mut levels = draft.levels.clone(); + if let Some(index) = levels + .iter() + .position(|level| level.level_id == target_level.level_id) + .or_else(|| (!levels.is_empty()).then_some(0)) + { + levels[index].level_name = target_level.level_name.clone(); + levels[index].ui_background_prompt = target_level.ui_background_prompt.clone(); + levels[index].ui_background_image_src = target_level.ui_background_image_src.clone(); + levels[index].ui_background_image_object_key = + target_level.ui_background_image_object_key.clone(); + if let Some(picture_reference) = picture_reference + .map(str::trim) + .filter(|value| !value.is_empty()) + { + levels[index].picture_reference = Some(picture_reference.to_string()); + } + } + levels +} + +pub(crate) fn attach_selected_puzzle_candidate_to_levels( + levels: &mut [PuzzleDraftLevelRecord], + target_level_id: &str, + candidate: &PuzzleGeneratedImageCandidateRecord, +) { + if let Some(index) = levels + .iter() + .position(|level| level.level_id == target_level_id) + .or_else(|| (!levels.is_empty()).then_some(0)) + { + let level = &mut levels[index]; + level.candidates.clear(); + let mut candidate = candidate.clone(); + candidate.selected = true; + level.selected_candidate_id = Some(candidate.candidate_id.clone()); + level.cover_image_src = Some(candidate.image_src.clone()); + level.cover_asset_id = Some(candidate.asset_id.clone()); + level.candidates.push(candidate); + level.generation_status = "ready".to_string(); + } +} + +pub(crate) fn resolve_puzzle_initial_ui_background_prompt( + draft: &PuzzleResultDraftRecord, + target_level: &PuzzleDraftLevelRecord, +) -> String { + target_level + .ui_background_prompt + .as_deref() + .and_then(normalize_puzzle_generated_ui_background_prompt) + .unwrap_or_else(|| normalize_puzzle_ui_background_prompt("", draft, target_level)) +} + +pub(crate) fn normalize_puzzle_ui_background_prompt( + raw_prompt: &str, + draft: &PuzzleResultDraftRecord, + target_level: &PuzzleDraftLevelRecord, +) -> String { + let prompt = raw_prompt.trim(); + if !prompt.is_empty() { + return prompt.chars().take(420).collect(); + } + + let title = draft.work_title.trim(); + let title = if title.is_empty() { + target_level.level_name.trim() + } else { + title + }; + let tags = draft + .theme_tags + .iter() + .map(|tag| tag.trim()) + .filter(|tag| !tag.is_empty()) + .collect::>() + .join(","); + [ + title, + draft.work_description.trim(), + target_level.picture_description.trim(), + tags.as_str(), + PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER, + ] + .into_iter() + .filter(|value| !value.is_empty()) + .collect::>() + .join("。") + .chars() + .take(420) + .collect() +} + +pub(crate) fn build_puzzle_ui_background_generation_prompt( + level_name: &str, + prompt: &str, +) -> String { + let level_name = level_name.trim(); + let title_clause = if level_name.is_empty() { + String::new() + } else { + format!("当前拼图关卡名称:{level_name}。") + }; + format!( + "{title_clause}{prompt}\n生成一张 9:16 竖屏拼图游戏纯背景图,只表现题材氛围、色彩层次和环境空间。画面不得出现拼图槽、棋盘、拼图区边框、物品槽、HUD、按钮、按钮文字、数字、文字、水印、拼图碎片、完整拼图图像、教程浮层或角色手指。中央区域保持干净通透,方便运行态后续叠加默认拼图槽和正式拼图图块。" + ) +} + +pub(crate) fn attach_puzzle_level_ui_background( + levels: &mut [PuzzleDraftLevelRecord], + level_id: &str, + prompt: String, + generated: GeneratedPuzzleUiBackgroundResponse, +) { + let Some(index) = levels + .iter() + .position(|level| level.level_id == level_id) + .or_else(|| (!levels.is_empty()).then_some(0)) + else { + return; + }; + levels[index].ui_background_prompt = Some(prompt); + levels[index].ui_background_image_src = Some(generated.image_src); + levels[index].ui_background_image_object_key = Some(generated.object_key); +} + +pub(crate) async fn generate_puzzle_background_music_required( + state: &AppState, + owner_user_id: &str, + profile_id: &str, + title: &str, +) -> Result { + let normalized_title = title.trim(); + if normalized_title.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图草稿背景音乐名称为空,无法完成背景音乐生成", + })), + ); + } + generate_background_music_asset_for_creation( + state, + owner_user_id, + String::new(), + normalized_title.to_string(), + Some("轻快, 拼图, 循环, instrumental".to_string()), + None, + GeneratedCreationAudioTarget { + entity_kind: PUZZLE_ENTITY_KIND.to_string(), + entity_id: profile_id.to_string(), + slot: PUZZLE_BACKGROUND_MUSIC_SLOT.to_string(), + asset_kind: PUZZLE_BACKGROUND_MUSIC_ASSET_KIND.to_string(), + profile_id: Some(profile_id.to_string()), + storage_prefix: LegacyAssetPrefix::PuzzleAssets, + }, + ) + .await +} + +pub(crate) async fn generate_puzzle_initial_ui_background_required( + state: &AppState, + owner_user_id: &str, + session_id: &str, + draft: &PuzzleResultDraftRecord, + target_level: &PuzzleDraftLevelRecord, +) -> Result<(String, GeneratedPuzzleUiBackgroundResponse), AppError> { + let prompt = resolve_puzzle_initial_ui_background_prompt(draft, target_level); + let generated = generate_puzzle_ui_background_image( + state, + owner_user_id, + session_id, + target_level.level_name.as_str(), + prompt.as_str(), + ) + .await?; + Ok((prompt, generated)) +} + +pub(crate) fn ensure_puzzle_initial_level_assets_ready( + level: &PuzzleDraftLevelRecord, +) -> Result<(), AppError> { + let has_ui_background = level + .ui_background_image_src + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + || level + .ui_background_image_object_key + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()); + if has_ui_background { + return Ok(()); + } + + let mut missing = Vec::new(); + if !has_ui_background { + missing.push("UI背景图"); + } + + Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": format!("拼图草稿资源生成未完成:缺少{}", missing.join("、")), + "missingAssets": missing, + })), + ) +} + +pub(crate) fn find_puzzle_level_for_initial_asset_check<'a>( + levels: &'a [PuzzleDraftLevelRecord], + level_id: &str, +) -> Option<&'a PuzzleDraftLevelRecord> { + levels + .iter() + .find(|level| level.level_id == level_id) + .or_else(|| levels.first()) +} + +pub(crate) async fn compile_puzzle_draft_with_initial_cover( + state: &AppState, + session_id: String, + owner_user_id: String, + prompt_text: Option<&str>, + reference_image_src: Option<&str>, + image_model: Option<&str>, + now: i64, +) -> Result { + let compiled_session = state + .spacetime_client() + .compile_puzzle_agent_draft(session_id.clone(), owner_user_id.clone(), now) + .await + .map_err(map_puzzle_compile_error)?; + let draft = compiled_session.draft.clone().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图结果页草稿尚未生成", + })) + })?; + let mut target_level = select_puzzle_level_for_api(&draft, None)?; + let fallback_level_name = target_level.level_name.clone(); + let image_prompt = resolve_puzzle_draft_cover_prompt( + prompt_text, + &target_level.picture_description, + &draft.summary, + ); + let image_level_name = if target_level.level_name.trim().is_empty() { + build_fallback_puzzle_first_level_name(&target_level.picture_description) + } else { + target_level.level_name.clone() + }; + // 中文注释:首图 prompt 只依赖画面描述,关卡名分支可以和生图分支并行;OSS 临时路径使用已有名或确定性兜底名。 + let level_name_future = + generate_puzzle_first_level_name(state, &target_level.picture_description); + // 点击生成草稿时一次性完成首图生成与正式图选定,前端只展示进度,不再承担业务编排。 + let candidates_future = generate_puzzle_image_candidates( + state, + owner_user_id.as_str(), + &compiled_session.session_id, + &image_level_name, + &image_prompt, + reference_image_src, + true, + image_model, + 1, + target_level.candidates.len(), + ); + let (generated_naming, candidates_result) = tokio::join!(level_name_future, candidates_future); + target_level.level_name = generated_naming.level_name.clone(); + target_level.ui_background_prompt = generated_naming.ui_background_prompt.clone(); + let mut generated_metadata = generated_naming; + let candidates = candidates_result?; + let selected_candidate_id = candidates + .iter() + .find(|candidate| candidate.record.selected) + .or_else(|| candidates.first()) + .map(|candidate| candidate.record.candidate_id.clone()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图候选图生成结果为空", + })) + })?; + if let Some(refined_naming) = generate_puzzle_first_level_name_from_image( + state, + target_level.picture_description.as_str(), + &candidates[0].downloaded_image, + ) + .await + { + target_level.level_name = refined_naming.level_name; + if refined_naming.ui_background_prompt.is_some() { + target_level.ui_background_prompt = refined_naming.ui_background_prompt; + } + if refined_naming.work_description.is_some() { + generated_metadata.work_description = refined_naming.work_description; + } + if refined_naming.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT { + generated_metadata.work_tags = refined_naming.work_tags; + } + generated_metadata.level_name = target_level.level_name.clone(); + generated_metadata.ui_background_prompt = target_level.ui_background_prompt.clone(); + } + let generated_level_name = target_level.level_name.clone(); + let mut updated_levels = + build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src); + // 中文注释:拼图草稿音频生成临时关闭,首版生成只补首图与 UI 背景。 + let (ui_prompt, ui_background) = generate_puzzle_initial_ui_background_required( + state, + owner_user_id.as_str(), + compiled_session.session_id.as_str(), + &draft, + &target_level, + ) + .await?; + attach_puzzle_level_ui_background( + &mut updated_levels, + target_level.level_id.as_str(), + ui_prompt, + ui_background, + ); + if let Some(selected_candidate) = candidates + .iter() + .find(|candidate| candidate.record.selected) + .or_else(|| candidates.first()) + { + attach_selected_puzzle_candidate_to_levels( + &mut updated_levels, + target_level.level_id.as_str(), + &selected_candidate.record, + ); + } + let ready_level = + find_puzzle_level_for_initial_asset_check(&updated_levels, target_level.level_id.as_str()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图草稿资源生成完成后未找到目标关卡", + })) + })?; + ensure_puzzle_initial_level_assets_ready(ready_level)?; + let levels_json_with_generated_name = + Some(serialize_puzzle_level_records_for_module(&updated_levels)?); + let work_title = if draft.work_title.trim().is_empty() + || draft.work_title.trim() == fallback_level_name.trim() + { + generated_level_name.clone() + } else { + draft.work_title.clone() + }; + let work_description = if draft.work_description.trim().is_empty() { + generated_metadata + .work_description + .clone() + .unwrap_or_else(|| draft.work_description.clone()) + } else { + draft.work_description.clone() + }; + let theme_tags = if draft.theme_tags.is_empty() + && generated_metadata.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT + { + generated_metadata.work_tags.clone() + } else { + draft.theme_tags.clone() + }; + let candidates_json = serde_json::to_string( + &candidates + .iter() + .map(|candidate| to_puzzle_generated_image_candidate(&candidate.record)) + .collect::>(), + ) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": format!("拼图候选图序列化失败:{error}"), + })) + })?; + let (saved_session, save_used_fallback) = state + .spacetime_client() + .save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput { + session_id: compiled_session.session_id.clone(), + owner_user_id: owner_user_id.clone(), + level_id: Some(target_level.level_id.clone()), + levels_json: levels_json_with_generated_name.clone(), + candidates_json, + saved_at_micros: current_utc_micros(), + }) + .await + .map_err(map_puzzle_client_error) + .map(|session| (session, false)) + .or_else(|error| { + if is_spacetimedb_connectivity_app_error(&error) { + // 中文注释:首图已落 OSS 时,SpacetimeDB 短暂不可用先返回本地快照,避免整次 VectorEngine 生图被判失败。 + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %compiled_session.session_id, + owner_user_id = %owner_user_id, + message = %error.body_text(), + "拼图首图已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照" + ); + let session = apply_generated_puzzle_candidates_to_session_snapshot( + apply_generated_puzzle_levels_to_session_snapshot( + apply_generated_puzzle_first_level_name_to_session_snapshot( + compiled_session.clone(), + target_level.level_id.as_str(), + generated_level_name.as_str(), + fallback_level_name.as_str(), + now, + ), + updated_levels.clone(), + now, + ), + target_level.level_id.as_str(), + candidates.into_records(), + reference_image_src, + now, + ); + Ok((session, true)) + } else { + Err(error) + } + })?; + let (_, profile_id) = build_stable_puzzle_work_ids(&compiled_session.session_id); + match state + .spacetime_client() + .update_puzzle_work(PuzzleWorkUpsertRecordInput { + profile_id, + owner_user_id: owner_user_id.clone(), + work_title, + work_description: work_description.clone(), + level_name: generated_level_name.clone(), + summary: work_description, + theme_tags, + cover_image_src: ready_level.cover_image_src.clone(), + cover_asset_id: ready_level.cover_asset_id.clone(), + levels_json: levels_json_with_generated_name.clone(), + updated_at_micros: now, + }) + .await + .map_err(map_puzzle_client_error) + { + Ok(_) => {} + Err(error) if is_spacetimedb_connectivity_app_error(&error) => { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %compiled_session.session_id, + owner_user_id = %owner_user_id, + message = %error.body_text(), + "拼图首图生成后作品元信息投影回写不可用,继续使用会话草稿快照" + ); + } + Err(error) => return Err(error), + } + let saved_session = apply_generated_puzzle_initial_metadata_to_session_snapshot( + saved_session, + &generated_metadata, + fallback_level_name.as_str(), + now, + ); + if save_used_fallback { + return Ok(saved_session); + } + match state + .spacetime_client() + .select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput { + session_id, + owner_user_id, + level_id: Some(target_level.level_id), + candidate_id: selected_candidate_id, + selected_at_micros: current_utc_micros(), + }) + .await + { + Ok(session) => Ok(apply_generated_puzzle_initial_metadata_to_session_snapshot( + session, + &generated_metadata, + fallback_level_name.as_str(), + now, + )), + Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %saved_session.session_id, + error = %error, + "拼图首图选定回写因 SpacetimeDB 连接不可用而降级使用已生成快照" + ); + Ok(saved_session) + } + Err(error) => Err(map_puzzle_client_error(error)), + } +} + +pub(crate) async fn compile_puzzle_draft_with_uploaded_cover( + state: &AppState, + session_id: String, + owner_user_id: String, + prompt_text: Option<&str>, + reference_image_src: Option<&str>, + now: i64, +) -> Result { + let uploaded_image_src = reference_image_src + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "field": "referenceImageSrc", + "message": "关闭 AI 重绘时必须上传拼图图片。", + })) + })?; + let uploaded_image = parse_puzzle_image_data_url(uploaded_image_src).ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "field": "referenceImageSrc", + "message": "关闭 AI 重绘时上传图必须是图片 Data URL。", + })) + })?; + let compiled_session = state + .spacetime_client() + .compile_puzzle_agent_draft(session_id.clone(), owner_user_id.clone(), now) + .await + .map_err(map_puzzle_compile_error)?; + let draft = compiled_session.draft.clone().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图结果页草稿尚未生成", + })) + })?; + let mut target_level = select_puzzle_level_for_api(&draft, None)?; + let fallback_level_name = target_level.level_name.clone(); + let image_prompt = resolve_puzzle_draft_cover_prompt( + prompt_text, + &target_level.picture_description, + &draft.summary, + ); + let image_level_name = if target_level.level_name.trim().is_empty() { + build_fallback_puzzle_first_level_name(&target_level.picture_description) + } else { + target_level.level_name.clone() + }; + // 中文注释:关闭 AI 重绘时首关图不请求 VectorEngine;上传图直接成为首关正式图候选。 + let candidate_id = format!( + "{}-candidate-{}", + compiled_session.session_id, + target_level.candidates.len() + 1 + ); + let uploaded_downloaded_image = PuzzleDownloadedImage { + extension: puzzle_mime_to_extension(uploaded_image.mime_type.as_str()).to_string(), + mime_type: normalize_puzzle_downloaded_image_mime_type(uploaded_image.mime_type.as_str()), + bytes: uploaded_image.bytes, + }; + let level_name_future = + generate_puzzle_first_level_name(state, &target_level.picture_description); + let image_level_name_future = generate_puzzle_first_level_name_from_image( + state, + target_level.picture_description.as_str(), + &uploaded_downloaded_image, + ); + let persist_upload_future = persist_puzzle_generated_asset( + state, + owner_user_id.as_str(), + &compiled_session.session_id, + image_level_name.as_str(), + candidate_id.as_str(), + "uploaded-direct", + uploaded_downloaded_image.clone(), + current_utc_micros(), + ); + let (mut generated_naming, refined_naming, persisted_upload_result) = tokio::join!( + level_name_future, + image_level_name_future, + persist_upload_future + ); + if let Some(refined_naming) = refined_naming { + generated_naming.level_name = refined_naming.level_name; + if refined_naming.ui_background_prompt.is_some() { + generated_naming.ui_background_prompt = refined_naming.ui_background_prompt; + } + if refined_naming.work_description.is_some() { + generated_naming.work_description = refined_naming.work_description; + } + if refined_naming.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT { + generated_naming.work_tags = refined_naming.work_tags; + } + } + target_level.level_name = generated_naming.level_name.clone(); + target_level.ui_background_prompt = generated_naming.ui_background_prompt.clone(); + let mut generated_metadata = generated_naming; + generated_metadata.level_name = target_level.level_name.clone(); + generated_metadata.ui_background_prompt = target_level.ui_background_prompt.clone(); + let generated_level_name = target_level.level_name.clone(); + let persisted_upload = persisted_upload_result?; + let mut updated_levels = + build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src); + // 中文注释:直用上传图时同样只补 UI 背景;音频生成入口临时关闭。 + let (ui_prompt, ui_background) = generate_puzzle_initial_ui_background_required( + state, + owner_user_id.as_str(), + compiled_session.session_id.as_str(), + &draft, + &target_level, + ) + .await?; + attach_puzzle_level_ui_background( + &mut updated_levels, + target_level.level_id.as_str(), + ui_prompt, + ui_background, + ); + attach_selected_puzzle_candidate_to_levels( + &mut updated_levels, + target_level.level_id.as_str(), + &PuzzleGeneratedImageCandidateRecord { + candidate_id: candidate_id.clone(), + image_src: persisted_upload.image_src.clone(), + asset_id: persisted_upload.asset_id.clone(), + prompt: image_prompt.clone(), + actual_prompt: None, + source_type: "uploaded".to_string(), + selected: true, + }, + ); + let ready_level = + find_puzzle_level_for_initial_asset_check(&updated_levels, target_level.level_id.as_str()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图草稿资源生成完成后未找到目标关卡", + })) + })?; + ensure_puzzle_initial_level_assets_ready(ready_level)?; + let levels_json_with_generated_name = + Some(serialize_puzzle_level_records_for_module(&updated_levels)?); + let work_title = if draft.work_title.trim().is_empty() + || draft.work_title.trim() == fallback_level_name.trim() + { + generated_level_name.clone() + } else { + draft.work_title.clone() + }; + let work_description = if draft.work_description.trim().is_empty() { + generated_metadata + .work_description + .clone() + .unwrap_or_else(|| draft.work_description.clone()) + } else { + draft.work_description.clone() + }; + let theme_tags = if draft.theme_tags.is_empty() + && generated_metadata.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT + { + generated_metadata.work_tags.clone() + } else { + draft.theme_tags.clone() + }; + let candidate = PuzzleGeneratedImageCandidateRecord { + candidate_id: candidate_id.clone(), + image_src: persisted_upload.image_src, + asset_id: persisted_upload.asset_id, + prompt: image_prompt, + actual_prompt: None, + source_type: "uploaded".to_string(), + selected: true, + }; + let candidates_json = serde_json::to_string(&vec![to_puzzle_generated_image_candidate( + &candidate, + )]) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": format!("拼图上传图候选序列化失败:{error}"), + })) + })?; + let (saved_session, save_used_fallback) = state + .spacetime_client() + .save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput { + session_id: compiled_session.session_id.clone(), + owner_user_id: owner_user_id.clone(), + level_id: Some(target_level.level_id.clone()), + levels_json: levels_json_with_generated_name.clone(), + candidates_json, + saved_at_micros: current_utc_micros(), + }) + .await + .map_err(map_puzzle_client_error) + .map(|session| (session, false)) + .or_else(|error| { + if is_spacetimedb_connectivity_app_error(&error) { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %compiled_session.session_id, + owner_user_id = %owner_user_id, + message = %error.body_text(), + "拼图上传图草稿回写不可用,降级返回本地快照" + ); + let session = apply_generated_puzzle_candidates_to_session_snapshot( + apply_generated_puzzle_levels_to_session_snapshot( + apply_generated_puzzle_first_level_name_to_session_snapshot( + compiled_session.clone(), + target_level.level_id.as_str(), + generated_level_name.as_str(), + fallback_level_name.as_str(), + now, + ), + updated_levels.clone(), + now, + ), + target_level.level_id.as_str(), + vec![candidate.clone()], + reference_image_src, + now, + ); + Ok((session, true)) + } else { + Err(error) + } + })?; + let (_, profile_id) = build_stable_puzzle_work_ids(&compiled_session.session_id); + match state + .spacetime_client() + .update_puzzle_work(PuzzleWorkUpsertRecordInput { + profile_id, + owner_user_id: owner_user_id.clone(), + work_title, + work_description: work_description.clone(), + level_name: generated_level_name.clone(), + summary: work_description, + theme_tags, + cover_image_src: ready_level.cover_image_src.clone(), + cover_asset_id: ready_level.cover_asset_id.clone(), + levels_json: levels_json_with_generated_name.clone(), + updated_at_micros: now, + }) + .await + .map_err(map_puzzle_client_error) + { + Ok(_) => {} + Err(error) if is_spacetimedb_connectivity_app_error(&error) => { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %compiled_session.session_id, + owner_user_id = %owner_user_id, + message = %error.body_text(), + "拼图上传图草稿作品元信息投影回写不可用,继续使用会话草稿快照" + ); + } + Err(error) => return Err(error), + } + let saved_session = apply_generated_puzzle_initial_metadata_to_session_snapshot( + saved_session, + &generated_metadata, + fallback_level_name.as_str(), + now, + ); + if save_used_fallback { + return Ok(saved_session); + } + match state + .spacetime_client() + .select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput { + session_id, + owner_user_id, + level_id: Some(target_level.level_id), + candidate_id, + selected_at_micros: current_utc_micros(), + }) + .await + { + Ok(session) => Ok(apply_generated_puzzle_initial_metadata_to_session_snapshot( + session, + &generated_metadata, + fallback_level_name.as_str(), + now, + )), + Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %saved_session.session_id, + error = %error, + "拼图上传图选定回写因 SpacetimeDB 连接不可用而降级使用已保存快照" + ); + Ok(saved_session) + } + Err(error) => Err(map_puzzle_client_error(error)), + } +} + +pub(crate) fn apply_generated_puzzle_candidates_to_session_snapshot( + mut session: PuzzleAgentSessionRecord, + target_level_id: &str, + candidates: Vec, + picture_reference: Option<&str>, + updated_at_micros: i64, +) -> PuzzleAgentSessionRecord { + let Some(draft) = session.draft.as_mut() else { + return session; + }; + let Some(target_index) = draft + .levels + .iter() + .position(|level| level.level_id == target_level_id) + .or_else(|| (!draft.levels.is_empty()).then_some(0)) + else { + return session; + }; + let mut candidates = candidates + .into_iter() + .take(1) + .map(|mut candidate| { + candidate.selected = true; + candidate + }) + .collect::>(); + let Some(selected) = candidates.first().cloned() else { + return session; + }; + let level = &mut draft.levels[target_index]; + level.candidates.clear(); + level.candidates.append(&mut candidates); + level.selected_candidate_id = Some(selected.candidate_id.clone()); + level.cover_image_src = Some(selected.image_src.clone()); + level.cover_asset_id = Some(selected.asset_id.clone()); + if let Some(picture_reference) = picture_reference + .map(str::trim) + .filter(|value| !value.is_empty()) + { + level.picture_reference = Some(picture_reference.to_string()); + } + level.generation_status = "ready".to_string(); + if target_index == 0 { + sync_puzzle_primary_draft_fields_from_level(draft); + } + session.progress_percent = session.progress_percent.max(94); + session.stage = "ready_to_publish".to_string(); + session.last_assistant_reply = Some("拼图图片已经生成,并已替换当前正式图。".to_string()); + session.updated_at = format_timestamp_micros(updated_at_micros); + session +} + +pub(crate) fn apply_generated_puzzle_levels_to_session_snapshot( + mut session: PuzzleAgentSessionRecord, + levels: Vec, + updated_at_micros: i64, +) -> PuzzleAgentSessionRecord { + let Some(draft) = session.draft.as_mut() else { + return session; + }; + if levels.is_empty() { + return session; + } + draft.levels = levels; + sync_puzzle_primary_draft_fields_from_level(draft); + session.updated_at = format_timestamp_micros(updated_at_micros); + session +} + +pub(crate) fn apply_generated_puzzle_first_level_name_to_session_snapshot( + mut session: PuzzleAgentSessionRecord, + target_level_id: &str, + level_name: &str, + previous_level_name: &str, + updated_at_micros: i64, +) -> PuzzleAgentSessionRecord { + let Some(draft) = session.draft.as_mut() else { + return session; + }; + let normalized_name = level_name.trim(); + if normalized_name.is_empty() { + return session; + } + let Some(target_index) = draft + .levels + .iter() + .position(|level| level.level_id == target_level_id) + .or_else(|| (!draft.levels.is_empty()).then_some(0)) + else { + return session; + }; + draft.levels[target_index].level_name = normalized_name.to_string(); + let should_default_work_title = + draft.work_title.trim().is_empty() || draft.work_title.trim() == previous_level_name.trim(); + if target_index == 0 && should_default_work_title { + draft.work_title = normalized_name.to_string(); + } + sync_puzzle_primary_draft_fields_from_level(draft); + session.updated_at = format_timestamp_micros(updated_at_micros); + session +} + +pub(crate) fn apply_generated_puzzle_initial_metadata_to_session_snapshot( + mut session: PuzzleAgentSessionRecord, + metadata: &PuzzleLevelNaming, + previous_level_name: &str, + updated_at_micros: i64, +) -> PuzzleAgentSessionRecord { + let Some(draft) = session.draft.as_mut() else { + return session; + }; + apply_generated_puzzle_initial_metadata_to_draft( + draft, + metadata, + previous_level_name, + updated_at_micros, + ); + session.updated_at = format_timestamp_micros(updated_at_micros); + session +} + +pub(crate) fn apply_generated_puzzle_initial_metadata_to_draft( + draft: &mut PuzzleResultDraftRecord, + metadata: &PuzzleLevelNaming, + previous_level_name: &str, + _updated_at_micros: i64, +) { + let should_default_work_title = + draft.work_title.trim().is_empty() || draft.work_title.trim() == previous_level_name.trim(); + if should_default_work_title { + draft.work_title = metadata.level_name.clone(); + } + + if draft.work_description.trim().is_empty() + && let Some(description) = metadata.work_description.as_ref() + { + draft.work_description = description.clone(); + draft.summary = description.clone(); + } + + if draft.theme_tags.is_empty() + && metadata.work_tags.len() == module_puzzle::PUZZLE_MAX_TAG_COUNT + { + draft.theme_tags = metadata.work_tags.clone(); + } + + sync_puzzle_primary_draft_fields_from_level(draft); +} + +pub(crate) fn sync_puzzle_primary_draft_fields_from_level(draft: &mut PuzzleResultDraftRecord) { + let Some(primary_level) = draft.levels.first() else { + return; + }; + draft.level_name = primary_level.level_name.clone(); + draft.candidates = primary_level.candidates.clone(); + draft.selected_candidate_id = primary_level.selected_candidate_id.clone(); + draft.cover_image_src = primary_level.cover_image_src.clone(); + draft.cover_asset_id = primary_level.cover_asset_id.clone(); + draft.generation_status = primary_level.generation_status.clone(); + draft.summary = draft.work_description.clone(); + if draft.form_draft.is_some() { + draft.form_draft = Some(PuzzleFormDraftRecord { + work_title: (!draft.work_title.trim().is_empty()).then_some(draft.work_title.clone()), + work_description: (!draft.work_description.trim().is_empty()) + .then_some(draft.work_description.clone()), + picture_description: (!primary_level.picture_description.trim().is_empty()) + .then_some(primary_level.picture_description.clone()), + }); + } +} + +pub(crate) fn replace_puzzle_session_draft_snapshot( + mut session: PuzzleAgentSessionRecord, + draft: PuzzleResultDraftRecord, + updated_at_micros: i64, +) -> PuzzleAgentSessionRecord { + session.draft = Some(draft); + session.updated_at = format_timestamp_micros(updated_at_micros); + session +} + +pub(crate) fn apply_generated_puzzle_ui_background_to_session_snapshot( + mut session: PuzzleAgentSessionRecord, + target_level_id: &str, + prompt: String, + image_src: String, + image_object_key: Option, + updated_at_micros: i64, +) -> PuzzleAgentSessionRecord { + let Some(draft) = session.draft.as_mut() else { + return session; + }; + let Some(target_index) = draft + .levels + .iter() + .position(|level| level.level_id == target_level_id) + .or_else(|| (!draft.levels.is_empty()).then_some(0)) + else { + return session; + }; + let level = &mut draft.levels[target_index]; + level.ui_background_prompt = Some(prompt); + level.ui_background_image_src = Some(image_src); + level.ui_background_image_object_key = image_object_key; + if target_index == 0 { + sync_puzzle_primary_draft_fields_from_level(draft); + } + session.progress_percent = session.progress_percent.max(96); + session.last_assistant_reply = Some("拼图 UI 背景图已生成。".to_string()); + session.updated_at = format_timestamp_micros(updated_at_micros); + session +} diff --git a/server-rs/crates/api-server/src/puzzle/generation.rs b/server-rs/crates/api-server/src/puzzle/generation.rs new file mode 100644 index 00000000..5055d0c3 --- /dev/null +++ b/server-rs/crates/api-server/src/puzzle/generation.rs @@ -0,0 +1,264 @@ +use super::*; + +pub(crate) fn map_puzzle_generation_endpoint_error(error: AppError) -> AppError { + if error.code() == "UPSTREAM_ERROR" { + let body_text = error.body_text(); + return AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": format!("拼图图片生成失败:{body_text}"), + })); + } + + error +} + +pub(crate) fn should_fallback_puzzle_reference_edit_to_generation(error: &AppError) -> bool { + error.status_code() == StatusCode::GATEWAY_TIMEOUT + || is_puzzle_request_timeout_message(error.body_text().as_str()) +} + +pub(crate) async fn generate_puzzle_image_candidates( + state: &AppState, + owner_user_id: &str, + session_id: &str, + level_name: &str, + prompt: &str, + reference_image_src: Option<&str>, + use_reference_image_edit: bool, + image_model: Option<&str>, + candidate_count: u32, + candidate_start_index: usize, +) -> Result, AppError> { + let total_started_at = Instant::now(); + let count = candidate_count.clamp(1, 1); + let resolved_model = resolve_puzzle_image_model(image_model); + let http_client = build_puzzle_image_http_client(state, resolved_model)?; + let has_reference_image = has_puzzle_reference_image(reference_image_src); + let should_use_reference_image_edit = + should_use_puzzle_reference_image_edit(reference_image_src, use_reference_image_edit); + let actual_prompt = build_puzzle_vector_engine_generation_prompt( + build_puzzle_image_prompt(level_name, prompt).as_str(), + should_use_reference_image_edit, + ); + tracing::info!( + provider = resolved_model.provider_name(), + image_model = resolved_model.request_model_name(), + session_id, + level_name, + prompt_chars = prompt.chars().count(), + actual_prompt_chars = actual_prompt.chars().count(), + has_reference_image, + use_reference_image_edit = should_use_reference_image_edit, + "拼图图片生成请求已准备" + ); + let reference_image_started_at = Instant::now(); + let reference_image = match reference_image_src + .map(str::trim) + .filter(|value| !value.is_empty()) + .filter(|_| should_use_reference_image_edit) + { + Some(source) => { + let resolved = + resolve_puzzle_reference_image_as_data_url(state, &http_client, source).await?; + tracing::info!( + provider = resolved_model.provider_name(), + image_model = resolved_model.request_model_name(), + session_id, + level_name, + reference_mime = %resolved.mime_type, + reference_bytes = resolved.bytes_len, + elapsed_ms = reference_image_started_at.elapsed().as_millis() as u64, + "拼图参考图解析完成" + ); + Some(resolved) + } + None => None, + }; + if !should_use_reference_image_edit { + tracing::info!( + provider = resolved_model.provider_name(), + image_model = resolved_model.request_model_name(), + session_id, + level_name, + has_reference_image, + use_reference_image_edit = should_use_reference_image_edit, + elapsed_ms = reference_image_started_at.elapsed().as_millis() as u64, + "拼图参考图解析跳过" + ); + } + // 中文注释:SpacetimeDB reducer 不能做外部 I/O,参考图读取与外部生图都必须停留在 api-server。 + // 中文注释:拼图作品资产统一按 1:1 正方形生成,前端运行时也按正方形棋盘切块承载。 + let settings = require_puzzle_vector_engine_settings(state)?; + let vector_engine_started_at = Instant::now(); + let generated = if should_use_reference_image_edit { + let reference_image = reference_image.as_ref().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "puzzle", + "field": "referenceImageSrc", + "message": "AI 重绘需要提供参考图。", + })) + })?; + let edit_result = create_puzzle_vector_engine_image_edit( + &http_client, + &settings, + actual_prompt.as_str(), + PUZZLE_DEFAULT_NEGATIVE_PROMPT, + PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, + count, + reference_image, + ) + .await; + match edit_result { + Ok(generated) => Ok(generated), + Err(error) if should_fallback_puzzle_reference_edit_to_generation(&error) => { + tracing::warn!( + provider = resolved_model.provider_name(), + image_model = resolved_model.request_model_name(), + session_id, + level_name, + reference_mime = %reference_image.mime_type, + reference_bytes = reference_image.bytes_len, + error = %error, + "拼图参考图编辑接口超时,降级为带参考图的生成接口" + ); + create_puzzle_vector_engine_image_generation( + &http_client, + &settings, + resolved_model, + actual_prompt.as_str(), + PUZZLE_DEFAULT_NEGATIVE_PROMPT, + PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, + count, + Some(reference_image), + ) + .await + } + Err(error) => Err(error), + } + } else { + create_puzzle_vector_engine_image_generation( + &http_client, + &settings, + resolved_model, + actual_prompt.as_str(), + PUZZLE_DEFAULT_NEGATIVE_PROMPT, + PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, + count, + None, + ) + .await + } + .map_err(map_puzzle_generation_endpoint_error)?; + tracing::info!( + provider = resolved_model.provider_name(), + image_model = resolved_model.request_model_name(), + session_id, + level_name, + generated_image_count = generated.images.len(), + elapsed_ms = vector_engine_started_at.elapsed().as_millis() as u64, + "拼图 VectorEngine 生图与下载完成" + ); + let mut items = Vec::with_capacity(generated.images.len()); + + for (index, image) in generated.images.into_iter().enumerate() { + let candidate_id = format!( + "{session_id}-candidate-{}", + candidate_start_index + index + 1 + ); + let downloaded_image = image.clone(); + let persist_started_at = Instant::now(); + let asset = persist_puzzle_generated_asset( + state, + owner_user_id, + session_id, + level_name, + candidate_id.as_str(), + generated.task_id.as_str(), + image, + current_utc_micros(), + ) + .await + .map_err(map_puzzle_generation_endpoint_error)?; + tracing::info!( + provider = resolved_model.provider_name(), + image_model = resolved_model.request_model_name(), + session_id, + level_name, + candidate_id = %candidate_id, + image_bytes = downloaded_image.bytes.len(), + image_mime = %downloaded_image.mime_type, + elapsed_ms = persist_started_at.elapsed().as_millis() as u64, + "拼图生成图片已写入 OSS 与资产索引" + ); + items.push(GeneratedPuzzleImageCandidate { + record: PuzzleGeneratedImageCandidateRecord { + candidate_id, + image_src: asset.image_src, + asset_id: asset.asset_id, + prompt: prompt.to_string(), + actual_prompt: Some(actual_prompt.clone()), + source_type: resolved_model.candidate_source_type().to_string(), + // 单图生成结果总是直接成为当前正式图。 + selected: index == 0, + }, + downloaded_image, + }); + } + + tracing::info!( + provider = resolved_model.provider_name(), + image_model = resolved_model.request_model_name(), + session_id, + level_name, + candidate_count = items.len(), + has_reference_image, + elapsed_ms = total_started_at.elapsed().as_millis() as u64, + "拼图图片候选生成完成" + ); + Ok(items) +} + +pub(crate) async fn generate_puzzle_ui_background_image( + state: &AppState, + owner_user_id: &str, + session_id: &str, + level_name: &str, + prompt: &str, +) -> Result { + let settings = require_openai_image_settings(state)?; + let http_client = build_openai_image_http_client(&settings)?; + let generated = create_openai_image_generation( + &http_client, + &settings, + build_puzzle_ui_background_generation_prompt(level_name, prompt).as_str(), + Some("文字、水印、按钮文字、数字、教程浮层、拼图碎片、完整拼图图像、拼图槽、棋盘、拼图区边框、物品槽、HUD、角色手指"), + "9:16", + 1, + &[], + "拼图 UI 背景图生成失败", + ) + .await?; + let image = generated.images.into_iter().next().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": VECTOR_ENGINE_PROVIDER, + "message": "拼图 UI 背景图生成失败:未返回图片", + })) + })?; + persist_puzzle_ui_background_image( + state, + owner_user_id, + session_id, + level_name, + generated.task_id.as_str(), + image, + ) + .await +} + +#[cfg(test)] +pub(crate) fn build_puzzle_ui_background_request_prompt_for_test( + level_name: &str, + prompt: &str, +) -> String { + build_puzzle_ui_background_generation_prompt(level_name, prompt) +} diff --git a/server-rs/crates/api-server/src/puzzle/handlers.rs b/server-rs/crates/api-server/src/puzzle/handlers.rs new file mode 100644 index 00000000..44b84c74 --- /dev/null +++ b/server-rs/crates/api-server/src/puzzle/handlers.rs @@ -0,0 +1,2009 @@ +use super::*; + +pub async fn create_puzzle_agent_session( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + + let seed_text = build_puzzle_form_seed_text(&payload); + let session = state + .spacetime_client() + .create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput { + session_id: build_prefixed_uuid_id("puzzle-session-"), + owner_user_id: authenticated.claims().user_id().to_string(), + seed_text: seed_text.clone(), + welcome_message_id: build_prefixed_uuid_id("puzzle-message-"), + welcome_message_text: build_puzzle_welcome_text(&seed_text), + created_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleAgentSessionResponse { + session: map_puzzle_agent_session_response(session), + }, + )) +} + +pub async fn generate_puzzle_onboarding_work( + State(state): State, + Extension(request_context): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + + let prompt_text = payload.prompt_text.trim().to_string(); + ensure_non_empty( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + &prompt_text, + "promptText", + )?; + + let now = current_utc_micros(); + let session_id = build_prefixed_uuid_id("puzzle-onboarding-"); + let naming = generate_puzzle_first_level_name(&state, prompt_text.as_str()).await; + let tags = + generate_puzzle_work_tags(&state, naming.level_name.as_str(), prompt_text.as_str()).await; + let candidates = generate_puzzle_image_candidates( + &state, + "onboarding-guest", + session_id.as_str(), + naming.level_name.as_str(), + prompt_text.as_str(), + None, + false, + Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2), + 1, + 0, + ) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_generation_endpoint_error(error), + ) + })? + .into_records(); + let selected = candidates.first().cloned().ok_or_else(|| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "新手引导拼图图片生成结果为空", + })), + ) + })?; + let level = PuzzleDraftLevelRecord { + level_id: "onboarding-level-1".to_string(), + level_name: naming.level_name.clone(), + picture_description: prompt_text.clone(), + picture_reference: None, + ui_background_prompt: naming.ui_background_prompt.clone(), + ui_background_image_src: None, + ui_background_image_object_key: None, + background_music: None, + candidates, + selected_candidate_id: Some(selected.candidate_id.clone()), + cover_image_src: Some(selected.image_src.clone()), + cover_asset_id: Some(selected.asset_id.clone()), + generation_status: "ready".to_string(), + }; + let anchor_pack = map_puzzle_domain_anchor_pack(module_puzzle::build_form_anchor_pack( + naming.level_name.as_str(), + level.picture_description.as_str(), + )); + let item = PuzzleWorkProfileRecord { + work_id: format!("onboarding-work-{now}"), + profile_id: format!("onboarding-profile-{now}"), + owner_user_id: "onboarding-guest".to_string(), + source_session_id: None, + author_display_name: "陶泥儿主".to_string(), + work_title: naming.level_name.clone(), + work_description: prompt_text.clone(), + level_name: naming.level_name, + summary: prompt_text, + theme_tags: tags, + cover_image_src: level.cover_image_src.clone(), + cover_asset_id: level.cover_asset_id.clone(), + publication_status: "draft".to_string(), + updated_at: format_timestamp_micros(now), + published_at: None, + play_count: 0, + remix_count: 0, + like_count: 0, + recent_play_count_7d: 0, + point_incentive_total_half_points: 0, + point_incentive_claimed_points: 0, + anchor_pack, + publish_ready: true, + levels: vec![level.clone()], + }; + + Ok(json_success_body( + Some(&request_context), + PuzzleOnboardingGenerateResponse { + item: map_puzzle_work_profile_response(&state, item.clone()).summary, + level: map_puzzle_draft_level_response(level), + }, + )) +} + +pub async fn save_puzzle_onboarding_work( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_WORKS_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_WORKS_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + + let prompt_text = payload.prompt_text.trim().to_string(); + ensure_non_empty( + &request_context, + PUZZLE_WORKS_PROVIDER, + &prompt_text, + "promptText", + )?; + + let first_level = payload.item.levels.first().cloned().ok_or_else(|| { + puzzle_error_response( + &request_context, + PUZZLE_WORKS_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_WORKS_PROVIDER, + "message": "新手引导拼图缺少可保存关卡", + })), + ) + })?; + let levels_json = serialize_puzzle_levels_response(&request_context, &payload.item.levels)?; + let work_title = payload.item.work_title.trim(); + let work_title = if work_title.is_empty() { + first_level.level_name.clone() + } else { + work_title.to_string() + }; + let work_description = payload.item.work_description.trim(); + let work_description = if work_description.is_empty() { + prompt_text.clone() + } else { + work_description.to_string() + }; + let summary = payload.item.summary.trim(); + let summary = if summary.is_empty() { + first_level.picture_description.clone() + } else { + summary.to_string() + }; + let now = current_utc_micros(); + let owner_user_id = authenticated.claims().user_id().to_string(); + let session_id = build_prefixed_uuid_id("puzzle-session-"); + state + .spacetime_client() + .create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput { + session_id: session_id.clone(), + owner_user_id: owner_user_id.clone(), + seed_text: prompt_text.clone(), + welcome_message_id: build_prefixed_uuid_id("puzzle-message-"), + welcome_message_text: build_puzzle_welcome_text(&prompt_text), + created_at_micros: now, + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_WORKS_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + let (_, profile_id) = build_stable_puzzle_work_ids(session_id.as_str()); + let item = state + .spacetime_client() + .update_puzzle_work(PuzzleWorkUpsertRecordInput { + profile_id, + owner_user_id, + work_title, + work_description, + level_name: first_level.level_name, + summary, + theme_tags: payload.item.theme_tags, + cover_image_src: first_level.cover_image_src, + cover_asset_id: first_level.cover_asset_id, + levels_json: Some(levels_json), + updated_at_micros: now, + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_WORKS_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleWorkMutationResponse { + item: map_puzzle_work_profile_response(&state, item), + }, + )) +} + +pub async fn get_puzzle_agent_session( + State(state): State, + AxumPath(session_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + &session_id, + "sessionId", + )?; + + let session = state + .spacetime_client() + .get_puzzle_agent_session(session_id, authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleAgentSessionResponse { + session: map_puzzle_agent_session_response(session), + }, + )) +} + +pub async fn submit_puzzle_agent_message( + State(state): State, + AxumPath(session_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + ensure_non_empty( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + &session_id, + "sessionId", + )?; + + let client_message_id = payload.client_message_id.trim().to_string(); + let message_text = payload.text.trim().to_string(); + if client_message_id.is_empty() || message_text.is_empty() { + return Err(puzzle_bad_request( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + "clientMessageId and text are required", + )); + } + + let owner_user_id = authenticated.claims().user_id().to_string(); + let submitted_session = state + .spacetime_client() + .submit_puzzle_agent_message(PuzzleAgentMessageSubmitRecordInput { + session_id: session_id.clone(), + owner_user_id: owner_user_id.clone(), + user_message_id: client_message_id, + user_message_text: message_text, + submitted_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + let turn_result = run_puzzle_agent_turn( + PuzzleAgentTurnRequest { + llm_client: state.llm_client(), + session: &submitted_session, + quick_fill_requested: payload.quick_fill_requested.unwrap_or(false), + enable_web_search: state.config.creation_agent_llm_web_search_enabled, + }, + |_| {}, + ) + .await; + let finalize_input = match turn_result { + Ok(turn_result) => build_finalize_record_input( + session_id.clone(), + owner_user_id.clone(), + format!("assistant-{session_id}-{}", current_utc_micros()), + turn_result, + current_utc_micros(), + ), + Err(error) => build_failed_finalize_record_input( + session_id.clone(), + owner_user_id.clone(), + &submitted_session, + error.to_string(), + current_utc_micros(), + ), + }; + let session = state + .spacetime_client() + .finalize_puzzle_agent_message(finalize_input) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleAgentSessionResponse { + session: map_puzzle_agent_session_response(session), + }, + )) +} + +pub async fn stream_puzzle_agent_message( + State(state): State, + AxumPath(session_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result { + let Json(payload) = payload.map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + ensure_non_empty( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + &session_id, + "sessionId", + )?; + + let owner_user_id = authenticated.claims().user_id().to_string(); + let quick_fill_requested = payload.quick_fill_requested.unwrap_or(false); + let session = state + .spacetime_client() + .submit_puzzle_agent_message(PuzzleAgentMessageSubmitRecordInput { + session_id: session_id.clone(), + owner_user_id: owner_user_id.clone(), + user_message_id: payload.client_message_id.trim().to_string(), + user_message_text: payload.text.trim().to_string(), + submitted_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + let state = state.clone(); + let session_id_for_stream = session_id.clone(); + let owner_user_id_for_stream = owner_user_id.clone(); + let stream = async_stream::stream! { + let mut draft_writer = AiGenerationDraftWriter::new(AiGenerationDraftContext::new( + "puzzle", + owner_user_id_for_stream.as_str(), + session_id_for_stream.as_str(), + payload.client_message_id.as_str(), + "拼图模板生成草稿", + )); + if let Err(error) = draft_writer.ensure_started(state.spacetime_client()).await { + tracing::warn!(error = %error, "拼图模板生成草稿任务启动失败,主生成流程继续执行"); + } + let (reply_tx, mut reply_rx) = tokio::sync::mpsc::unbounded_channel::(); + let turn_result = { + let run_turn = run_puzzle_agent_turn( + PuzzleAgentTurnRequest { + llm_client: state.llm_client(), + session: &session, + quick_fill_requested, + enable_web_search: state.config.creation_agent_llm_web_search_enabled, + }, + move |text| { + let _ = reply_tx.send(text.to_string()); + }, + ); + tokio::pin!(run_turn); + + loop { + tokio::select! { + result = &mut run_turn => break result, + maybe_text = reply_rx.recv() => { + if let Some(text) = maybe_text { + draft_writer.persist_visible_text(state.spacetime_client(), text.as_str()).await; + yield Ok::(puzzle_sse_json_event_or_error( + "reply_delta", + json!({ "text": text }), + )); + } + } + } + } + }; + + while let Some(text) = reply_rx.recv().await { + draft_writer.persist_visible_text(state.spacetime_client(), text.as_str()).await; + yield Ok::(puzzle_sse_json_event_or_error( + "reply_delta", + json!({ "text": text }), + )); + } + + let finalize_input = match turn_result { + Ok(turn_result) => build_finalize_record_input( + session_id_for_stream.clone(), + owner_user_id_for_stream.clone(), + format!("assistant-{session_id_for_stream}-{}", current_utc_micros()), + turn_result, + current_utc_micros(), + ), + Err(error) => build_failed_finalize_record_input( + session_id_for_stream.clone(), + owner_user_id_for_stream.clone(), + &session, + error.to_string(), + current_utc_micros(), + ), + }; + let finalize_result = state + .spacetime_client() + .finalize_puzzle_agent_message(finalize_input) + .await; + let _final_session = match finalize_result { + Ok(session) => session, + Err(error) => { + yield Ok::(puzzle_sse_json_event_or_error( + "error", + json!({ "message": error.to_string() }), + )); + return; + } + }; + let final_session = match state + .spacetime_client() + .get_puzzle_agent_session(session_id_for_stream, owner_user_id_for_stream) + .await + { + Ok(session) => session, + Err(error) => { + yield Ok::(puzzle_sse_json_event_or_error( + "error", + json!({ "message": error.to_string() }), + )); + return; + } + }; + let session_response = map_puzzle_agent_session_response(final_session); + yield Ok::(puzzle_sse_json_event_or_error( + "session", + json!({ "session": session_response }), + )); + yield Ok::(puzzle_sse_json_event_or_error( + "done", + json!({ "ok": true }), + )); + }; + Ok(Sse::new(stream).into_response()) +} + +pub async fn execute_puzzle_agent_action( + State(state): State, + AxumPath(session_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + ensure_non_empty( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + &session_id, + "sessionId", + )?; + + let owner_user_id = authenticated.claims().user_id().to_string(); + let now = current_utc_micros(); + let action = payload.action.trim().to_string(); + let billing_asset_id = format!("{session_id}:{now}"); + tracing::info!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %session_id, + owner_user_id = %owner_user_id, + action = %action, + image_model = resolve_puzzle_image_model(payload.image_model.as_deref()).request_model_name(), + prompt_chars = payload + .prompt_text + .as_deref() + .map(|value| value.chars().count()) + .unwrap_or(0), + has_reference_image = has_puzzle_reference_images( + payload.reference_image_src.as_deref(), + payload.reference_image_srcs.as_slice(), + ), + "拼图 Agent action 开始执行" + ); + let (operation_type, phase_label, phase_detail, session) = match action.as_str() { + "compile_puzzle_draft" => { + let ai_redraw = payload.ai_redraw.unwrap_or(true); + let reference_image_sources = collect_puzzle_reference_image_sources( + payload.reference_image_src.as_deref(), + payload.reference_image_srcs.as_slice(), + ); + let primary_reference_image_src = reference_image_sources.first().map(String::as_str); + let prompt_text = payload + .picture_description + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .or_else(|| payload.prompt_text.as_deref()); + let compile_session_id = match save_puzzle_form_payload_before_compile( + &state, + &request_context, + &session_id, + &owner_user_id, + &payload, + now, + ) + .await + { + Ok(next_session_id) => next_session_id, + Err(response) => return Err(response), + }; + let session = if ai_redraw { + execute_billable_asset_operation_with_cost( + &state, + &owner_user_id, + "puzzle_initial_image", + &billing_asset_id, + PUZZLE_IMAGE_GENERATION_POINTS_COST, + async { + compile_puzzle_draft_with_initial_cover( + &state, + compile_session_id.clone(), + owner_user_id.clone(), + prompt_text, + primary_reference_image_src, + payload.image_model.as_deref(), + now, + ) + .await + }, + ) + .await + } else { + compile_puzzle_draft_with_uploaded_cover( + &state, + compile_session_id.clone(), + owner_user_id.clone(), + prompt_text, + payload.reference_image_src.as_deref(), + now, + ) + .await + } + .map_err(|error| { + puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) + }); + ( + "compile_puzzle_draft", + "首关拼图草稿", + if ai_redraw { + "已编译首关草稿、生成首关画面并写入正式草稿。" + } else { + "已编译首关草稿,并直接应用上传图片为第一关图片。" + }, + session, + ) + } + "save_puzzle_form_draft" => { + let seed_text = build_puzzle_form_seed_text_from_parts( + None, + None, + payload + .picture_description + .as_deref() + .or(payload.prompt_text.as_deref()), + ); + let save_result = state + .spacetime_client() + .save_puzzle_form_draft(PuzzleFormDraftSaveRecordInput { + session_id: session_id.clone(), + owner_user_id: owner_user_id.clone(), + seed_text, + saved_at_micros: now, + }) + .await; + let session = match save_result { + Ok(session) => Ok(session), + Err(error) if is_missing_puzzle_form_draft_procedure_error(&error) => { + // 中文注释:旧 wasm 缺少该自动保存 procedure 时,返回当前 session,避免填表页被非关键错误打断。 + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %session_id, + owner_user_id = %owner_user_id, + error = %error, + "拼图表单自动保存 procedure 缺失,降级返回当前会话" + ); + state + .spacetime_client() + .get_puzzle_agent_session(session_id.clone(), owner_user_id.clone()) + .await + .map_err(|fallback_error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(fallback_error), + ) + }) + } + Err(error) => Err(puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + )), + }; + ( + "save_puzzle_form_draft", + "表单草稿保存", + "拼图表单草稿已保存。", + session, + ) + } + "generate_puzzle_images" => { + let target_level_id = payload.level_id.clone(); + let levels_json = normalize_puzzle_levels_json_for_module( + payload.levels_json.as_deref(), + ) + .map_err(|message| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": message, + })) + }); + let session = execute_billable_asset_operation_with_cost( + &state, + &owner_user_id, + "puzzle_generated_image", + &billing_asset_id, + PUZZLE_IMAGE_GENERATION_POINTS_COST, + async { + let levels_json = levels_json?; + let session = get_puzzle_session_for_image_generation( + &state, + session_id.clone(), + owner_user_id.clone(), + &payload, + levels_json.as_deref(), + now, + ) + .await?; + let mut draft = session.draft.clone().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图结果页草稿尚未生成", + })) + })?; + if let Some(levels_json) = levels_json.as_ref() { + draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?; + } + let mut target_level = + select_puzzle_level_for_api(&draft, target_level_id.as_deref())?; + let fallback_level_name = target_level.level_name.clone(); + let prompt = resolve_puzzle_level_image_prompt( + payload.prompt_text.as_deref(), + &target_level.picture_description, + ); + let reference_image_sources = collect_puzzle_reference_image_sources( + payload.reference_image_src.as_deref(), + payload.reference_image_srcs.as_slice(), + ); + let primary_reference_image_src = + reference_image_sources.first().map(String::as_str); + // 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。 + let candidate_count = 1; + let candidate_start_index = target_level.candidates.len(); + let candidates = generate_puzzle_image_candidates( + &state, + owner_user_id.as_str(), + &session.session_id, + &target_level.level_name, + &prompt, + primary_reference_image_src, + payload.ai_redraw.unwrap_or(true), + payload.image_model.as_deref(), + candidate_count, + candidate_start_index, + ) + .await + .map_err(map_puzzle_generation_endpoint_error)?; + if candidates.is_empty() { + return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details( + json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图候选图生成结果为空", + }), + )); + } + if let Some(refined_naming) = generate_puzzle_first_level_name_from_image( + &state, + target_level.picture_description.as_str(), + &candidates[0].downloaded_image, + ) + .await + { + target_level.level_name = refined_naming.level_name; + if refined_naming.ui_background_prompt.is_some() { + target_level.ui_background_prompt = refined_naming.ui_background_prompt; + } + } + let generated_level_name = target_level.level_name.clone(); + let levels_json_with_generated_name = + Some(serialize_puzzle_level_records_for_module( + &build_puzzle_levels_with_primary_update( + &draft, + &target_level, + primary_reference_image_src, + ), + )?); + let candidates_json = serde_json::to_string( + &candidates + .iter() + .map(|candidate| to_puzzle_generated_image_candidate(&candidate.record)) + .collect::>(), + ) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": format!("拼图候选图序列化失败:{error}"), + })) + })?; + let save_result = state + .spacetime_client() + .save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput { + session_id: session.session_id.clone(), + owner_user_id: owner_user_id.clone(), + level_id: Some(target_level.level_id.clone()), + levels_json: levels_json_with_generated_name, + candidates_json, + saved_at_micros: now, + }) + .await; + match save_result { + Ok(session) => Ok(session), + Err(error) + if should_skip_asset_operation_billing_for_connectivity(&error) => + { + // 中文注释:VectorEngine/OSS 已生成真实图片时,SpacetimeDB 短暂 503 不应让前端看不到本次图片;先返回内存合成快照,待后续操作恢复正常持久化。 + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %session.session_id, + owner_user_id = %owner_user_id, + error = %error, + "拼图图片已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照" + ); + let fallback_session = + replace_puzzle_session_draft_snapshot(session, draft, now); + Ok(apply_generated_puzzle_candidates_to_session_snapshot( + apply_generated_puzzle_first_level_name_to_session_snapshot( + fallback_session, + target_level.level_id.as_str(), + generated_level_name.as_str(), + fallback_level_name.as_str(), + now, + ), + target_level.level_id.as_str(), + candidates.into_records(), + primary_reference_image_src, + now, + )) + } + Err(error) => Err(map_puzzle_client_error(error)), + } + }, + ) + .await + .map_err(|error| { + puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) + }); + ( + "generate_puzzle_images", + "拼图图片生成", + "已生成并替换当前拼图图片。", + session, + ) + } + "generate_puzzle_ui_background" => { + let target_level_id = payload.level_id.clone(); + let raw_prompt = payload + .prompt_text + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or_default() + .to_string(); + let levels_json = normalize_puzzle_levels_json_for_module( + payload.levels_json.as_deref(), + ) + .map_err(|message| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": message, + })) + }); + let session = execute_billable_asset_operation_with_cost( + &state, + &owner_user_id, + "puzzle_ui_background_image", + &billing_asset_id, + PUZZLE_IMAGE_GENERATION_POINTS_COST, + async { + let levels_json = levels_json?; + let session = get_puzzle_session_for_image_generation( + &state, + session_id.clone(), + owner_user_id.clone(), + &payload, + levels_json.as_deref(), + now, + ) + .await?; + let mut draft = session.draft.clone().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图结果页草稿尚未生成", + })) + })?; + if let Some(levels_json) = levels_json.as_ref() { + draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?; + } + let target_level = + select_puzzle_level_for_api(&draft, target_level_id.as_deref())?; + let resolved_prompt = normalize_puzzle_ui_background_prompt( + raw_prompt.as_str(), + &draft, + &target_level, + ); + let generated = generate_puzzle_ui_background_image( + &state, + owner_user_id.as_str(), + &session.session_id, + &target_level.level_name, + resolved_prompt.as_str(), + ) + .await + .map_err(map_puzzle_generation_endpoint_error)?; + let save_result = state + .spacetime_client() + .save_puzzle_ui_background(PuzzleUiBackgroundSaveRecordInput { + session_id: session.session_id.clone(), + owner_user_id: owner_user_id.clone(), + level_id: Some(target_level.level_id.clone()), + levels_json, + prompt: resolved_prompt.clone(), + image_src: generated.image_src.clone(), + image_object_key: Some(generated.object_key.clone()), + saved_at_micros: now, + }) + .await; + match save_result { + Ok(session) => Ok(session), + Err(error) + if should_skip_asset_operation_billing_for_connectivity(&error) => + { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %session.session_id, + owner_user_id = %owner_user_id, + error = %error, + "拼图 UI 背景图已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照" + ); + let fallback_session = + replace_puzzle_session_draft_snapshot(session, draft, now); + Ok(apply_generated_puzzle_ui_background_to_session_snapshot( + fallback_session, + target_level.level_id.as_str(), + resolved_prompt, + generated.image_src, + Some(generated.object_key), + now, + )) + } + Err(error) => Err(map_puzzle_client_error(error)), + } + }, + ) + .await + .map_err(|error| { + puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) + }); + ( + "generate_puzzle_ui_background", + "UI 背景图生成", + "已生成拼图 UI 背景图。", + session, + ) + } + "generate_puzzle_tags" => { + let work_title = payload + .work_title + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + puzzle_bad_request( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + "作品名称不能为空", + ) + })?; + let work_description = payload + .work_description + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + puzzle_bad_request( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + "作品描述不能为空", + ) + })?; + let levels_json = normalize_puzzle_levels_json_for_module( + payload.levels_json.as_deref(), + ) + .map_err(|message| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": message, + })), + ) + })?; + let generated_tags = + generate_puzzle_work_tags(&state, work_title, work_description).await; + let session = save_generated_puzzle_tags_to_session( + &state, + &session_id, + &owner_user_id, + &payload, + generated_tags, + levels_json, + now, + ) + .await + .map_err(|error| { + puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) + }); + ( + "generate_puzzle_tags", + "作品标签生成", + "已生成 6 个作品标签。", + session, + ) + } + "select_puzzle_image" => { + let candidate_id = payload + .candidate_id + .clone() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + puzzle_bad_request( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + "candidateId is required", + ) + })?; + let session = state + .spacetime_client() + .select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput { + session_id: session_id.clone(), + owner_user_id: owner_user_id.clone(), + level_id: payload.level_id.clone(), + candidate_id, + selected_at_micros: now, + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + }); + ( + "select_puzzle_image", + "正式图确认", + "已应用正式拼图图片。", + session, + ) + } + "publish_puzzle_work" => { + let levels_json = normalize_puzzle_levels_json_for_module( + payload.levels_json.as_deref(), + ) + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": error, + })), + ) + })?; + let (work_id, profile_id) = build_stable_puzzle_work_ids(&session_id); + let author_display_name = resolve_author_display_name(&state, &authenticated); + let profile = execute_billable_asset_operation( + &state, + &owner_user_id, + "puzzle_publish_work", + &work_id, + async { + state + .spacetime_client() + .publish_puzzle_work(PuzzlePublishRecordInput { + session_id: session_id.clone(), + owner_user_id: owner_user_id.clone(), + // 发布沿用 session 派生的稳定作品 ID,避免草稿卡与已发布卡重复。 + work_id: work_id.clone(), + profile_id, + author_display_name, + work_title: payload.work_title.clone(), + work_description: payload.work_description.clone(), + level_name: payload.level_name.clone(), + summary: payload.summary.clone(), + theme_tags: payload.theme_tags.clone(), + levels_json, + published_at_micros: now, + }) + .await + .map_err(map_puzzle_client_error) + }, + ) + .await + .map_err(|error| { + puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) + })?; + + let session = state + .spacetime_client() + .get_puzzle_agent_session(session_id.clone(), owner_user_id.clone()) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + return Ok(json_success_body( + Some(&request_context), + PuzzleAgentActionResponse { + operation: PuzzleAgentOperationResponse { + operation_id: profile.profile_id.clone(), + operation_type: "publish_puzzle_work".to_string(), + status: "completed".to_string(), + phase_label: "作品发布".to_string(), + phase_detail: "拼图作品已发布到广场。".to_string(), + progress: 100, + error: None, + }, + session: map_puzzle_agent_session_response(session), + }, + )); + } + other => { + return Err(puzzle_bad_request( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + format!("action `{other}` is not supported").as_str(), + )); + } + }; + + let session = session?; + + Ok(json_success_body( + Some(&request_context), + PuzzleAgentActionResponse { + operation: PuzzleAgentOperationResponse { + operation_id: session.session_id.clone(), + operation_type: operation_type.to_string(), + status: "completed".to_string(), + phase_label: phase_label.to_string(), + phase_detail: phase_detail.to_string(), + progress: 100, + error: None, + }, + session: map_puzzle_agent_session_response(session), + }, + )) +} + +pub async fn get_puzzle_works( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + let items = state + .spacetime_client() + .list_puzzle_works(authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_WORKS_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleWorksResponse { + items: items + .into_iter() + .map(|item| map_puzzle_work_summary_response(&state, item)) + .collect(), + }, + )) +} + +pub async fn get_puzzle_work_detail( + State(state): State, + AxumPath(profile_id): AxumPath, + Extension(request_context): Extension, + Extension(_authenticated): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + PUZZLE_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; + + let item = state + .spacetime_client() + .get_puzzle_work_detail(profile_id) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_WORKS_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleWorkDetailResponse { + item: map_puzzle_work_profile_response(&state, item), + }, + )) +} + +pub async fn put_puzzle_work( + State(state): State, + AxumPath(profile_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_WORKS_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_WORKS_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + ensure_non_empty( + &request_context, + PUZZLE_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; + + let item = state + .spacetime_client() + .update_puzzle_work(PuzzleWorkUpsertRecordInput { + profile_id, + owner_user_id: authenticated.claims().user_id().to_string(), + work_title: payload.work_title, + work_description: payload.work_description, + level_name: payload.level_name, + summary: payload.summary, + theme_tags: payload.theme_tags, + cover_image_src: payload.cover_image_src, + cover_asset_id: payload.cover_asset_id, + levels_json: Some(serialize_puzzle_levels_response( + &request_context, + &payload.levels, + )?), + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_WORKS_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleWorkMutationResponse { + item: map_puzzle_work_profile_response(&state, item), + }, + )) +} + +pub async fn delete_puzzle_work( + State(state): State, + AxumPath(profile_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + PUZZLE_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; + + let items = state + .spacetime_client() + .delete_puzzle_work(profile_id, authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_WORKS_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleWorksResponse { + items: items + .into_iter() + .map(|item| map_puzzle_work_summary_response(&state, item)) + .collect(), + }, + )) +} + +pub async fn claim_puzzle_work_point_incentive( + State(state): State, + AxumPath(profile_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + PUZZLE_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; + + let item = state + .spacetime_client() + .claim_puzzle_work_point_incentive(PuzzleWorkPointIncentiveClaimRecordInput { + profile_id, + owner_user_id: authenticated.claims().user_id().to_string(), + claimed_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_WORKS_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleWorkMutationResponse { + item: map_puzzle_work_profile_response(&state, item), + }, + )) +} + +pub async fn list_puzzle_gallery( + State(state): State, + Extension(request_context): Extension, +) -> Result { + if let Some(response) = state.puzzle_gallery_cache().read_fresh_response().await { + crate::telemetry::record_puzzle_gallery_cache_hit(); + return Ok(puzzle_gallery_cached_json(&request_context, response)); + } + crate::telemetry::record_puzzle_gallery_cache_miss(); + let _rebuild_guard = state.puzzle_gallery_cache().acquire_rebuild_guard().await; + if let Some(response) = state.puzzle_gallery_cache().read_fresh_response().await { + crate::telemetry::record_puzzle_gallery_cache_hit(); + return Ok(puzzle_gallery_cached_json(&request_context, response)); + } + + let rebuild_started_at = std::time::Instant::now(); + let items = state + .spacetime_client() + .list_puzzle_gallery() + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_GALLERY_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + let response = build_puzzle_gallery_window_response( + items + .into_iter() + .map(|item| map_puzzle_gallery_card_response(&state, item)) + .collect(), + ); + let cached_response = state + .puzzle_gallery_cache() + .store_response(response) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_GALLERY_PROVIDER, + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": PUZZLE_GALLERY_PROVIDER, + "message": format!("拼图广场缓存序列化失败:{error}"), + })), + ) + })?; + crate::telemetry::record_puzzle_gallery_cache_rebuild( + rebuild_started_at.elapsed(), + cached_response.data_json_len(), + ); + + Ok(puzzle_gallery_cached_json( + &request_context, + cached_response, + )) +} + +pub async fn get_puzzle_gallery_detail( + State(state): State, + AxumPath(profile_id): AxumPath, + Extension(request_context): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + PUZZLE_GALLERY_PROVIDER, + &profile_id, + "profileId", + )?; + + let item = state + .spacetime_client() + .get_puzzle_gallery_detail(profile_id) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_GALLERY_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleGalleryDetailResponse { + item: map_puzzle_work_profile_response(&state, item), + }, + )) +} + +pub async fn record_puzzle_gallery_like( + State(state): State, + AxumPath(profile_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + PUZZLE_GALLERY_PROVIDER, + &profile_id, + "profileId", + )?; + + let item = state + .spacetime_client() + .record_puzzle_work_like(PuzzleWorkLikeReportRecordInput { + profile_id, + user_id: authenticated.claims().user_id().to_string(), + liked_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_GALLERY_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleGalleryDetailResponse { + item: map_puzzle_work_profile_response(&state, item), + }, + )) +} + +pub async fn remix_puzzle_gallery_work( + State(state): State, + AxumPath(profile_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + PUZZLE_GALLERY_PROVIDER, + &profile_id, + "profileId", + )?; + + let owner_user_id = authenticated.claims().user_id().to_string(); + let session = state + .spacetime_client() + .remix_puzzle_work(PuzzleWorkRemixRecordInput { + source_profile_id: profile_id, + target_owner_user_id: owner_user_id, + target_session_id: build_prefixed_uuid_id("puzzle-session-"), + target_profile_id: build_prefixed_uuid_id("puzzle-profile-"), + target_work_id: build_prefixed_uuid_id("puzzle-work-"), + author_display_name: resolve_author_display_name(&state, &authenticated), + welcome_message_id: build_prefixed_uuid_id("puzzle-message-"), + remixed_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_GALLERY_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleAgentSessionResponse { + session: map_puzzle_agent_session_response(session), + }, + )) +} + +pub async fn start_puzzle_run( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_RUNTIME_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + ensure_non_empty( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + &payload.profile_id, + "profileId", + )?; + + let run = state + .spacetime_client() + .start_puzzle_run(PuzzleRunStartRecordInput { + run_id: build_prefixed_uuid_id("puzzle-run-"), + owner_user_id: authenticated.claims().user_id().to_string(), + profile_id: payload.profile_id.clone(), + level_id: payload.level_id.clone(), + started_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + record_work_play_start_after_success( + &state, + &request_context, + WorkPlayTrackingDraft::new( + "puzzle", + payload.profile_id.clone(), + &authenticated, + "/api/runtime/puzzle/...", + ) + .profile_id(payload.profile_id.clone()) + .extra(json!({ + "levelId": payload.level_id, + "runId": run.run_id, + })), + ) + .await; + + Ok(json_success_body( + Some(&request_context), + PuzzleRunResponse { + run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), + }, + )) +} + +pub async fn get_puzzle_run( + State(state): State, + AxumPath(run_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; + + let run = state + .spacetime_client() + .get_puzzle_run(run_id, authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleRunResponse { + run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), + }, + )) +} + +pub async fn swap_puzzle_pieces( + State(state): State, + AxumPath(run_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_RUNTIME_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; + ensure_non_empty( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + &payload.first_piece_id, + "firstPieceId", + )?; + ensure_non_empty( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + &payload.second_piece_id, + "secondPieceId", + )?; + + let run = state + .spacetime_client() + .swap_puzzle_pieces(PuzzleRunSwapRecordInput { + run_id, + owner_user_id: authenticated.claims().user_id().to_string(), + first_piece_id: payload.first_piece_id, + second_piece_id: payload.second_piece_id, + swapped_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleRunResponse { + run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), + }, + )) +} + +pub async fn drag_puzzle_piece_or_group( + State(state): State, + AxumPath(run_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_RUNTIME_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; + ensure_non_empty( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + &payload.piece_id, + "pieceId", + )?; + + let run = state + .spacetime_client() + .drag_puzzle_piece_or_group(PuzzleRunDragRecordInput { + run_id, + owner_user_id: authenticated.claims().user_id().to_string(), + piece_id: payload.piece_id, + target_row: payload.target_row, + target_col: payload.target_col, + dragged_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleRunResponse { + run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), + }, + )) +} + +pub async fn advance_puzzle_next_level( + State(state): State, + AxumPath(run_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; + let payload = match payload { + Ok(Json(payload)) => payload, + Err(error) if error.status() == StatusCode::UNSUPPORTED_MEDIA_TYPE => { + AdvancePuzzleNextLevelRequest { + target_profile_id: None, + } + } + Err(error) => { + return Err(puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_RUNTIME_PROVIDER, + "message": error.body_text(), + })), + )); + } + }; + + let run = state + .spacetime_client() + .advance_puzzle_next_level(spacetime_client::PuzzleRunNextLevelRecordInput { + run_id, + owner_user_id: authenticated.claims().user_id().to_string(), + target_profile_id: payload.target_profile_id, + advanced_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleRunResponse { + run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), + }, + )) +} + +pub async fn update_puzzle_run_pause( + State(state): State, + AxumPath(run_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_RUNTIME_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; + + let run = state + .spacetime_client() + .update_puzzle_run_pause(PuzzleRunPauseRecordInput { + run_id, + owner_user_id: authenticated.claims().user_id().to_string(), + paused: payload.paused, + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleRunResponse { + run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), + }, + )) +} + +pub async fn use_puzzle_runtime_prop( + State(state): State, + AxumPath(run_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_RUNTIME_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; + ensure_non_empty( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + &payload.prop_kind, + "propKind", + )?; + + let owner_user_id = authenticated.claims().user_id().to_string(); + let prop_kind = payload.prop_kind.trim().to_string(); + let billing_asset_kind = match prop_kind.as_str() { + "hint" => "puzzle_prop_hint", + "reference" => "puzzle_prop_preview", + "freezeTime" | "freeze_time" => "puzzle_prop_freeze_time", + "extendTime" | "extend_time" => "puzzle_prop_extend_time", + _ => { + return Err(puzzle_bad_request( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + "unknown puzzle prop kind", + )); + } + }; + let should_sync_freeze_boundary = matches!(prop_kind.as_str(), "freezeTime" | "freeze_time"); + let billing_asset_id = format!("{}:{}:{}", run_id, prop_kind, current_utc_micros()); + let reducer_owner_user_id = owner_user_id.clone(); + let reducer_run_id = run_id.clone(); + let fallback_run_id = run_id.clone(); + let fallback_owner_user_id = owner_user_id.clone(); + let run_result = execute_billable_asset_operation( + &state, + &owner_user_id, + billing_asset_kind, + billing_asset_id.as_str(), + async { + state + .spacetime_client() + .use_puzzle_runtime_prop(PuzzleRunPropRecordInput { + run_id: reducer_run_id, + owner_user_id: reducer_owner_user_id, + prop_kind, + used_at_micros: current_utc_micros(), + spent_points: crate::asset_billing::ASSET_OPERATION_POINTS_COST, + }) + .await + .map_err(map_puzzle_client_error) + }, + ) + .await; + + let run = match run_result { + Ok(run) => run, + Err(error) if should_sync_puzzle_freeze_boundary(&error, should_sync_freeze_boundary) => { + // 中文注释:冻结确认窗打开时前端会暂停视觉计时,但正式 run 仍可能在服务端边界帧先结算失败。 + // 这类情况已由扣费包装器退款,此处只同步失败态快照,避免玩家看到“操作不合法”。 + state + .spacetime_client() + .get_puzzle_run(fallback_run_id, fallback_owner_user_id) + .await + .map_err(map_puzzle_client_error) + .map_err(|error| { + puzzle_error_response(&request_context, PUZZLE_RUNTIME_PROVIDER, error) + })? + } + Err(error) => { + return Err(puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + error, + )); + } + }; + + Ok(json_success_body( + Some(&request_context), + PuzzleRunResponse { + run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), + }, + )) +} + +pub async fn submit_puzzle_leaderboard( + State(state): State, + AxumPath(run_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_RUNTIME_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + + ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?; + + let run = state + .spacetime_client() + .submit_puzzle_leaderboard_entry(PuzzleLeaderboardSubmitRecordInput { + run_id, + owner_user_id: authenticated.claims().user_id().to_string(), + profile_id: payload.profile_id, + grid_size: payload.grid_size, + elapsed_ms: payload.elapsed_ms.max(1_000), + nickname: payload.nickname.trim().to_string(), + submitted_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_RUNTIME_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleRunResponse { + run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), + }, + )) +} diff --git a/server-rs/crates/api-server/src/puzzle/mappers.rs b/server-rs/crates/api-server/src/puzzle/mappers.rs index e60e6900..e988809c 100644 --- a/server-rs/crates/api-server/src/puzzle/mappers.rs +++ b/server-rs/crates/api-server/src/puzzle/mappers.rs @@ -278,9 +278,73 @@ pub(super) fn map_puzzle_result_preview_finding_response( } } +fn resolve_puzzle_work_generation_status(item: &PuzzleWorkProfileRecord) -> Option { + item.levels + .iter() + .map(|level| level.generation_status.trim()) + .find(|status| *status == "generating") + .or_else(|| { + item.levels + .iter() + .map(|level| level.generation_status.trim()) + .find(|status| *status == "ready") + }) + .or_else(|| { + item.levels + .iter() + .map(|level| level.generation_status.trim()) + .find(|status| !status.is_empty()) + }) + .map(str::to_string) +} + pub(super) fn map_puzzle_work_summary_response( state: &AppState, item: PuzzleWorkProfileRecord, +) -> PuzzleWorkSummaryResponse { + let generation_status = resolve_puzzle_work_generation_status(&item); + let author = resolve_work_author_by_user_id( + state, + &item.owner_user_id, + Some(&item.author_display_name), + None, + ); + PuzzleWorkSummaryResponse { + work_id: item.work_id, + profile_id: item.profile_id, + owner_user_id: item.owner_user_id, + source_session_id: item.source_session_id, + author_display_name: author.display_name, + work_title: item.work_title, + work_description: item.work_description, + level_name: item.level_name, + summary: item.summary, + theme_tags: item.theme_tags, + cover_image_src: item.cover_image_src, + cover_asset_id: item.cover_asset_id, + publication_status: item.publication_status, + updated_at: item.updated_at, + published_at: item.published_at, + play_count: item.play_count, + remix_count: item.remix_count, + like_count: item.like_count, + recent_play_count_7d: item.recent_play_count_7d, + point_incentive_total_half_points: item.point_incentive_total_half_points, + point_incentive_claimed_points: item.point_incentive_claimed_points, + point_incentive_total_points: item.point_incentive_total_half_points as f64 / 2.0, + point_incentive_claimable_points: item + .point_incentive_total_half_points + .saturating_div(2) + .saturating_sub(item.point_incentive_claimed_points), + publish_ready: item.publish_ready, + generation_status, + levels: Vec::new(), + } +} + +pub(super) fn map_puzzle_gallery_card_response( + state: &AppState, + item: PuzzleGalleryCardRecord, ) -> PuzzleWorkSummaryResponse { let author = resolve_work_author_by_user_id( state, @@ -316,6 +380,7 @@ pub(super) fn map_puzzle_work_summary_response( .saturating_div(2) .saturating_sub(item.point_incentive_claimed_points), publish_ready: item.publish_ready, + generation_status: item.generation_status, levels: Vec::new(), } } diff --git a/server-rs/crates/api-server/src/puzzle/tests.rs b/server-rs/crates/api-server/src/puzzle/tests.rs new file mode 100644 index 00000000..ec169758 --- /dev/null +++ b/server-rs/crates/api-server/src/puzzle/tests.rs @@ -0,0 +1,880 @@ +use super::*; + +#[test] +fn puzzle_generated_image_size_is_square_1_1() { + assert_eq!(PUZZLE_GENERATED_IMAGE_SIZE, "1024*1024"); + assert_eq!(PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, "1024x1024"); +} + +#[test] +fn puzzle_vector_engine_request_uses_gpt_image_2_all_and_reference_images() { + let body = build_puzzle_vector_engine_image_request_body( + PuzzleImageModel::Gemini31FlashPreview, + "一只猫在雨夜灯牌下回头。", + PUZZLE_DEFAULT_NEGATIVE_PROMPT, + PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, + 4, + None, + ); + + assert_eq!(body["model"], VECTOR_ENGINE_GPT_IMAGE_2_MODEL); + assert_eq!(body["size"], PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE); + assert_eq!(body["n"], 1); + assert!(body.get("official_fallback").is_none()); + assert!(body.get("image").is_none()); + assert!( + body["prompt"] + .as_str() + .unwrap_or_default() + .contains("文字水印") + ); +} + +#[test] +fn puzzle_vector_engine_generation_fallback_includes_reference_image() { + let image = image::DynamicImage::ImageRgb8(image::RgbImage::new(4, 4)); + let mut cursor = std::io::Cursor::new(Vec::new()); + image + .write_to(&mut cursor, ImageFormat::Png) + .expect("test image should encode"); + let reference_image = PuzzleResolvedReferenceImage { + mime_type: "image/png".to_string(), + bytes_len: cursor.get_ref().len(), + bytes: cursor.into_inner(), + }; + + let body = build_puzzle_vector_engine_image_request_body( + PuzzleImageModel::GptImage2, + "参考图里的小猫做成拼图主图。", + PUZZLE_DEFAULT_NEGATIVE_PROMPT, + PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, + 1, + Some(&reference_image), + ); + + let images = body["image"] + .as_array() + .expect("fallback generation should include reference image array"); + assert_eq!(images.len(), 1); + assert!( + images[0] + .as_str() + .unwrap_or_default() + .starts_with("data:image/png;base64,") + ); +} + +#[test] +fn puzzle_vector_engine_edit_url_uses_images_edits_endpoint() { + let settings = PuzzleVectorEngineSettings { + base_url: "https://vector.example/v1".to_string(), + api_key: "test-key".to_string(), + }; + + assert_eq!( + puzzle_vector_engine_images_edit_url(&settings), + "https://vector.example/v1/images/edits" + ); +} + +#[test] +fn puzzle_vector_engine_edit_response_decodes_b64_image() { + let images = puzzle_images_from_base64( + "edit-1".to_string(), + vec![BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest")], + 1, + ); + + assert_eq!(images.images.len(), 1); + assert_eq!(images.images[0].mime_type, "image/png"); + assert_eq!(images.images[0].extension, "png"); +} + +#[test] +fn puzzle_vector_engine_prompt_strongly_uses_reference_image() { + let prompt = build_puzzle_vector_engine_generation_prompt("请生成雨夜猫街。", true); + + assert!(prompt.contains("参考图作为第一优先级")); + assert!(prompt.contains("严格保留参考图的主要主体、构图关系、视角、姿态、配色和光影氛围")); + assert!(prompt.contains("请生成雨夜猫街。")); +} + +#[test] +fn puzzle_vector_engine_prompt_keeps_text_only_prompt_unchanged() { + let prompt = build_puzzle_vector_engine_generation_prompt("请生成雨夜猫街。", false); + + assert_eq!(prompt, "请生成雨夜猫街。"); +} + +#[test] +fn puzzle_reference_image_edit_requires_ai_redraw() { + assert!(!should_use_puzzle_reference_image_edit(None, true)); + assert!(!should_use_puzzle_reference_image_edit( + Some("data:image/png;base64,abcd"), + false + )); + assert!(should_use_puzzle_reference_image_edit( + Some("data:image/png;base64,abcd"), + true + )); +} + +#[test] +fn puzzle_reference_image_sources_are_deduped_and_limited() { + let sources = collect_puzzle_reference_image_sources( + Some("data:image/png;base64,a"), + &[ + "data:image/png;base64,a".to_string(), + "data:image/png;base64,b".to_string(), + "data:image/png;base64,c".to_string(), + "data:image/png;base64,d".to_string(), + "data:image/png;base64,e".to_string(), + "data:image/png;base64,f".to_string(), + ], + ); + + assert_eq!(sources.len(), 5); + assert_eq!(sources[0], "data:image/png;base64,a"); + assert_eq!(sources[1], "data:image/png;base64,b"); + assert!(!sources.contains(&"data:image/png;base64,f".to_string())); +} + +#[test] +fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() { + let error = map_puzzle_vector_engine_request_error( + "创建拼图 VectorEngine 图片生成任务失败:operation timed out".to_string(), + ); + + let response = error.into_response(); + assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT); +} + +#[test] +fn puzzle_vector_engine_upstream_timeout_maps_to_gateway_timeout() { + let error = map_puzzle_vector_engine_upstream_error( + reqwest::StatusCode::GATEWAY_TIMEOUT, + r#"{"error":{"message":"VectorEngine edit endpoint timeout"}}"#, + "创建拼图 VectorEngine 图片编辑任务失败", + ); + + let response = error.into_response(); + assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT); +} + +#[test] +fn puzzle_reference_edit_fallback_only_accepts_timeout_errors() { + let timeout_error = map_puzzle_vector_engine_upstream_error( + reqwest::StatusCode::GATEWAY_TIMEOUT, + r#"{"error":{"message":"VectorEngine edit endpoint timeout"}}"#, + "创建拼图 VectorEngine 图片编辑任务失败", + ); + assert!(should_fallback_puzzle_reference_edit_to_generation( + &timeout_error + )); + + let auth_error = map_puzzle_vector_engine_upstream_error( + reqwest::StatusCode::UNAUTHORIZED, + r#"{"error":{"message":"invalid api key"}}"#, + "创建拼图 VectorEngine 图片编辑任务失败", + ); + assert!(!should_fallback_puzzle_reference_edit_to_generation( + &auth_error + )); +} + +#[test] +fn puzzle_vector_engine_reqwest_error_maps_to_bad_gateway() { + let error = match reqwest::Client::new().get("http://[::1").build() { + Ok(_) => panic!("invalid url should fail request build"), + Err(error) => error, + }; + let app_error = map_puzzle_vector_engine_reqwest_error( + "创建拼图 VectorEngine 图片编辑任务失败", + "https://api.vectorengine.ai/v1/images/edits", + error, + ); + + let response = app_error.into_response(); + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); +} + +#[test] +fn puzzle_compile_error_preserves_vector_engine_unavailable_status() { + let error = map_puzzle_compile_error(SpacetimeClientError::Runtime( + "VECTOR_ENGINE_API_KEY 未配置".to_string(), + )); + + let response = error.into_response(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); +} + +#[tokio::test] +async fn puzzle_compile_error_normalizes_legacy_apimart_image_message() { + let error = map_puzzle_compile_error(SpacetimeClientError::Runtime( + "APIMart 图片生成密钥未配置".to_string(), + )); + + let response = error.into_response(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let body = response.into_body(); + let bytes = axum::body::to_bytes(body, usize::MAX) + .await + .expect("body bytes should read"); + let payload: Value = + serde_json::from_slice(&bytes).expect("error response should be valid json"); + assert_eq!( + payload["error"]["details"]["provider"], + Value::String(VECTOR_ENGINE_PROVIDER.to_string()) + ); + assert_eq!( + payload["error"]["details"]["message"], + Value::String("VectorEngine 图片生成密钥未配置".to_string()) + ); +} + +#[test] +fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() { + let levels_json = serde_json::to_string(&vec![json!({ + "level_id": "puzzle-level-1", + "level_name": "雨夜猫街", + "picture_description": "一只猫在雨夜灯牌下回头。", + "candidates": [], + "selected_candidate_id": null, + "cover_image_src": null, + "cover_asset_id": null, + "generation_status": "idle", + })]) + .expect("levels json"); + let payload = ExecutePuzzleAgentActionRequest { + action: "generate_puzzle_images".to_string(), + prompt_text: None, + reference_image_src: None, + reference_image_srcs: Vec::new(), + image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()), + ai_redraw: None, + candidate_count: Some(1), + candidate_id: None, + level_id: Some("puzzle-level-1".to_string()), + work_title: Some("暖灯猫街作品".to_string()), + work_description: Some("一套雨夜猫街主题拼图。".to_string()), + picture_description: None, + level_name: None, + summary: Some("当前关卡画面。".to_string()), + theme_tags: Some(vec!["猫咪".to_string(), "雨夜".to_string()]), + levels_json: Some(levels_json.clone()), + }; + + let session = build_puzzle_session_snapshot_from_action_payload( + "puzzle-session-1", + &payload, + Some(levels_json.as_str()), + 1_713_686_401_234_567, + ) + .expect("fallback session"); + + let draft = session.draft.expect("draft"); + assert_eq!(session.stage, "ready_to_publish"); + assert_eq!(draft.work_title, "暖灯猫街作品"); + assert_eq!(draft.theme_tags, vec!["猫咪", "雨夜"]); + assert_eq!(draft.levels[0].level_id, "puzzle-level-1"); + assert_eq!( + draft.levels[0].picture_description, + "一只猫在雨夜灯牌下回头。" + ); +} + +#[test] +fn puzzle_first_level_name_parser_accepts_json_and_normalizes_text() { + assert_eq!( + parse_puzzle_first_level_name_from_text(r#"{"levelName":"雨夜猫街"}"#), + Some("雨夜猫街".to_string()) + ); + assert_eq!( + parse_puzzle_first_level_name_from_text("1. 《暖灯猫街》"), + Some("暖灯猫街".to_string()) + ); + assert_eq!( + parse_puzzle_first_level_name_from_text(r#"{"levelName":"雨夜猫街画面"}"#), + Some("雨夜猫街".to_string()) + ); + assert_eq!( + parse_puzzle_first_level_name_from_text(r#"{"levelNam"#), + None + ); +} + +#[test] +fn puzzle_level_naming_parser_accepts_metadata_and_ui_background_prompt() { + let naming = parse_puzzle_level_naming_from_text( + r#"{"levelName":"雨夜猫街","workDescription":"在湿润灯牌与猫影之间完成一套雨夜街角拼图。","workTags":["雨夜","猫咪","灯牌","街角","暖色","插画"],"uiBackgroundPrompt":"雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次"}"#, + ) + .expect("naming should parse"); + + assert_eq!(naming.level_name, "雨夜猫街"); + assert_eq!( + naming.work_description.as_deref(), + Some("在湿润灯牌与猫影之间完成一套雨夜街角拼图") + ); + assert_eq!(naming.work_tags.len(), module_puzzle::PUZZLE_MAX_TAG_COUNT); + assert!(naming.work_tags.contains(&"雨夜".to_string())); + assert!(naming.work_tags.contains(&"猫咪".to_string())); + assert!(naming.work_tags.contains(&"灯牌".to_string())); + assert_eq!( + naming.ui_background_prompt.as_deref(), + Some("雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次") + ); +} + +#[test] +fn puzzle_level_naming_parser_filters_forbidden_ui_prompt_words() { + let naming = parse_puzzle_level_naming_from_text( + r#"{"levelName":"雨夜猫街","uiBackgroundPrompt":"雨夜老街背景,中央不要出现拼图槽、棋盘、HUD、按钮、文字或水印,保留暖色灯光"}"#, + ) + .expect("naming should parse"); + let prompt = naming + .ui_background_prompt + .as_deref() + .expect("prompt should parse"); + + assert!(!prompt.contains("拼图槽")); + assert!(!prompt.contains("棋盘")); + assert!(!prompt.contains("HUD")); + assert!(!prompt.contains("按钮")); + assert!(!prompt.contains("文字")); + assert!(!prompt.contains("水印")); +} + +#[test] +fn puzzle_first_level_name_fallback_uses_picture_keywords() { + assert_eq!( + build_fallback_puzzle_first_level_name("一只猫在雨夜灯牌下回头。"), + "雨夜猫街" + ); + assert_eq!( + build_fallback_puzzle_first_level_name("看不出关键词的抽象色块。"), + "奇境初见" + ); +} + +#[test] +fn puzzle_level_name_image_data_url_downsizes_generated_image() { + let image = image::DynamicImage::ImageRgb8(image::RgbImage::new(4, 4)); + let mut cursor = std::io::Cursor::new(Vec::new()); + image + .write_to(&mut cursor, ImageFormat::Png) + .expect("test image should encode"); + let downloaded = PuzzleDownloadedImage { + extension: "png".to_string(), + mime_type: "image/png".to_string(), + bytes: cursor.into_inner(), + }; + + let data_url = + build_puzzle_level_name_image_data_url(&downloaded).expect("data url should be generated"); + + assert!(data_url.starts_with("data:image/png;base64,")); + assert!(data_url.len() > "data:image/png;base64,".len()); +} + +#[test] +fn puzzle_first_level_name_snapshot_defaults_work_title() { + let levels_json = serde_json::to_string(&vec![json!({ + "level_id": "puzzle-level-1", + "level_name": "猫画面", + "picture_description": "一只猫在雨夜灯牌下回头。", + "candidates": [], + "selected_candidate_id": null, + "cover_image_src": null, + "cover_asset_id": null, + "generation_status": "idle", + })]) + .expect("levels json"); + let payload = ExecutePuzzleAgentActionRequest { + action: "generate_puzzle_images".to_string(), + prompt_text: None, + reference_image_src: None, + reference_image_srcs: Vec::new(), + image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()), + ai_redraw: None, + candidate_count: Some(1), + candidate_id: None, + level_id: Some("puzzle-level-1".to_string()), + work_title: Some("猫画面".to_string()), + work_description: None, + picture_description: None, + level_name: None, + summary: None, + theme_tags: Some(vec![]), + levels_json: Some(levels_json.clone()), + }; + let session = build_puzzle_session_snapshot_from_action_payload( + "puzzle-session-1", + &payload, + Some(levels_json.as_str()), + 1_713_686_401_234_567, + ) + .expect("fallback session"); + + let renamed = apply_generated_puzzle_first_level_name_to_session_snapshot( + session, + "puzzle-level-1", + "雨夜猫街", + "猫画面", + 1_713_686_401_234_568, + ); + let draft = renamed.draft.expect("draft"); + assert_eq!(draft.level_name, "雨夜猫街"); + assert_eq!(draft.work_title, "雨夜猫街"); + assert_eq!(draft.levels[0].level_name, "雨夜猫街"); +} + +#[test] +fn puzzle_initial_metadata_defaults_empty_work_description_and_tags() { + let mut session = PuzzleAgentSessionRecord { + session_id: "puzzle-session-1".to_string(), + seed_text: "画面描述:一只猫在雨夜灯牌下回头。".to_string(), + current_turn: 1, + progress_percent: 94, + stage: "ready_to_publish".to_string(), + anchor_pack: test_puzzle_anchor_pack_record(), + draft: Some(test_puzzle_draft_record()), + messages: Vec::new(), + last_assistant_reply: None, + published_profile_id: None, + suggested_actions: Vec::new(), + result_preview: None, + updated_at: "2024-01-01T00:00:00Z".to_string(), + }; + { + let draft = session.draft.as_mut().expect("draft"); + draft.work_title = "猫画面".to_string(); + draft.work_description = String::new(); + draft.summary = String::new(); + draft.theme_tags = Vec::new(); + } + let metadata = PuzzleLevelNaming { + level_name: "雨夜猫街".to_string(), + work_description: Some("在湿润灯牌与猫影之间完成一套雨夜街角拼图".to_string()), + work_tags: vec![ + "插画".to_string(), + "灯牌".to_string(), + "街角".to_string(), + "猫咪".to_string(), + "暖色".to_string(), + "雨夜".to_string(), + ], + ui_background_prompt: None, + }; + + let session = apply_generated_puzzle_initial_metadata_to_session_snapshot( + session, + &metadata, + "猫画面", + 1_713_686_401_234_568, + ); + + let draft = session.draft.expect("draft"); + assert_eq!(draft.work_title, "雨夜猫街"); + assert_eq!( + draft.work_description, + "在湿润灯牌与猫影之间完成一套雨夜街角拼图" + ); + assert_eq!(draft.summary, draft.work_description); + assert_eq!(draft.theme_tags, metadata.work_tags); +} + +#[test] +fn puzzle_level_audio_asset_roundtrips_between_response_and_module_json() { + let level = PuzzleDraftLevelResponse { + level_id: "puzzle-level-1".to_string(), + level_name: "雨夜猫街".to_string(), + picture_description: "一只猫在雨夜灯牌下回头。".to_string(), + picture_reference: None, + ui_background_prompt: None, + ui_background_image_src: None, + ui_background_image_object_key: None, + background_music: Some(CreationAudioAsset { + task_id: "suno-task-1".to_string(), + provider: "vector-engine-suno".to_string(), + asset_object_id: Some("assetobj_1".to_string()), + asset_kind: Some("puzzle_background_music".to_string()), + audio_src: "/generated-puzzle-assets/audio.mp3".to_string(), + prompt: Some("轻快拼图音乐".to_string()), + title: Some("雨夜猫街背景音乐".to_string()), + updated_at: Some("2026-05-11T00:00:00Z".to_string()), + }), + candidates: vec![], + selected_candidate_id: None, + cover_image_src: None, + cover_asset_id: None, + generation_status: "ready".to_string(), + }; + let request_context = RequestContext::new( + "test-request".to_string(), + "PUT /api/runtime/puzzle/works/test".to_string(), + Duration::ZERO, + false, + ); + + let levels_json = serialize_puzzle_levels_response(&request_context, &[level]) + .expect("levels should serialize"); + let payload: Value = serde_json::from_str(&levels_json).expect("levels json should parse"); + assert_eq!( + payload[0]["background_music"]["audio_src"], + Value::String("/generated-puzzle-assets/audio.mp3".to_string()) + ); + assert!(payload[0]["background_music"].get("audioSrc").is_none()); + + let records = parse_puzzle_level_records_from_module_json(&levels_json) + .expect("levels should map back into records"); + let music = records[0] + .background_music + .as_ref() + .expect("background music should exist"); + assert_eq!(music.audio_src, "/generated-puzzle-assets/audio.mp3"); + assert_eq!(music.asset_kind.as_deref(), Some("puzzle_background_music")); + + let response = map_puzzle_draft_level_response(records[0].clone()); + assert_eq!( + response + .background_music + .as_ref() + .map(|asset| asset.audio_src.as_str()), + Some("/generated-puzzle-assets/audio.mp3") + ); +} + +#[test] +fn puzzle_ui_background_fields_roundtrip_between_response_and_module_json() { + let level = PuzzleDraftLevelResponse { + level_id: "puzzle-level-1".to_string(), + level_name: "雨夜猫街".to_string(), + picture_description: "一只猫在雨夜灯牌下回头。".to_string(), + picture_reference: None, + ui_background_prompt: Some("雨夜猫街竖屏拼图UI背景".to_string()), + ui_background_image_src: Some( + "/generated-puzzle-assets/session/ui/background.png".to_string(), + ), + ui_background_image_object_key: Some( + "generated-puzzle-assets/session/ui/background.png".to_string(), + ), + background_music: None, + candidates: vec![], + selected_candidate_id: None, + cover_image_src: Some("/generated-puzzle-assets/session/cover.png".to_string()), + cover_asset_id: Some("asset-1".to_string()), + generation_status: "ready".to_string(), + }; + let request_context = RequestContext::new( + "test-request".to_string(), + "PUT /api/runtime/puzzle/works/test".to_string(), + Duration::ZERO, + false, + ); + + let levels_json = serialize_puzzle_levels_response(&request_context, &[level]) + .expect("levels should serialize"); + let payload: Value = serde_json::from_str(&levels_json).expect("levels json should parse"); + assert_eq!( + payload[0]["ui_background_prompt"], + Value::String("雨夜猫街竖屏拼图UI背景".to_string()) + ); + assert!(payload[0].get("uiBackgroundPrompt").is_none()); + + let records = parse_puzzle_level_records_from_module_json(&levels_json) + .expect("levels should map back into records"); + assert_eq!( + records[0].ui_background_image_src.as_deref(), + Some("/generated-puzzle-assets/session/ui/background.png") + ); + + let response = map_puzzle_draft_level_response(records[0].clone()); + assert_eq!( + response.ui_background_image_object_key.as_deref(), + Some("generated-puzzle-assets/session/ui/background.png") + ); +} + +#[test] +fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() { + let state = AppState::new(crate::config::AppConfig::default()).expect("state should build"); + let level = PuzzleDraftLevelRecord { + level_id: "puzzle-level-1".to_string(), + level_name: "雨夜猫街".to_string(), + picture_description: "一只猫在雨夜灯牌下回头。".to_string(), + picture_reference: None, + ui_background_prompt: None, + ui_background_image_src: None, + ui_background_image_object_key: None, + background_music: None, + candidates: vec![PuzzleGeneratedImageCandidateRecord { + candidate_id: "candidate-1".to_string(), + image_src: "/generated-puzzle-assets/session/candidate-1.png".to_string(), + asset_id: "asset-1".to_string(), + prompt: "雨夜猫街".to_string(), + actual_prompt: None, + source_type: "generated".to_string(), + selected: true, + }], + selected_candidate_id: Some("candidate-1".to_string()), + cover_image_src: Some("/generated-puzzle-assets/session/cover.png".to_string()), + cover_asset_id: Some("asset-1".to_string()), + generation_status: "ready".to_string(), + }; + + let response = map_puzzle_work_summary_response( + &state, + PuzzleWorkProfileRecord { + work_id: "puzzle-work-1".to_string(), + profile_id: "puzzle-profile-1".to_string(), + owner_user_id: "user-1".to_string(), + source_session_id: Some("puzzle-session-1".to_string()), + 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()], + cover_image_src: None, + cover_asset_id: None, + publication_status: "draft".to_string(), + updated_at: "2026-05-08T00:00:00.000Z".to_string(), + published_at: None, + play_count: 0, + remix_count: 0, + like_count: 0, + recent_play_count_7d: 0, + point_incentive_total_half_points: 0, + point_incentive_claimed_points: 0, + publish_ready: false, + anchor_pack: test_puzzle_anchor_pack_record(), + levels: vec![level], + }, + ); + + assert_eq!(response.levels.len(), 1); + assert_eq!(response.generation_status.as_deref(), Some("ready")); + assert_eq!( + response.levels[0].cover_image_src.as_deref(), + Some("/generated-puzzle-assets/session/cover.png") + ); + assert_eq!( + response.levels[0].candidates[0].image_src, + "/generated-puzzle-assets/session/candidate-1.png" + ); +} + +#[test] +fn puzzle_ui_background_prompt_keeps_generated_slots_out_of_background() { + let prompt = build_puzzle_ui_background_request_prompt_for_test("雨夜猫街", "雨夜猫街主题背景"); + + assert!(prompt.contains("9:16")); + assert!(prompt.contains("纯背景图")); + assert!(prompt.contains("不得出现拼图槽")); + assert!(prompt.contains("默认拼图槽")); + assert!(prompt.contains("文字")); +} + +#[test] +fn puzzle_initial_ui_background_prompt_prefers_ai_generated_prompt() { + let mut draft = test_puzzle_draft_record(); + draft.work_title = "模板作品名".to_string(); + draft.work_description = "模板作品描述".to_string(); + let mut target_level = draft.levels[0].clone(); + target_level.level_name = "雨夜猫街".to_string(); + let ai_prompt = "雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次"; + target_level.ui_background_prompt = Some(ai_prompt.to_string()); + + let prompt = resolve_puzzle_initial_ui_background_prompt(&draft, &target_level); + + assert_eq!(prompt, ai_prompt); + assert!(!prompt.contains(PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER)); +} + +#[test] +fn puzzle_initial_ui_background_prompt_falls_back_to_context_template() { + let draft = test_puzzle_draft_record(); + let target_level = draft.levels[0].clone(); + + let prompt = resolve_puzzle_initial_ui_background_prompt(&draft, &target_level); + + assert!(prompt.contains("雨夜猫街")); + assert!(prompt.contains(PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER)); +} + +#[test] +fn puzzle_ui_background_initial_attach_updates_first_level_fields() { + let draft = test_puzzle_draft_record(); + let generated = GeneratedPuzzleUiBackgroundResponse { + image_src: "/generated-puzzle-assets/session/ui/background.png".to_string(), + object_key: "generated-puzzle-assets/session/ui/background.png".to_string(), + }; + let mut levels = draft.levels.clone(); + + attach_puzzle_level_ui_background( + &mut levels, + "puzzle-level-1", + "雨夜猫街移动端拼图UI背景".to_string(), + generated, + ); + + assert_eq!( + levels[0].ui_background_prompt.as_deref(), + Some("雨夜猫街移动端拼图UI背景") + ); + assert_eq!( + levels[0].ui_background_image_src.as_deref(), + Some("/generated-puzzle-assets/session/ui/background.png") + ); + assert_eq!( + levels[0].ui_background_image_object_key.as_deref(), + Some("generated-puzzle-assets/session/ui/background.png") + ); +} + +#[test] +fn puzzle_initial_draft_assets_must_include_ui_background() { + let mut draft = test_puzzle_draft_record(); + let missing_all = ensure_puzzle_initial_level_assets_ready(&draft.levels[0]) + .expect_err("缺少自动生成资产时不能把草稿标记为完成"); + assert_eq!(missing_all.status_code(), StatusCode::BAD_GATEWAY); + assert!(missing_all.body_text().contains("UI背景图")); + + draft.levels[0].ui_background_image_src = + Some("/generated-puzzle-assets/session/ui/background.png".to_string()); + ensure_puzzle_initial_level_assets_ready(&draft.levels[0]) + .expect("UI 背景存在时即可完成自动草稿资源检查"); +} + +fn test_puzzle_anchor_pack_record() -> PuzzleAnchorPackRecord { + let item = PuzzleAnchorItemRecord { + key: "visualSubject".to_string(), + label: "画面".to_string(), + value: "雨夜猫街".to_string(), + status: "confirmed".to_string(), + }; + + PuzzleAnchorPackRecord { + theme_promise: item.clone(), + visual_subject: item.clone(), + visual_mood: item.clone(), + composition_hooks: item.clone(), + tags_and_forbidden: item, + } +} + +fn test_puzzle_draft_record() -> PuzzleResultDraftRecord { + let anchor_pack = test_puzzle_anchor_pack_record(); + PuzzleResultDraftRecord { + work_title: "雨夜猫街".to_string(), + work_description: "一只猫在雨夜灯牌下回头。".to_string(), + level_name: "猫画面".to_string(), + summary: "一只猫在雨夜灯牌下回头。".to_string(), + theme_tags: vec![], + forbidden_directives: vec![], + creator_intent: None, + anchor_pack, + candidates: vec![], + selected_candidate_id: None, + cover_image_src: None, + cover_asset_id: None, + generation_status: "idle".to_string(), + levels: vec![PuzzleDraftLevelRecord { + level_id: "puzzle-level-1".to_string(), + level_name: "猫画面".to_string(), + picture_description: "一只猫在雨夜灯牌下回头。".to_string(), + picture_reference: None, + ui_background_prompt: None, + ui_background_image_src: None, + ui_background_image_object_key: None, + background_music: None, + candidates: vec![], + selected_candidate_id: None, + cover_image_src: None, + cover_asset_id: None, + generation_status: "idle".to_string(), + }], + form_draft: None, + } +} + +#[test] +fn puzzle_primary_level_update_preserves_reference_for_regeneration() { + let draft = test_puzzle_draft_record(); + let mut target_level = draft.levels[0].clone(); + target_level.level_name = "雨夜猫街".to_string(); + + let levels = build_puzzle_levels_with_primary_update( + &draft, + &target_level, + Some("data:image/png;base64,abcd"), + ); + + assert_eq!(levels[0].level_name, "雨夜猫街"); + assert_eq!( + levels[0].picture_reference.as_deref(), + Some("data:image/png;base64,abcd") + ); +} + +#[test] +fn puzzle_generated_fallback_snapshot_preserves_picture_reference() { + let anchor_pack = test_puzzle_anchor_pack_record(); + let session = PuzzleAgentSessionRecord { + session_id: "puzzle-session-1".to_string(), + seed_text: "雨夜猫街".to_string(), + current_turn: 1, + progress_percent: 0, + stage: "draft_ready".to_string(), + anchor_pack: anchor_pack.clone(), + draft: Some(test_puzzle_draft_record()), + messages: Vec::new(), + last_assistant_reply: None, + published_profile_id: None, + suggested_actions: Vec::new(), + result_preview: None, + updated_at: "2024-01-01T00:00:00Z".to_string(), + }; + let candidate = PuzzleGeneratedImageCandidateRecord { + candidate_id: "puzzle-session-1-candidate-1".to_string(), + image_src: "/generated-puzzle-assets/puzzle-session-1/1/cover.png".to_string(), + asset_id: "puzzle-cover-1".to_string(), + prompt: "雨夜猫街".to_string(), + actual_prompt: Some("雨夜猫街".to_string()), + source_type: "generated:gpt-image-2".to_string(), + selected: true, + }; + + let session = apply_generated_puzzle_candidates_to_session_snapshot( + session, + "puzzle-level-1", + vec![candidate], + Some("data:image/png;base64,abcd"), + 1_713_686_401_234_568, + ); + + let draft = session.draft.expect("draft"); + assert_eq!( + draft.levels[0].picture_reference.as_deref(), + Some("data:image/png;base64,abcd") + ); +} + +#[test] +fn freeze_boundary_sync_only_matches_freeze_invalid_operation() { + let invalid_operation = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "spacetimedb", + "message": "操作不合法", + })); + let other_error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "spacetimedb", + "message": "泥点余额不足", + })); + + assert!(should_sync_puzzle_freeze_boundary(&invalid_operation, true)); + assert!(!should_sync_puzzle_freeze_boundary( + &invalid_operation, + false + )); + assert!(!should_sync_puzzle_freeze_boundary(&other_error, true)); +} diff --git a/server-rs/crates/api-server/src/puzzle/vector_engine.rs b/server-rs/crates/api-server/src/puzzle/vector_engine.rs new file mode 100644 index 00000000..08fdb5bc --- /dev/null +++ b/server-rs/crates/api-server/src/puzzle/vector_engine.rs @@ -0,0 +1,1273 @@ +use super::*; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum PuzzleImageModel { + GptImage2, + Gemini31FlashPreview, +} + +impl PuzzleImageModel { + pub(crate) fn provider_name(self) -> &'static str { + VECTOR_ENGINE_PROVIDER + } + + pub(crate) fn request_model_name(self) -> &'static str { + VECTOR_ENGINE_GPT_IMAGE_2_MODEL + } + + pub(crate) fn candidate_source_type(self) -> &'static str { + match self { + Self::GptImage2 => "generated:gpt-image-2", + Self::Gemini31FlashPreview => "generated:nanobanana2", + } + } +} + +pub(crate) struct PuzzleVectorEngineSettings { + pub(crate) base_url: String, + pub(crate) api_key: String, +} + +pub(crate) struct PuzzleGeneratedImages { + pub(crate) task_id: String, + pub(crate) images: Vec, +} + +pub(crate) struct PuzzleResolvedReferenceImage { + pub(crate) mime_type: String, + pub(crate) bytes_len: usize, + pub(crate) bytes: Vec, +} + +pub(crate) struct GeneratedPuzzleImageCandidate { + pub(crate) record: PuzzleGeneratedImageCandidateRecord, + pub(crate) downloaded_image: PuzzleDownloadedImage, +} + +impl GeneratedPuzzleImageCandidate { + pub(crate) fn into_record(self) -> PuzzleGeneratedImageCandidateRecord { + self.record + } +} + +pub(crate) trait GeneratedPuzzleImageCandidatesExt { + fn into_records(self) -> Vec; +} + +impl GeneratedPuzzleImageCandidatesExt for Vec { + fn into_records(self) -> Vec { + self.into_iter() + .map(GeneratedPuzzleImageCandidate::into_record) + .collect() + } +} + +#[derive(Clone)] +pub(crate) struct PuzzleDownloadedImage { + pub(crate) extension: String, + pub(crate) mime_type: String, + pub(crate) bytes: Vec, +} + +pub(crate) struct ParsedPuzzleImageDataUrl { + pub(crate) mime_type: String, + pub(crate) bytes: Vec, +} + +pub(crate) struct GeneratedPuzzleAssetResponse { + pub(crate) image_src: String, + pub(crate) asset_id: String, +} + +pub(crate) struct GeneratedPuzzleUiBackgroundResponse { + pub(crate) image_src: String, + pub(crate) object_key: String, +} + +pub(crate) fn resolve_puzzle_image_model(value: Option<&str>) -> PuzzleImageModel { + match value.map(str::trim).filter(|value| !value.is_empty()) { + Some(PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW) => { + tracing::warn!( + requested_model = PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW, + effective_model = VECTOR_ENGINE_GPT_IMAGE_2_MODEL, + "拼图 nanobanana2 历史选项已回落到 VectorEngine GPT-image-2-all" + ); + PuzzleImageModel::Gemini31FlashPreview + } + _ => PuzzleImageModel::GptImage2, + } +} + +pub(crate) fn require_puzzle_vector_engine_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_PROVIDER, + "message": "VectorEngine 图片生成地址未配置", + "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_PROVIDER, + "message": "VectorEngine 图片生成密钥未配置", + "reason": "VECTOR_ENGINE_API_KEY 未配置", + })) + })?; + + Ok(PuzzleVectorEngineSettings { + base_url: base_url.to_string(), + api_key: api_key.to_string(), + }) +} + +pub(crate) fn build_puzzle_image_http_client( + state: &AppState, + image_model: PuzzleImageModel, +) -> Result { + let provider = image_model.provider_name(); + let request_timeout_ms = state.config.vector_engine_image_request_timeout_ms; + + reqwest::Client::builder() + .timeout(Duration::from_millis(request_timeout_ms.max(1))) + // 中文注释:VectorEngine 的图片编辑接口是 multipart 请求;强制 HTTP/1.1 可避开部分网关对 HTTP/2 multipart 流的中断兼容问题。 + .http1_only() + .build() + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": provider, + "message": format!("构造拼图图片生成 HTTP 客户端失败:{error}"), + })) + }) +} + +pub(crate) fn to_puzzle_generated_image_candidate( + candidate: &PuzzleGeneratedImageCandidateRecord, +) -> PuzzleGeneratedImageCandidate { + // SpacetimeDB 模块反序列化的是 module-puzzle 的持久化结构,必须保留 snake_case 字段名;HTTP 响应层再单独映射为 camelCase。 + PuzzleGeneratedImageCandidate { + candidate_id: candidate.candidate_id.clone(), + image_src: candidate.image_src.clone(), + asset_id: candidate.asset_id.clone(), + prompt: candidate.prompt.clone(), + actual_prompt: candidate.actual_prompt.clone(), + source_type: candidate.source_type.clone(), + selected: candidate.selected, + } +} + +pub(crate) async fn create_puzzle_vector_engine_image_generation( + http_client: &reqwest::Client, + settings: &PuzzleVectorEngineSettings, + image_model: PuzzleImageModel, + prompt: &str, + negative_prompt: &str, + size: &str, + candidate_count: u32, + reference_image: Option<&PuzzleResolvedReferenceImage>, +) -> Result { + let request_body = build_puzzle_vector_engine_image_request_body( + image_model, + prompt, + negative_prompt, + size, + candidate_count, + reference_image, + ); + let request_url = puzzle_vector_engine_images_generation_url(settings); + let request_started_at = Instant::now(); + let response = http_client + .post(request_url.as_str()) + .header( + reqwest::header::AUTHORIZATION, + format!("Bearer {}", settings.api_key), + ) + .header(reqwest::header::ACCEPT, "application/json") + .header(reqwest::header::CONTENT_TYPE, "application/json") + .json(&request_body) + .send() + .await + .map_err(|error| { + map_puzzle_vector_engine_request_error(format!( + "创建拼图 VectorEngine 图片生成任务失败:{error}" + )) + })?; + let status = response.status(); + let upstream_elapsed_ms = request_started_at.elapsed().as_millis() as u64; + tracing::info!( + provider = VECTOR_ENGINE_PROVIDER, + image_model = image_model.request_model_name(), + endpoint = %request_url, + status = status.as_u16(), + prompt_chars = prompt.chars().count(), + size, + has_reference_image = reference_image.is_some(), + elapsed_ms = upstream_elapsed_ms, + "拼图 VectorEngine 图片生成 HTTP 返回" + ); + let response_text = response.text().await.map_err(|error| { + map_puzzle_vector_engine_request_error(format!( + "读取拼图 VectorEngine 图片生成响应失败:{error}" + )) + })?; + if !status.is_success() { + return Err(map_puzzle_vector_engine_upstream_error( + status, + response_text.as_str(), + "创建拼图 VectorEngine 图片生成任务失败", + )); + } + + let payload = parse_puzzle_json_payload( + response_text.as_str(), + "解析拼图 VectorEngine 图片生成响应失败", + )?; + let image_urls = extract_puzzle_image_urls(&payload); + if !image_urls.is_empty() { + let download_started_at = Instant::now(); + let images = download_puzzle_images_from_urls( + http_client, + format!("vector-engine-{}", current_utc_micros()), + image_urls, + candidate_count, + ) + .await?; + tracing::info!( + provider = VECTOR_ENGINE_PROVIDER, + image_model = image_model.request_model_name(), + image_count = images.images.len(), + elapsed_ms = download_started_at.elapsed().as_millis() as u64, + "拼图 VectorEngine 图片下载完成" + ); + return Ok(images); + } + + Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": VECTOR_ENGINE_PROVIDER, + "message": "拼图 VectorEngine 图片生成未返回图片地址", + })), + ) +} + +pub(crate) async fn create_puzzle_vector_engine_image_edit( + http_client: &reqwest::Client, + settings: &PuzzleVectorEngineSettings, + prompt: &str, + negative_prompt: &str, + size: &str, + candidate_count: u32, + reference_image: &PuzzleResolvedReferenceImage, +) -> Result { + let request_url = puzzle_vector_engine_images_edit_url(settings); + let task_id = format!("vector-engine-edit-{}", current_utc_micros()); + let file_name = format!( + "puzzle-reference.{}", + puzzle_mime_to_extension(reference_image.mime_type.as_str()) + ); + let image_part = reqwest::multipart::Part::bytes(reference_image.bytes.clone()) + .file_name(file_name) + .mime_str(reference_image.mime_type.as_str()) + .map_err(|error| { + map_puzzle_vector_engine_request_error(format!( + "构造拼图 VectorEngine 图片编辑参考图失败:{error}" + )) + })?; + let form = reqwest::multipart::Form::new() + .part("image", image_part) + .text("model", PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL.to_string()) + .text( + "prompt", + build_puzzle_vector_engine_prompt(prompt, negative_prompt), + ) + .text("n", candidate_count.clamp(1, 1).to_string()) + .text("size", size.to_string()); + let request_started_at = Instant::now(); + let response = http_client + .post(request_url.as_str()) + .header( + reqwest::header::AUTHORIZATION, + format!("Bearer {}", settings.api_key), + ) + .header(reqwest::header::ACCEPT, "application/json") + .multipart(form) + .send() + .await + .map_err(|error| { + map_puzzle_vector_engine_reqwest_error( + "创建拼图 VectorEngine 图片编辑任务失败", + &request_url, + error, + ) + })?; + let status = response.status(); + tracing::info!( + provider = VECTOR_ENGINE_PROVIDER, + image_model = PUZZLE_VECTOR_ENGINE_IMAGE_EDIT_MODEL, + endpoint = %request_url, + status = status.as_u16(), + prompt_chars = prompt.chars().count(), + size, + reference_mime = %reference_image.mime_type, + reference_bytes = reference_image.bytes_len, + elapsed_ms = request_started_at.elapsed().as_millis() as u64, + "拼图 VectorEngine 图片编辑 HTTP 返回" + ); + let response_text = response.text().await.map_err(|error| { + map_puzzle_vector_engine_request_error(format!( + "读取拼图 VectorEngine 图片编辑响应失败:{error}" + )) + })?; + if !status.is_success() { + return Err(map_puzzle_vector_engine_upstream_error( + status, + response_text.as_str(), + "创建拼图 VectorEngine 图片编辑任务失败", + )); + } + + let payload = parse_puzzle_json_payload( + response_text.as_str(), + "解析拼图 VectorEngine 图片编辑响应失败", + )?; + let image_urls = extract_puzzle_image_urls(&payload); + if !image_urls.is_empty() { + return download_puzzle_images_from_urls(http_client, task_id, image_urls, candidate_count) + .await; + } + let b64_images = extract_puzzle_b64_images(&payload); + if !b64_images.is_empty() { + return Ok(puzzle_images_from_base64( + task_id, + b64_images, + candidate_count, + )); + } + + Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": VECTOR_ENGINE_PROVIDER, + "message": "拼图 VectorEngine 图片编辑未返回图片", + })), + ) +} + +pub(crate) fn build_puzzle_vector_engine_image_request_body( + image_model: PuzzleImageModel, + prompt: &str, + negative_prompt: &str, + size: &str, + candidate_count: u32, + reference_image: Option<&PuzzleResolvedReferenceImage>, +) -> Value { + let mut body = Map::from_iter([ + ( + "model".to_string(), + Value::String(image_model.request_model_name().to_string()), + ), + ( + "prompt".to_string(), + Value::String(build_puzzle_vector_engine_prompt(prompt, negative_prompt)), + ), + ("n".to_string(), json!(candidate_count.clamp(1, 1))), + ("size".to_string(), Value::String(size.to_string())), + ]); + if let Some(reference_image) = reference_image + && let Some(reference_data_url) = + build_puzzle_generation_reference_image_data_url(reference_image) + { + body.insert("image".to_string(), json!([reference_data_url])); + } + + Value::Object(body) +} + +pub(crate) fn build_puzzle_vector_engine_generation_prompt( + prompt: &str, + has_reference_image: bool, +) -> String { + let prompt = prompt.trim(); + if !has_reference_image { + return prompt.to_string(); + } + + format!( + concat!( + "请以随请求提供的参考图作为第一优先级生成依据,严格保留参考图的主要主体、构图关系、视角、姿态、配色和光影氛围;", + "允许按下面文字要求做风格化和细节增强,但不要改成与参考图无关的新画面。\n", + "{prompt}" + ), + prompt = prompt, + ) +} + +pub(crate) fn build_puzzle_generation_reference_image_data_url( + image: &PuzzleResolvedReferenceImage, +) -> Option { + let bytes = resize_puzzle_generation_reference_image_bytes(image.bytes.as_slice()) + .unwrap_or_else(|| image.bytes.clone()); + let mime_type = if bytes.starts_with(b"\x89PNG\r\n\x1A\n") { + "image/png" + } else { + image.mime_type.as_str() + }; + + Some(format!( + "data:{};base64,{}", + normalize_puzzle_downloaded_image_mime_type(mime_type), + BASE64_STANDARD.encode(bytes) + )) +} + +pub(crate) fn resize_puzzle_generation_reference_image_bytes(bytes: &[u8]) -> Option> { + let image = image::load_from_memory(bytes).ok()?; + let resized = image.resize(1024, 1024, image::imageops::FilterType::Triangle); + let mut cursor = std::io::Cursor::new(Vec::new()); + resized.write_to(&mut cursor, ImageFormat::Png).ok()?; + Some(cursor.into_inner()) +} + +pub(crate) fn has_puzzle_reference_image(reference_image_src: Option<&str>) -> bool { + reference_image_src + .map(str::trim) + .map(|value| !value.is_empty()) + .unwrap_or(false) +} + +pub(crate) fn collect_puzzle_reference_image_sources( + legacy_reference_image_src: Option<&str>, + reference_image_srcs: &[String], +) -> Vec { + let mut sources = Vec::new(); + for source in legacy_reference_image_src + .into_iter() + .chain(reference_image_srcs.iter().map(String::as_str)) + { + 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() >= PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT { + break; + } + } + sources +} + +pub(crate) fn has_puzzle_reference_images( + legacy_reference_image_src: Option<&str>, + reference_image_srcs: &[String], +) -> bool { + !collect_puzzle_reference_image_sources(legacy_reference_image_src, reference_image_srcs) + .is_empty() +} + +pub(crate) fn should_use_puzzle_reference_image_edit( + reference_image_src: Option<&str>, + use_reference_image_edit: bool, +) -> bool { + use_reference_image_edit && has_puzzle_reference_image(reference_image_src) +} + +pub(crate) fn build_puzzle_vector_engine_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}") +} + +pub(crate) fn puzzle_vector_engine_images_generation_url( + settings: &PuzzleVectorEngineSettings, +) -> String { + if settings.base_url.ends_with("/v1") { + format!("{}/images/generations", settings.base_url) + } else { + format!("{}/v1/images/generations", settings.base_url) + } +} + +pub(crate) fn puzzle_vector_engine_images_edit_url( + settings: &PuzzleVectorEngineSettings, +) -> String { + if settings.base_url.ends_with("/v1") { + format!("{}/images/edits", settings.base_url) + } else { + format!("{}/v1/images/edits", settings.base_url) + } +} + +pub(crate) async fn download_puzzle_images_from_urls( + http_client: &reqwest::Client, + task_id: String, + image_urls: Vec, + candidate_count: u32, +) -> Result { + let mut images = Vec::with_capacity(candidate_count.clamp(1, 1) as usize); + for image_url in image_urls + .into_iter() + .take(candidate_count.clamp(1, 1) as usize) + { + images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?); + } + Ok(PuzzleGeneratedImages { task_id, images }) +} + +pub(crate) async fn resolve_puzzle_reference_image_as_data_url( + state: &AppState, + http_client: &reqwest::Client, + source: &str, +) -> Result { + let trimmed = source.trim(); + if trimmed.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "puzzle", + "field": "referenceImageSrc", + "message": "参考图不能为空。", + })), + ); + } + + if let Some(parsed) = parse_puzzle_image_data_url(trimmed) { + let bytes_len = parsed.bytes.len(); + if bytes_len > PUZZLE_REFERENCE_IMAGE_MAX_BYTES { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "puzzle", + "field": "referenceImageSrc", + "message": "参考图过大,请压缩后重试。", + "maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES, + "actualBytes": bytes_len, + })), + ); + } + return Ok(PuzzleResolvedReferenceImage { + mime_type: parsed.mime_type, + bytes_len, + bytes: parsed.bytes, + }); + } + + if !trimmed.starts_with('/') { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "puzzle", + "field": "referenceImageSrc", + "message": "参考图必须是 Data URL 或 /generated-* 旧路径。", + })), + ); + } + + let object_key = trimmed.trim_start_matches('/'); + if LegacyAssetPrefix::from_object_key(object_key).is_none() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "puzzle", + "field": "referenceImageSrc", + "message": "参考图当前只支持 /generated-* 旧路径。", + })), + ); + } + + 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(OssSignedGetObjectUrlRequest { + object_key: object_key.to_string(), + expire_seconds: Some(60), + }) + .map_err(map_puzzle_asset_oss_error)?; + let response = http_client + .get(signed.signed_url) + .send() + .await + .map_err(|error| map_puzzle_image_request_error(format!("读取拼图参考图失败:{error}")))?; + let status = response.status(); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("image/png") + .to_string(); + let body = response.bytes().await.map_err(|error| { + map_puzzle_image_request_error(format!("读取拼图参考图内容失败:{error}")) + })?; + if !status.is_success() { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "aliyun-oss", + "message": format!("读取参考图失败,状态码:{status}"), + "objectKey": object_key, + })), + ); + } + if body.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "aliyun-oss", + "message": "读取参考图失败:对象内容为空", + "objectKey": object_key, + })), + ); + } + + let mime_type = normalize_puzzle_downloaded_image_mime_type(content_type.as_str()); + let bytes_len = body.len(); + Ok(PuzzleResolvedReferenceImage { + mime_type, + bytes_len, + bytes: body.to_vec(), + }) +} + +pub(crate) async fn download_puzzle_remote_image( + http_client: &reqwest::Client, + image_url: &str, +) -> Result { + let response = http_client.get(image_url).send().await.map_err(|error| { + map_puzzle_image_request_error(format!("下载拼图正式图片失败:{error}")) + })?; + let status = response.status(); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("image/jpeg") + .to_string(); + let bytes = response.bytes().await.map_err(|error| { + map_puzzle_image_request_error(format!("读取拼图正式图片内容失败:{error}")) + })?; + if !status.is_success() { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "puzzle-image", + "message": "下载拼图正式图片失败", + "status": status.as_u16(), + })), + ); + } + let mime_type = normalize_puzzle_downloaded_image_mime_type(content_type.as_str()); + Ok(PuzzleDownloadedImage { + extension: puzzle_mime_to_extension(mime_type.as_str()).to_string(), + mime_type, + bytes: bytes.to_vec(), + }) +} + +pub(crate) async fn persist_puzzle_generated_asset( + state: &AppState, + owner_user_id: &str, + session_id: &str, + level_name: &str, + candidate_id: &str, + task_id: &str, + image: PuzzleDownloadedImage, + generated_at_micros: i64, +) -> Result { + let oss_client = state.oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS 未完成环境变量配置", + })) + })?; + let http_client = reqwest::Client::new(); + let asset_id = format!("asset-{generated_at_micros}"); + let put_result = oss_client + .put_object( + &http_client, + OssPutObjectRequest { + prefix: LegacyAssetPrefix::PuzzleAssets, + path_segments: vec![ + sanitize_path_segment(session_id, "session"), + sanitize_path_segment(level_name, "puzzle"), + sanitize_path_segment(candidate_id, "candidate"), + asset_id.clone(), + ], + file_name: format!("image.{}", image.extension), + content_type: Some(image.mime_type.clone()), + access: OssObjectAccess::Private, + metadata: build_puzzle_asset_metadata(owner_user_id, session_id, candidate_id), + body: image.bytes, + }, + ) + .await + .map_err(map_puzzle_asset_oss_error)?; + let head = oss_client + .head_object( + &http_client, + OssHeadObjectRequest { + object_key: put_result.object_key.clone(), + }, + ) + .await + .map_err(map_puzzle_asset_oss_error)?; + let asset_object = state + .spacetime_client() + .confirm_asset_object( + build_asset_object_upsert_input( + generate_asset_object_id(generated_at_micros), + head.bucket, + head.object_key, + AssetObjectAccessPolicy::Private, + head.content_type.or(Some(image.mime_type)), + head.content_length, + head.etag, + "puzzle_cover_image".to_string(), + Some(task_id.to_string()), + Some(owner_user_id.to_string()), + None, + Some(session_id.to_string()), + generated_at_micros, + ) + .map_err(map_puzzle_asset_field_error)?, + ) + .await; + match asset_object { + Ok(asset_object) => { + if let Err(error) = state + .spacetime_client() + .bind_asset_object_to_entity( + build_asset_entity_binding_input( + generate_asset_binding_id(generated_at_micros), + asset_object.asset_object_id, + PUZZLE_ENTITY_KIND.to_string(), + session_id.to_string(), + candidate_id.to_string(), + "puzzle_cover_image".to_string(), + Some(owner_user_id.to_string()), + None, + generated_at_micros, + ) + .map_err(map_puzzle_asset_field_error)?, + ) + .await + { + handle_puzzle_asset_spacetime_index_error( + error, + owner_user_id, + session_id, + candidate_id, + "绑定拼图资产对象到实体", + )?; + } + } + Err(error) => handle_puzzle_asset_spacetime_index_error( + error, + owner_user_id, + session_id, + candidate_id, + "确认拼图资产对象", + )?, + } + + Ok(GeneratedPuzzleAssetResponse { + image_src: put_result.legacy_public_path, + asset_id, + }) +} + +pub(crate) async fn persist_puzzle_ui_background_image( + state: &AppState, + owner_user_id: &str, + session_id: &str, + level_name: &str, + task_id: &str, + image: DownloadedOpenAiImage, +) -> Result { + let oss_client = state.oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS 未完成环境变量配置", + })) + })?; + let http_client = reqwest::Client::new(); + let put_result = oss_client + .put_object( + &http_client, + OssPutObjectRequest { + prefix: LegacyAssetPrefix::PuzzleAssets, + path_segments: vec![ + sanitize_path_segment(session_id, "session"), + sanitize_path_segment(level_name, "puzzle"), + "ui-background".to_string(), + sanitize_path_segment(task_id, "task"), + ], + file_name: format!("background.{}", image.extension), + content_type: Some(image.mime_type.clone()), + access: OssObjectAccess::Private, + metadata: build_puzzle_ui_background_asset_metadata(owner_user_id, session_id), + body: image.bytes, + }, + ) + .await + .map_err(map_puzzle_asset_oss_error)?; + Ok(GeneratedPuzzleUiBackgroundResponse { + image_src: put_result.legacy_public_path, + object_key: put_result.object_key, + }) +} + +pub(crate) fn handle_puzzle_asset_spacetime_index_error( + error: SpacetimeClientError, + owner_user_id: &str, + session_id: &str, + candidate_id: &str, + stage: &str, +) -> Result<(), AppError> { + if should_skip_asset_operation_billing_for_connectivity(&error) { + // 中文注释:OSS 已经持有真实图片,资产索引的 SpacetimeDB 短暂失败只影响历史检索,不应阻断本次生图展示。 + tracing::warn!( + provider = "spacetimedb", + owner_user_id, + session_id, + candidate_id, + stage, + error = %error, + "拼图图片资产索引写入因 SpacetimeDB 连接不可用而降级跳过" + ); + return Ok(()); + } + + Err(map_puzzle_asset_spacetime_error(error)) +} + +pub(crate) fn build_puzzle_asset_metadata( + owner_user_id: &str, + session_id: &str, + candidate_id: &str, +) -> BTreeMap { + BTreeMap::from([ + ("asset_kind".to_string(), "puzzle_cover_image".to_string()), + ("owner_user_id".to_string(), owner_user_id.to_string()), + ("entity_kind".to_string(), PUZZLE_ENTITY_KIND.to_string()), + ("entity_id".to_string(), session_id.to_string()), + ("slot".to_string(), candidate_id.to_string()), + ]) +} + +pub(crate) fn build_puzzle_ui_background_asset_metadata( + owner_user_id: &str, + session_id: &str, +) -> BTreeMap { + BTreeMap::from([ + ( + "asset_kind".to_string(), + "puzzle_ui_background_image".to_string(), + ), + ("owner_user_id".to_string(), owner_user_id.to_string()), + ("entity_kind".to_string(), PUZZLE_ENTITY_KIND.to_string()), + ("entity_id".to_string(), session_id.to_string()), + ("slot".to_string(), "ui_background".to_string()), + ]) +} + +pub(crate) fn parse_puzzle_json_payload( + raw_text: &str, + fallback_message: &str, +) -> Result { + serde_json::from_str::(raw_text).map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": VECTOR_ENGINE_PROVIDER, + "message": format!("{fallback_message}:{error}"), + })) + }) +} + +pub(crate) fn parse_puzzle_image_data_url(value: &str) -> Option { + let body = value.strip_prefix("data:")?; + let (mime_type, data) = body.split_once(";base64,")?; + if !mime_type.starts_with("image/") { + return None; + } + let bytes = decode_puzzle_base64(data)?; + Some(ParsedPuzzleImageDataUrl { + mime_type: mime_type.to_string(), + bytes, + }) +} + +pub(crate) fn decode_puzzle_base64(value: &str) -> Option> { + let cleaned = value.trim().replace(char::is_whitespace, ""); + let mut output = Vec::with_capacity(cleaned.len() * 3 / 4); + let mut buffer = 0u32; + let mut bits = 0u8; + + for byte in cleaned.bytes() { + let value = match byte { + b'A'..=b'Z' => byte - b'A', + b'a'..=b'z' => byte - b'a' + 26, + b'0'..=b'9' => byte - b'0' + 52, + b'+' => 62, + b'/' => 63, + b'=' => break, + _ => return None, + } as u32; + buffer = (buffer << 6) | value; + bits += 6; + while bits >= 8 { + bits -= 8; + output.push(((buffer >> bits) & 0xFF) as u8); + } + } + + Some(output) +} + +pub(crate) fn extract_puzzle_image_urls(payload: &Value) -> Vec { + let mut urls = Vec::new(); + collect_puzzle_strings_by_key(payload, "image", &mut urls); + collect_puzzle_strings_by_key(payload, "url", &mut urls); + let mut deduped = Vec::new(); + for url in urls { + if !deduped.contains(&url) { + deduped.push(url); + } + } + deduped +} + +pub(crate) fn extract_puzzle_b64_images(payload: &Value) -> Vec { + let mut values = Vec::new(); + collect_puzzle_strings_by_key(payload, "b64_json", &mut values); + values +} + +pub(crate) fn puzzle_images_from_base64( + task_id: String, + b64_images: Vec, + candidate_count: u32, +) -> PuzzleGeneratedImages { + let images = b64_images + .into_iter() + .take(candidate_count.clamp(1, 1) as usize) + .filter_map(|raw| decode_puzzle_generated_image_base64(raw.as_str())) + .collect(); + + PuzzleGeneratedImages { task_id, images } +} + +pub(crate) fn decode_puzzle_generated_image_base64(raw: &str) -> Option { + let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?; + let mime_type = infer_puzzle_image_mime_type(bytes.as_slice()); + Some(PuzzleDownloadedImage { + extension: puzzle_mime_to_extension(mime_type.as_str()).to_string(), + mime_type, + bytes, + }) +} + +pub(crate) fn find_first_puzzle_string_by_key(payload: &Value, target_key: &str) -> Option { + let mut results = Vec::new(); + collect_puzzle_strings_by_key(payload, target_key, &mut results); + results.into_iter().next() +} + +pub(crate) fn collect_puzzle_strings_by_key( + payload: &Value, + target_key: &str, + results: &mut Vec, +) { + match payload { + Value::Array(entries) => { + for entry in entries { + collect_puzzle_strings_by_key(entry, target_key, results); + } + } + Value::Object(object) => { + for (key, value) in object { + if key == target_key { + collect_puzzle_string_values(value, results); + } + collect_puzzle_strings_by_key(value, target_key, results); + } + } + _ => {} + } +} + +pub(crate) fn collect_puzzle_string_values(payload: &Value, results: &mut Vec) { + match payload { + Value::String(text) => results.push(text.to_string()), + Value::Array(items) => { + for item in items { + collect_puzzle_string_values(item, results); + } + } + _ => {} + } +} + +pub(crate) fn infer_puzzle_image_mime_type(bytes: &[u8]) -> String { + if bytes.starts_with(b"\x89PNG\r\n\x1A\n") { + return "image/png".to_string(); + } + if bytes.starts_with(b"\xFF\xD8\xFF") { + return "image/jpeg".to_string(); + } + if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { + return "image/webp".to_string(); + } + if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + return "image/gif".to_string(); + } + "image/png".to_string() +} + +pub(crate) fn normalize_puzzle_downloaded_image_mime_type(content_type: &str) -> String { + let mime_type = content_type + .split(';') + .next() + .map(str::trim) + .unwrap_or("image/jpeg"); + match mime_type { + "image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => { + mime_type.to_string() + } + _ => "image/jpeg".to_string(), + } +} + +pub(crate) fn puzzle_mime_to_extension(mime_type: &str) -> &str { + match mime_type { + "image/png" => "png", + "image/webp" => "webp", + "image/gif" => "gif", + _ => "jpg", + } +} + +pub(crate) fn map_puzzle_image_request_error(message: String) -> AppError { + let is_timeout = is_puzzle_request_timeout_message(message.as_str()); + let status = if is_timeout { + StatusCode::GATEWAY_TIMEOUT + } else { + StatusCode::BAD_GATEWAY + }; + + AppError::from_status(status).with_details(json!({ + "provider": "puzzle-image", + "message": message, + "timeout": is_timeout, + })) +} + +pub(crate) fn map_puzzle_vector_engine_request_error(message: String) -> AppError { + let is_timeout = is_puzzle_request_timeout_message(message.as_str()); + let status = if is_timeout { + StatusCode::GATEWAY_TIMEOUT + } else { + StatusCode::BAD_GATEWAY + }; + + AppError::from_status(status).with_details(json!({ + "provider": VECTOR_ENGINE_PROVIDER, + "message": message, + "timeout": is_timeout, + })) +} + +pub(crate) fn map_puzzle_vector_engine_reqwest_error( + context: &str, + request_url: &str, + error: reqwest::Error, +) -> AppError { + let message = format!( + "{context}:{}", + normalize_puzzle_reqwest_error_message(&error) + ); + let is_timeout = error.is_timeout() || is_puzzle_request_timeout_message(message.as_str()); + let is_connect = error.is_connect(); + let status = if is_timeout { + StatusCode::GATEWAY_TIMEOUT + } else { + StatusCode::BAD_GATEWAY + }; + let source = error.source().map(ToString::to_string).unwrap_or_default(); + + tracing::warn!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + timeout = is_timeout, + connect = is_connect, + request = error.is_request(), + body = error.is_body(), + source = %source, + message = %message, + "拼图 VectorEngine 请求发送失败" + ); + + AppError::from_status(status).with_details(json!({ + "provider": VECTOR_ENGINE_PROVIDER, + "message": message, + "reason": resolve_puzzle_vector_engine_request_failure_reason(&error), + "endpoint": request_url, + "timeout": is_timeout, + "connect": is_connect, + "request": error.is_request(), + "body": error.is_body(), + "source": source, + })) +} + +pub(crate) fn normalize_puzzle_reqwest_error_message(error: &reqwest::Error) -> String { + error + .to_string() + .split_whitespace() + .collect::>() + .join(" ") +} + +pub(crate) fn resolve_puzzle_vector_engine_request_failure_reason( + error: &reqwest::Error, +) -> &'static str { + if error.is_timeout() { + return "VectorEngine 图片编辑请求超时,请稍后重试或调大 VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS"; + } + if error.is_connect() { + return "无法连接 VectorEngine 图片编辑接口,请检查服务器网络、DNS、防火墙或代理配置"; + } + if error.is_body() { + return "发送 VectorEngine 图片编辑 multipart 请求体失败,请重试并检查参考图大小"; + } + "VectorEngine 图片编辑请求发送失败,请查看 source 字段中的底层网络错误" +} + +pub(crate) fn is_puzzle_request_timeout_message(message: &str) -> bool { + let lower = message.to_ascii_lowercase(); + lower.contains("timed out") + || lower.contains("timeout") + || lower.contains("operation timed out") + || lower.contains("deadline has elapsed") +} + +pub(crate) fn map_puzzle_vector_engine_upstream_error( + upstream_status: reqwest::StatusCode, + raw_text: &str, + fallback_message: &str, +) -> AppError { + let message = parse_puzzle_api_error_message(raw_text, fallback_message); + let raw_excerpt = trim_puzzle_upstream_excerpt(raw_text, 800); + let is_timeout = is_puzzle_request_timeout_message(message.as_str()) + || is_puzzle_request_timeout_message(raw_excerpt.as_str()); + let status = if is_timeout { + StatusCode::GATEWAY_TIMEOUT + } else { + StatusCode::BAD_GATEWAY + }; + tracing::warn!( + provider = VECTOR_ENGINE_PROVIDER, + upstream_status = upstream_status.as_u16(), + timeout = is_timeout, + message = %message, + raw_excerpt = %raw_excerpt, + "拼图 VectorEngine 上游请求失败" + ); + + AppError::from_status(status).with_details(json!({ + "provider": VECTOR_ENGINE_PROVIDER, + "upstreamStatus": upstream_status.as_u16(), + "message": message, + "rawExcerpt": raw_excerpt, + "timeout": is_timeout, + })) +} + +pub(crate) fn parse_puzzle_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) + && let Some(message) = find_first_puzzle_string_by_key(&payload, "message") + { + return message; + } + fallback_message.to_string() +} + +pub(crate) fn trim_puzzle_upstream_excerpt(raw_text: &str, max_chars: usize) -> String { + let normalized = raw_text.split_whitespace().collect::>().join(" "); + if normalized.chars().count() <= max_chars { + return normalized; + } + + let keep_chars = max_chars.saturating_sub(3); + format!( + "{}...", + normalized.chars().take(keep_chars).collect::() + ) +} + +pub(crate) fn map_puzzle_asset_oss_error(error: platform_oss::OssError) -> AppError { + map_oss_error(error, "aliyun-oss") +} + +pub(crate) fn map_puzzle_asset_spacetime_error(error: SpacetimeClientError) -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "spacetimedb", + "message": error.to_string(), + })) +} + +pub(crate) fn map_puzzle_asset_field_error(error: AssetObjectFieldError) -> AppError { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "asset-object", + "message": error.to_string(), + })) +} + +pub(crate) fn sanitize_path_segment(value: &str, fallback: &str) -> String { + let sanitized = value + .trim() + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ('\u{4e00}'..='\u{9fff}').contains(&ch) { + ch + } else { + '-' + } + }) + .collect::() + .trim_matches('-') + .to_string(); + if sanitized.is_empty() { + fallback.to_string() + } else { + sanitized + } +} + +pub(crate) fn current_utc_micros() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + (duration.as_secs() as i64) * 1_000_000 + i64::from(duration.subsec_micros()) +} diff --git a/server-rs/crates/api-server/src/puzzle_gallery_cache.rs b/server-rs/crates/api-server/src/puzzle_gallery_cache.rs new file mode 100644 index 00000000..a6b9eb7d --- /dev/null +++ b/server-rs/crates/api-server/src/puzzle_gallery_cache.rs @@ -0,0 +1,208 @@ +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; + +use axum::response::Response; +use bytes::Bytes; +use shared_contracts::{ + puzzle_gallery::{PuzzleGalleryResponse, PuzzleGalleryWorkRefResponse}, + puzzle_works::PuzzleWorkSummaryResponse, +}; +use tokio::{ + sync::{Mutex, MutexGuard, RwLock}, + time, +}; + +use crate::{api_response::json_success_data_bytes_response, request_context::RequestContext}; + +const PUZZLE_GALLERY_PRIMARY_ITEM_COUNT: usize = 10; +const PUZZLE_GALLERY_PREVIEW_REF_COUNT: usize = 10; +const PUZZLE_GALLERY_CACHE_TTL: Duration = Duration::from_secs(5); +const PUZZLE_GALLERY_CACHE_MAX_IDLE: Duration = Duration::from_secs(300); +const PUZZLE_GALLERY_CACHE_CLEANUP_INTERVAL: Duration = Duration::from_secs(60); + +#[derive(Clone, Debug)] +pub struct PuzzleGalleryCache { + inner: Arc>>, + rebuild_lock: Arc>, +} + +#[derive(Clone, Debug)] +struct PuzzleGalleryCacheEntry { + data_json: Bytes, + built_at: Instant, +} + +#[derive(Clone, Debug)] +pub struct PuzzleGalleryCachedResponse { + data_json: Bytes, +} + +impl PuzzleGalleryCachedResponse { + pub fn data_json_len(&self) -> usize { + self.data_json.len() + } +} + +impl PuzzleGalleryCache { + pub fn new() -> Self { + Self { + inner: Arc::new(RwLock::new(None)), + rebuild_lock: Arc::new(Mutex::new(())), + } + } + + pub async fn acquire_rebuild_guard(&self) -> MutexGuard<'_, ()> { + self.rebuild_lock.lock().await + } + + pub async fn read_fresh_response(&self) -> Option { + let guard = self.inner.read().await; + let entry = guard.as_ref()?; + let now = Instant::now(); + if now.duration_since(entry.built_at) > PUZZLE_GALLERY_CACHE_TTL { + return None; + } + Some(PuzzleGalleryCachedResponse { + data_json: entry.data_json.clone(), + }) + } + + pub async fn store_response( + &self, + response: PuzzleGalleryResponse, + ) -> Result { + let now = Instant::now(); + let cached = PuzzleGalleryCachedResponse { + data_json: Bytes::from(serde_json::to_vec(&response)?), + }; + *self.inner.write().await = Some(PuzzleGalleryCacheEntry { + data_json: cached.data_json.clone(), + built_at: now, + }); + Ok(cached) + } + + pub fn spawn_cleanup_task(&self) { + let cache = self.clone(); + tokio::spawn(async move { + let mut interval = time::interval(PUZZLE_GALLERY_CACHE_CLEANUP_INTERVAL); + loop { + interval.tick().await; + cache.cleanup_idle_entry().await; + } + }); + } + + async fn cleanup_idle_entry(&self) { + let mut guard = self.inner.write().await; + if let Some(entry) = guard.as_ref() + && Instant::now().duration_since(entry.built_at) > PUZZLE_GALLERY_CACHE_MAX_IDLE + { + *guard = None; + } + } +} + +pub fn build_puzzle_gallery_window_response( + items: Vec, +) -> PuzzleGalleryResponse { + let total_count = items.len().min(u32::MAX as usize) as u32; + let preview_refs = items + .iter() + .skip(PUZZLE_GALLERY_PRIMARY_ITEM_COUNT) + .take(PUZZLE_GALLERY_PREVIEW_REF_COUNT) + .map(|item| PuzzleGalleryWorkRefResponse { + work_id: item.work_id.clone(), + profile_id: item.profile_id.clone(), + }) + .collect::>(); + let next_cursor = items + .get(PUZZLE_GALLERY_PRIMARY_ITEM_COUNT + PUZZLE_GALLERY_PREVIEW_REF_COUNT) + .map(|item| item.profile_id.clone()); + let has_more = + items.len() > PUZZLE_GALLERY_PRIMARY_ITEM_COUNT + PUZZLE_GALLERY_PREVIEW_REF_COUNT; + + PuzzleGalleryResponse { + items: items + .into_iter() + .take(PUZZLE_GALLERY_PRIMARY_ITEM_COUNT) + .collect(), + preview_refs, + has_more, + next_cursor, + total_count, + } +} + +pub fn puzzle_gallery_cached_json( + request_context: &RequestContext, + response: PuzzleGalleryCachedResponse, +) -> Response { + json_success_data_bytes_response(Some(request_context), response.data_json) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn build_summary(index: usize) -> PuzzleWorkSummaryResponse { + PuzzleWorkSummaryResponse { + work_id: format!("work-{index}"), + profile_id: format!("profile-{index}"), + owner_user_id: "user-1".to_string(), + source_session_id: None, + author_display_name: "作者".to_string(), + work_title: format!("作品 {index}"), + work_description: "描述".to_string(), + level_name: "第一关".to_string(), + summary: "摘要".to_string(), + theme_tags: Vec::new(), + cover_image_src: None, + cover_asset_id: None, + publication_status: "published".to_string(), + updated_at: "2026-05-01T00:00:00Z".to_string(), + published_at: None, + play_count: 0, + remix_count: 0, + like_count: 0, + recent_play_count_7d: 0, + point_incentive_total_half_points: 0, + point_incentive_claimed_points: 0, + point_incentive_total_points: 0.0, + point_incentive_claimable_points: 0, + publish_ready: true, + generation_status: Some("ready".to_string()), + levels: Vec::new(), + } + } + + #[test] + fn build_window_returns_primary_cards_preview_refs_and_cursor() { + let response = + build_puzzle_gallery_window_response((0..25).map(build_summary).collect::>()); + + assert_eq!(response.total_count, 25); + assert_eq!(response.items.len(), 10); + assert_eq!(response.preview_refs.len(), 10); + assert_eq!(response.items[0].profile_id, "profile-0"); + assert_eq!(response.items[9].profile_id, "profile-9"); + assert_eq!(response.preview_refs[0].profile_id, "profile-10"); + assert_eq!(response.preview_refs[9].profile_id, "profile-19"); + assert!(response.has_more); + assert_eq!(response.next_cursor.as_deref(), Some("profile-20")); + } + + #[test] + fn build_window_handles_short_gallery_without_more_cursor() { + let response = + build_puzzle_gallery_window_response((0..8).map(build_summary).collect::>()); + + assert_eq!(response.total_count, 8); + assert_eq!(response.items.len(), 8); + assert!(response.preview_refs.is_empty()); + assert!(!response.has_more); + assert_eq!(response.next_cursor, None); + } +} diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index ee4b9a7e..2e2e690e 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -27,20 +27,25 @@ use shared_contracts::creation_entry_config::CreationEntryConfigResponse; use shared_contracts::creative_agent::CreativeAgentSessionSnapshot; use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError}; use time::OffsetDateTime; +use tokio::sync::Semaphore; use tracing::{info, warn}; use crate::config::AppConfig; +use crate::puzzle_gallery_cache::PuzzleGalleryCache; use crate::wechat_pay::{WechatPayClient, map_wechat_pay_init_error}; use crate::wechat_provider::build_wechat_provider; const ADMIN_ROLE: &str = "admin"; +pub type HttpRequestPermitPool = Semaphore; + // 当前阶段先保留最小共享状态壳,后续逐步接入配置、客户端与平台适配。 #[derive(Clone, Debug)] pub struct AppState { // 配置会在后续中间件、路由和平台适配接入时逐步消费。 #[allow(dead_code)] pub config: AppConfig, + http_request_permit_pool: Option>, auth_jwt_config: JwtConfig, admin_runtime: Option, refresh_cookie_config: RefreshCookieConfig, @@ -60,6 +65,7 @@ pub struct AppState { #[cfg_attr(not(test), allow(dead_code))] ai_task_service: AiTaskService, spacetime_client: SpacetimeClient, + puzzle_gallery_cache: PuzzleGalleryCache, llm_client: Option, creative_agent_gpt5_client: Option, creative_agent_executor: Arc, @@ -192,9 +198,14 @@ impl AppState { }); let llm_client = build_llm_client(&config)?; let creative_agent_gpt5_client = build_creative_agent_gpt5_client(&config)?; + let http_request_permit_pool = config + .max_concurrent_requests + .map(HttpRequestPermitPool::new) + .map(Arc::new); Ok(Self { config, + http_request_permit_pool, auth_jwt_config, admin_runtime, refresh_cookie_config, @@ -214,6 +225,7 @@ impl AppState { wechat_pay_client, ai_task_service, spacetime_client, + puzzle_gallery_cache: PuzzleGalleryCache::new(), llm_client, creative_agent_gpt5_client, creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor), @@ -235,6 +247,10 @@ impl AppState { &self.refresh_cookie_config } + pub fn http_request_permit_pool(&self) -> Option> { + self.http_request_permit_pool.clone() + } + pub async fn upsert_creation_entry_type_config( &self, input: module_runtime::CreationEntryTypeAdminUpsertInput, @@ -464,6 +480,10 @@ impl AppState { &self.spacetime_client } + pub fn puzzle_gallery_cache(&self) -> &PuzzleGalleryCache { + &self.puzzle_gallery_cache + } + pub fn llm_client(&self) -> Option<&LlmClient> { self.llm_client.as_ref() } diff --git a/server-rs/crates/api-server/src/telemetry.rs b/server-rs/crates/api-server/src/telemetry.rs new file mode 100644 index 00000000..39643976 --- /dev/null +++ b/server-rs/crates/api-server/src/telemetry.rs @@ -0,0 +1,303 @@ +use axum::{ + body::Body, + extract::State, + http::{HeaderMap, Request, Response}, + middleware::Next, +}; +use http_body_util::BodyExt; +use opentelemetry::{KeyValue, global, metrics::Counter}; +use std::sync::{ + Arc, OnceLock, + atomic::{AtomicI64, Ordering}, +}; +use tracing::{info, warn}; + +use crate::{request_context::resolve_request_id, state::AppState}; + +static HTTP_RESPONSE_BODY_IN_FLIGHT: AtomicI64 = AtomicI64::new(0); +static HTTP_REQUEST_PERMITS_AVAILABLE: OnceLock> = OnceLock::new(); + +// 集中维护 api-server HTTP 观测,避免在 handler 中散落高基数字段或重复创建 instrument。 +pub async fn record_http_observability( + State(state): State, + request: Request, + next: Next, +) -> Response { + let method = request.method().as_str().to_string(); + let route = observability_route(request.uri().path()); + let scheme = resolve_request_scheme(request.headers()); + let path = request.uri().path().to_string(); + let request_id = resolve_request_id(&request).unwrap_or_else(|| "unknown".to_string()); + let base_labels = http_base_labels(method.clone(), route.clone()); + let metrics = http_metrics(); + metrics.in_flight.add(1, &base_labels); + let started_at = std::time::Instant::now(); + + let response = next.run(request).await; + let status = response.status().as_u16(); + let status_class = status_class(status); + let latency_ms = started_at.elapsed().as_millis().min(u64::MAX as u128) as u64; + let slow_request = latency_ms >= state.config.slow_request_threshold_ms; + let labels = http_response_labels(base_labels, status); + metrics.requests.add(1, &labels); + metrics + .duration + .record(started_at.elapsed().as_secs_f64(), &labels); + metrics.in_flight.add(-1, &labels[..2]); + + if slow_request { + warn!( + request_id = %request_id, + http.request.method = %method, + http.route = %route, + url.scheme = %scheme, + url.path = %path, + http.response.status_code = status, + status, + status_class, + latency_ms, + slow_request = true, + "http request completed slowly" + ); + } else { + info!( + request_id = %request_id, + http.request.method = %method, + http.route = %route, + url.scheme = %scheme, + url.path = %path, + http.response.status_code = status, + status, + status_class, + latency_ms, + slow_request = false, + "http request completed" + ); + } + + track_response_body_in_flight(response) +} + +pub(crate) fn update_http_request_permits_available(available: usize) { + let gauge = HTTP_REQUEST_PERMITS_AVAILABLE.get_or_init(|| { + let gauge = Arc::new(AtomicI64::new(0)); + register_http_request_permits_available_metric(gauge.clone()); + gauge + }); + gauge.store(available.min(i64::MAX as usize) as i64, Ordering::Relaxed); +} + +pub(crate) fn record_puzzle_gallery_cache_hit() { + puzzle_gallery_cache_metrics().hits.add(1, &[]); +} + +pub(crate) fn record_puzzle_gallery_cache_miss() { + puzzle_gallery_cache_metrics().misses.add(1, &[]); +} + +pub(crate) fn record_puzzle_gallery_cache_rebuild(duration: std::time::Duration, data_bytes: usize) { + let metrics = puzzle_gallery_cache_metrics(); + metrics.rebuilds.add(1, &[]); + metrics + .rebuild_duration + .record(duration.as_secs_f64(), &[]); + metrics + .data_json_bytes + .record(data_bytes.min(u64::MAX as usize) as u64, &[]); +} + +fn track_response_body_in_flight(response: Response) -> Response { + response.map(|body| { + HTTP_RESPONSE_BODY_IN_FLIGHT.fetch_add(1, Ordering::Relaxed); + let guard = ResponseBodyInFlightGuard; + Body::new(body.map_frame(move |frame| { + let _guard = &guard; + frame + })) + }) +} + +struct HttpMetrics { + requests: Counter, + in_flight: opentelemetry::metrics::UpDownCounter, + duration: opentelemetry::metrics::Histogram, +} + +struct PuzzleGalleryCacheMetrics { + hits: Counter, + misses: Counter, + rebuilds: Counter, + rebuild_duration: opentelemetry::metrics::Histogram, + data_json_bytes: opentelemetry::metrics::Histogram, +} + +struct ResponseBodyInFlightGuard; + +impl Drop for ResponseBodyInFlightGuard { + fn drop(&mut self) { + HTTP_RESPONSE_BODY_IN_FLIGHT.fetch_sub(1, Ordering::Relaxed); + } +} + +fn http_metrics() -> &'static HttpMetrics { + static METRICS: std::sync::OnceLock = std::sync::OnceLock::new(); + METRICS.get_or_init(|| { + let meter = global::meter("genarrative-api"); + HttpMetrics { + requests: meter + .u64_counter("genarrative.http.server.requests") + .with_description("HTTP request count grouped by route and status class") + .build(), + in_flight: meter + .i64_up_down_counter("http.server.active_requests") + .with_unit("{request}") + .with_description("Number of active HTTP server requests") + .build(), + duration: meter + .f64_histogram("http.server.request.duration") + .with_unit("s") + .with_description("Duration of HTTP server requests") + .build(), + } + }) +} + +fn puzzle_gallery_cache_metrics() -> &'static PuzzleGalleryCacheMetrics { + static METRICS: std::sync::OnceLock = std::sync::OnceLock::new(); + METRICS.get_or_init(|| { + let meter = global::meter("genarrative-api"); + PuzzleGalleryCacheMetrics { + hits: meter + .u64_counter("genarrative.puzzle_gallery.cache.hits") + .with_description("Puzzle gallery response cache hits") + .build(), + misses: meter + .u64_counter("genarrative.puzzle_gallery.cache.misses") + .with_description("Puzzle gallery response cache misses") + .build(), + rebuilds: meter + .u64_counter("genarrative.puzzle_gallery.cache.rebuilds") + .with_description("Puzzle gallery response cache rebuild count") + .build(), + rebuild_duration: meter + .f64_histogram("genarrative.puzzle_gallery.cache.rebuild.duration") + .with_unit("s") + .with_description("Puzzle gallery response cache rebuild duration") + .build(), + data_json_bytes: meter + .u64_histogram("genarrative.puzzle_gallery.cache.data_json_bytes") + .with_unit("By") + .with_description("Serialized puzzle gallery data JSON size") + .build(), + } + }) +} + +fn register_http_request_permits_available_metric(gauge: Arc) { + let meter = global::meter("genarrative-api"); + meter + .i64_observable_up_down_counter("genarrative.http.server.request_permits.available") + .with_unit("{permit}") + .with_description("Available api-server HTTP backpressure permits") + .with_callback(move |observer| { + observer.observe(gauge.load(Ordering::Relaxed), &[]); + }) + .build(); +} + +pub(crate) fn register_http_runtime_metrics() { + static REGISTERED: OnceLock<()> = OnceLock::new(); + REGISTERED.get_or_init(|| { + let meter = global::meter("genarrative-api"); + meter + .i64_observable_up_down_counter("genarrative.http.server.response_bodies.in_flight") + .with_unit("{response}") + .with_description("HTTP response bodies still owned by Axum/Hyper") + .with_callback(|observer| { + observer.observe(HTTP_RESPONSE_BODY_IN_FLIGHT.load(Ordering::Relaxed), &[]); + }) + .build(); + }); +} + +fn http_base_labels(method: String, route: String) -> Vec { + vec![ + KeyValue::new("http.request.method", method), + KeyValue::new("http.route", route), + ] +} + +fn http_response_labels(mut labels: Vec, status: u16) -> Vec { + labels.push(KeyValue::new("status_class", status_class(status))); + labels +} + +fn status_class(status: u16) -> &'static str { + match status { + 100..=199 => "1xx", + 200..=299 => "2xx", + 300..=399 => "3xx", + 400..=499 => "4xx", + 500..=599 => "5xx", + _ => "unknown", + } +} + +pub(crate) fn observability_route(path: &str) -> String { + if path.starts_with("/api/runtime/puzzle/gallery") { + "/api/runtime/puzzle/gallery".to_string() + } else if path.starts_with("/api/runtime/custom-world-gallery") { + "/api/runtime/custom-world-gallery".to_string() + } else if path.starts_with("/admin/api/") { + "/admin/api/*".to_string() + } else if path.starts_with("/api/") { + "/api/*".to_string() + } else { + "other".to_string() + } +} + +pub(crate) fn resolve_request_scheme(headers: &HeaderMap) -> String { + headers + .get("x-forwarded-proto") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(',').next()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("http") + .to_string() +} + +#[cfg(test)] +mod tests { + use axum::http::{HeaderMap, HeaderValue}; + + use super::{observability_route, resolve_request_scheme}; + + #[test] + fn observability_route_keeps_metrics_labels_low_cardinality() { + assert_eq!( + observability_route("/api/runtime/puzzle/gallery?cursor=abc"), + "/api/runtime/puzzle/gallery" + ); + assert_eq!( + observability_route("/api/runtime/puzzle/runs/run-123/history"), + "/api/*" + ); + assert_eq!( + observability_route("/admin/api/debug/http"), + "/admin/api/*" + ); + } + + #[test] + fn resolve_request_scheme_uses_forwarded_proto_first_value() { + let mut headers = HeaderMap::new(); + headers.insert( + "x-forwarded-proto", + HeaderValue::from_static("https, http"), + ); + + assert_eq!(resolve_request_scheme(&headers), "https"); + } +} diff --git a/server-rs/crates/module-auth/Cargo.toml b/server-rs/crates/module-auth/Cargo.toml index eb7fa7b5..082ac278 100644 --- a/server-rs/crates/module-auth/Cargo.toml +++ b/server-rs/crates/module-auth/Cargo.toml @@ -9,6 +9,7 @@ platform-auth = { workspace = true } shared-kernel = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +sha2 = { workspace = true } time = { workspace = true, features = ["formatting", "parsing"] } tracing = { workspace = true } diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index 815be0e7..3d628ec1 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -18,10 +18,11 @@ use std::{ }; use platform_auth::{ - SmsAuthProvider, SmsProviderError, SmsSendCodeRequest, SmsVerifyCodeRequest, hash_password, + SmsAuthProvider, SmsAuthProviderKind, SmsProviderError, SmsSendCodeRequest, hash_password, verify_password, }; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use shared_kernel::{ build_prefixed_uuid_id, format_rfc3339 as format_shared_rfc3339, new_uuid_simple_string, normalize_optional_string, normalize_required_string, parse_rfc3339, @@ -77,6 +78,7 @@ struct StoredRefreshSession { struct StoredPhoneCode { phone_number: String, scene: PhoneAuthScene, + verify_code_hash: String, expires_at: String, last_sent_at: String, failed_attempts: u32, @@ -117,6 +119,7 @@ pub struct AuthUserService { pub struct PhoneAuthService { store: InMemoryAuthStore, sms_provider: SmsAuthProvider, + verify_code_salt: String, } #[derive(Clone, Debug)] @@ -431,6 +434,7 @@ impl PhoneAuthService { Self { store, sms_provider, + verify_code_salt: new_uuid_simple_string(), } } @@ -442,6 +446,7 @@ impl PhoneAuthService { let scene = input.scene.clone(); let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; let national_phone_number = build_national_phone_number(&normalized_phone.e164)?; + let verify_code = self.generate_phone_verify_code(); info!( scene = scene.as_str(), provider = self.sms_provider.kind().as_str(), @@ -457,12 +462,19 @@ impl PhoneAuthService { let expires_at = format_rfc3339(expires_at).map_err(|message| { PhoneAuthError::Store(format!("短信验证码过期时间格式化失败:{message}")) })?; + let verify_code_hash = hash_phone_verify_code( + &self.verify_code_salt, + &normalized_phone.e164, + &scene, + &verify_code, + ); let provider_result = self .sms_provider .send_code(SmsSendCodeRequest { national_phone_number, scene: input.scene.as_str().to_string(), + verify_code, }) .await .map_err(map_sms_provider_error_to_phone_error)?; @@ -488,6 +500,7 @@ impl PhoneAuthService { StoredPhoneCode { phone_number: normalized_phone.e164.clone(), scene, + verify_code_hash, expires_at, last_sent_at: format_rfc3339(now).map_err(|message| { PhoneAuthError::Store(format!("短信验证码发送时间格式化失败:{message}")) @@ -516,28 +529,12 @@ impl PhoneAuthService { ) -> Result { let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; verify_sms_code_format(&input.verify_code)?; - let provider_out_id = self.store.assert_phone_code_active( + let provider_out_id = self.verify_phone_code( &normalized_phone.e164, &PhoneAuthScene::Login, + &input.verify_code, now, )?; - match self - .sms_provider - .verify_code(SmsVerifyCodeRequest { - national_phone_number: build_national_phone_number(&normalized_phone.e164)?, - verify_code: input.verify_code.trim().to_string(), - provider_out_id: provider_out_id.clone(), - }) - .await - { - Ok(()) => self - .store - .consume_phone_code_success(&normalized_phone.e164, &PhoneAuthScene::Login)?, - Err(SmsProviderError::InvalidVerifyCode) => self - .store - .consume_phone_code_failure(&normalized_phone.e164, &PhoneAuthScene::Login)?, - Err(other) => return Err(map_sms_provider_error_to_phone_error(other)), - } if let Some(user) = self .store @@ -582,30 +579,12 @@ impl PhoneAuthService { let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; verify_sms_code_format(&input.verify_code)?; validate_password(&input.new_password).map_err(map_password_error_to_phone_error)?; - let provider_out_id = self.store.assert_phone_code_active( + let provider_out_id = self.verify_phone_code( &normalized_phone.e164, &PhoneAuthScene::ResetPassword, + &input.verify_code, now, )?; - match self - .sms_provider - .verify_code(SmsVerifyCodeRequest { - national_phone_number: build_national_phone_number(&normalized_phone.e164)?, - verify_code: input.verify_code.trim().to_string(), - provider_out_id: provider_out_id.clone(), - }) - .await - { - Ok(()) => self.store.consume_phone_code_success( - &normalized_phone.e164, - &PhoneAuthScene::ResetPassword, - )?, - Err(SmsProviderError::InvalidVerifyCode) => self.store.consume_phone_code_failure( - &normalized_phone.e164, - &PhoneAuthScene::ResetPassword, - )?, - Err(other) => return Err(map_sms_provider_error_to_phone_error(other)), - } self.store .find_by_phone_number(&normalized_phone.e164)? @@ -632,28 +611,12 @@ impl PhoneAuthService { ) -> Result { let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; verify_sms_code_format(&input.verify_code)?; - let provider_out_id = self.store.assert_phone_code_active( + self.verify_phone_code( &normalized_phone.e164, &PhoneAuthScene::BindPhone, + &input.verify_code, now, )?; - match self - .sms_provider - .verify_code(SmsVerifyCodeRequest { - national_phone_number: build_national_phone_number(&normalized_phone.e164)?, - verify_code: input.verify_code.trim().to_string(), - provider_out_id, - }) - .await - { - Ok(()) => self - .store - .consume_phone_code_success(&normalized_phone.e164, &PhoneAuthScene::BindPhone)?, - Err(SmsProviderError::InvalidVerifyCode) => self - .store - .consume_phone_code_failure(&normalized_phone.e164, &PhoneAuthScene::BindPhone)?, - Err(other) => return Err(map_sms_provider_error_to_phone_error(other)), - } let current_user = self .store @@ -677,6 +640,35 @@ impl PhoneAuthService { }) } + fn verify_phone_code( + &self, + phone_number: &str, + scene: &PhoneAuthScene, + verify_code: &str, + now: OffsetDateTime, + ) -> Result, PhoneAuthError> { + let stored = self.store.get_active_phone_code(phone_number, scene, now)?; + let expected_hash = + hash_phone_verify_code(&self.verify_code_salt, phone_number, scene, verify_code); + if stored.verify_code_hash != expected_hash { + self.store.consume_phone_code_failure(phone_number, scene)?; + return Err(PhoneAuthError::InvalidVerifyCode); + } + self.store.consume_phone_code_success(phone_number, scene)?; + Ok(stored.provider_out_id) + } + + fn generate_phone_verify_code(&self) -> String { + match self.sms_provider.kind() { + SmsAuthProviderKind::Mock => self + .sms_provider + .mock_verify_code() + .map(str::to_string) + .unwrap_or_else(|| "123456".to_string()), + SmsAuthProviderKind::Aliyun => generate_random_phone_verify_code(), + } + } + pub async fn bind_wechat_verified_phone( &self, input: BindWechatVerifiedPhoneInput, @@ -1518,12 +1510,12 @@ impl InMemoryAuthStore { }) } - fn assert_phone_code_active( + fn get_active_phone_code( &self, phone_number: &str, scene: &PhoneAuthScene, now: OffsetDateTime, - ) -> Result, PhoneAuthError> { + ) -> Result { let mut state = self .inner .lock() @@ -1543,7 +1535,7 @@ impl InMemoryAuthStore { state.phone_codes_by_key.remove(&key); return Err(PhoneAuthError::VerifyCodeExpired); } - Ok(stored.provider_out_id) + Ok(stored) } fn consume_phone_code_success( @@ -2139,6 +2131,36 @@ fn build_random_password_seed() -> String { ) } +fn generate_random_phone_verify_code() -> String { + let digest = Sha256::digest(new_uuid_simple_string().as_bytes()); + let mut digits = digest + .iter() + .take(SMS_CODE_LENGTH) + .map(|byte| char::from(b'0' + (*byte % 10))) + .collect::(); + while digits.len() < SMS_CODE_LENGTH { + digits.push('0'); + } + digits +} + +fn hash_phone_verify_code( + salt: &str, + phone_number: &str, + scene: &PhoneAuthScene, + verify_code: &str, +) -> String { + let content = format!( + "{}:{}:{}:{}", + salt, + phone_number.trim(), + scene.as_str(), + verify_code.trim() + ); + let digest = Sha256::digest(content.as_bytes()); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + fn format_rfc3339(value: OffsetDateTime) -> Result { format_shared_rfc3339(value) } @@ -2655,6 +2677,14 @@ mod tests { assert!(bind_result.await.is_ok()); } + #[test] + fn random_phone_verify_code_is_six_digits() { + let code = generate_random_phone_verify_code(); + + assert_eq!(code.len(), SMS_CODE_LENGTH); + assert!(code.chars().all(|character| character.is_ascii_digit())); + } + #[tokio::test] async fn phone_login_expires_code_after_too_many_wrong_attempts() { let service = build_phone_service(build_store()); diff --git a/server-rs/crates/module-bark-battle/src/application.rs b/server-rs/crates/module-bark-battle/src/application.rs new file mode 100644 index 00000000..840d5977 --- /dev/null +++ b/server-rs/crates/module-bark-battle/src/application.rs @@ -0,0 +1 @@ +//! 中文注释:汪汪声浪领域应用服务预留落位,当前规则仍集中在 domain/scoring。 diff --git a/server-rs/crates/module-bark-battle/src/commands.rs b/server-rs/crates/module-bark-battle/src/commands.rs new file mode 100644 index 00000000..c6be3434 --- /dev/null +++ b/server-rs/crates/module-bark-battle/src/commands.rs @@ -0,0 +1 @@ +//! 中文注释:汪汪声浪命令归一化预留落位,当前无独立命令构造。 diff --git a/server-rs/crates/module-bark-battle/src/errors.rs b/server-rs/crates/module-bark-battle/src/errors.rs new file mode 100644 index 00000000..06ea419b --- /dev/null +++ b/server-rs/crates/module-bark-battle/src/errors.rs @@ -0,0 +1 @@ +//! 中文注释:汪汪声浪领域错误预留落位,当前复用调用方错误文本。 diff --git a/server-rs/crates/module-bark-battle/src/events.rs b/server-rs/crates/module-bark-battle/src/events.rs new file mode 100644 index 00000000..fc838aae --- /dev/null +++ b/server-rs/crates/module-bark-battle/src/events.rs @@ -0,0 +1 @@ +//! 中文注释:汪汪声浪领域事件预留落位,当前不导出独立事件类型。 diff --git a/server-rs/crates/module-bark-battle/src/lib.rs b/server-rs/crates/module-bark-battle/src/lib.rs index b587645a..64a3b54d 100644 --- a/server-rs/crates/module-bark-battle/src/lib.rs +++ b/server-rs/crates/module-bark-battle/src/lib.rs @@ -1,4 +1,8 @@ +mod application; +mod commands; pub mod domain; +mod errors; +mod events; pub mod scoring; pub use domain::*; diff --git a/server-rs/crates/module-big-fish/src/commands.rs b/server-rs/crates/module-big-fish/src/commands.rs index 72d67bdd..0b186ac7 100644 --- a/server-rs/crates/module-big-fish/src/commands.rs +++ b/server-rs/crates/module-big-fish/src/commands.rs @@ -68,7 +68,7 @@ pub struct BigFishWorkRemixInput { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BigFishWorksProcedureResult { pub ok: bool, - pub items_json: Option, + pub items: Vec, pub error_message: Option, } @@ -188,9 +188,9 @@ pub struct BigFishInputSubmitInput { } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct BigFishRunProcedureResult { pub ok: bool, - pub run_json: Option, + pub run: Option, pub error_message: Option, } diff --git a/server-rs/crates/module-creative-agent/src/events.rs b/server-rs/crates/module-creative-agent/src/events.rs new file mode 100644 index 00000000..669dec26 --- /dev/null +++ b/server-rs/crates/module-creative-agent/src/events.rs @@ -0,0 +1 @@ +//! 中文注释:创意 Agent 领域事件预留落位,当前流程不导出独立事件类型。 diff --git a/server-rs/crates/module-creative-agent/src/lib.rs b/server-rs/crates/module-creative-agent/src/lib.rs index b68fa524..b700e48b 100644 --- a/server-rs/crates/module-creative-agent/src/lib.rs +++ b/server-rs/crates/module-creative-agent/src/lib.rs @@ -2,6 +2,7 @@ mod application; mod commands; mod domain; mod errors; +mod events; pub use application::*; pub use commands::*; diff --git a/server-rs/crates/module-match3d/src/application.rs b/server-rs/crates/module-match3d/src/application.rs index 111550e7..64ddef75 100644 --- a/server-rs/crates/module-match3d/src/application.rs +++ b/server-rs/crates/module-match3d/src/application.rs @@ -237,7 +237,9 @@ pub fn confirm_click_at( return Ok(rejected(next, Match3DClickRejectReason::ItemNotClickable)); } - let Some(slot_index) = first_empty_slot_index(&next.tray_slots) else { + let Some(slot_index) = + insert_item_into_tray_after_same_type(&mut next.tray_slots, &mut next.items, item_index) + else { next = fail_run(next, Match3DFailureReason::TrayFull, client_action_id); return Ok(rejected(next, Match3DClickRejectReason::TrayFull)); }; @@ -246,7 +248,6 @@ pub fn confirm_click_at( next.items[item_index].state = Match3DItemState::InTray; next.items[item_index].clickable = false; next.items[item_index].tray_slot_index = Some(slot_index); - fill_tray_slot(&mut next.tray_slots, slot_index, &next.items[item_index]); let cleared_item_instance_ids = clear_first_triple(&mut next, &item_type_id); compact_tray(&mut next); @@ -540,12 +541,64 @@ fn first_empty_slot_index(slots: &[Match3DTraySlot]) -> Option { .map(|slot| slot.slot_index) } -fn fill_tray_slot(slots: &mut [Match3DTraySlot], slot_index: u32, item: &Match3DItemSnapshot) { - if let Some(slot) = slots.iter_mut().find(|slot| slot.slot_index == slot_index) { - slot.item_instance_id = Some(item.item_instance_id.clone()); - slot.item_type_id = Some(item.item_type_id.clone()); - slot.visual_key = Some(item.visual_key.clone()); +fn insert_item_into_tray_after_same_type( + slots: &mut [Match3DTraySlot], + items: &mut [Match3DItemSnapshot], + item_index: usize, +) -> Option { + let occupied = slots + .iter() + .filter_map(|slot| { + Some(( + slot.item_instance_id.clone()?, + slot.item_type_id.clone()?, + slot.visual_key.clone()?, + )) + }) + .collect::>(); + if occupied.len() >= slots.len() { + return None; } + + let item = items.get(item_index)?.clone(); + let insertion_index = occupied + .iter() + .rposition(|(_, item_type_id, _)| item_type_id == &item.item_type_id) + .map(|index| index + 1) + .unwrap_or(occupied.len()); + let mut next_occupied = occupied; + next_occupied.insert( + insertion_index, + ( + item.item_instance_id.clone(), + item.item_type_id.clone(), + item.visual_key.clone(), + ), + ); + + for slot in slots.iter_mut() { + slot.item_instance_id = None; + slot.item_type_id = None; + slot.visual_key = None; + } + for (index, (item_instance_id, item_type_id, visual_key)) in + next_occupied.into_iter().enumerate() + { + let slot_index = index as u32; + if let Some(slot) = slots.iter_mut().find(|slot| slot.slot_index == slot_index) { + slot.item_instance_id = Some(item_instance_id.clone()); + slot.item_type_id = Some(item_type_id); + slot.visual_key = Some(visual_key); + } + if let Some(entry) = items + .iter_mut() + .find(|entry| entry.item_instance_id == item_instance_id) + { + entry.tray_slot_index = Some(slot_index); + } + } + + Some(insertion_index as u32) } fn clear_first_triple(run: &mut Match3DRunSnapshot, item_type_id: &str) -> Vec { @@ -579,6 +632,7 @@ fn clear_first_triple(run: &mut Match3DRunSnapshot, item_type_id: &str) -> Vec= MATCH3D_BOARD_CENTER { "r" } else { "l" }, - if item.y >= MATCH3D_BOARD_CENTER { "b" } else { "t" }, + if item.x >= MATCH3D_BOARD_CENTER { + "r" + } else { + "l" + }, + if item.y >= MATCH3D_BOARD_CENTER { + "b" + } else { + "t" + }, ); *quadrants.entry(quadrant).or_default() += 1; } @@ -1108,6 +1170,82 @@ mod tests { ); } + #[test] + fn clicking_item_inserts_after_same_type_and_shifts_following_slots() { + let mut run = Match3DRunSnapshot { + run_id: "run-insert".to_string(), + profile_id: "profile-1".to_string(), + owner_user_id: "user-1".to_string(), + status: Match3DRunStatus::Running, + started_at_ms: 0, + duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS, + remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS, + clear_count: 3, + total_item_count: 4, + cleared_item_count: 0, + board_version: 1, + items: vec![ + manual_item("apple-3", "apple", None), + manual_item("apple-1", "apple", Some(0)), + manual_item("apple-2", "apple", Some(1)), + manual_item("pear-1", "pear", Some(2)), + ], + tray_slots: empty_tray_slots(), + failure_reason: None, + last_confirmed_action_id: None, + }; + run.tray_slots[0].item_instance_id = Some("apple-1".to_string()); + run.tray_slots[0].item_type_id = Some("apple".to_string()); + run.tray_slots[0].visual_key = Some("apple".to_string()); + run.tray_slots[1].item_instance_id = Some("apple-2".to_string()); + run.tray_slots[1].item_type_id = Some("apple".to_string()); + run.tray_slots[1].visual_key = Some("apple".to_string()); + run.tray_slots[2].item_instance_id = Some("pear-1".to_string()); + run.tray_slots[2].item_type_id = Some("pear".to_string()); + run.tray_slots[2].visual_key = Some("pear".to_string()); + + let confirmed = confirm_click_at( + &run, + &Match3DClickInput { + run_id: run.run_id.clone(), + owner_user_id: run.owner_user_id.clone(), + item_instance_id: "apple-3".to_string(), + client_action_id: "action-insert".to_string(), + snapshot_version: run.board_version, + clicked_at_ms: 1_000, + }, + ) + .expect("click should confirm"); + + assert_eq!(confirmed.entered_slot_index, Some(2)); + assert_eq!( + confirmed + .run + .tray_slots + .iter() + .map(|slot| slot.item_instance_id.as_deref()) + .collect::>(), + vec![Some("pear-1"), None, None, None, None, None, None] + ); + assert_eq!( + confirmed + .run + .items + .iter() + .find(|item| item.item_instance_id == "pear-1") + .and_then(|item| item.tray_slot_index), + Some(0) + ); + assert_eq!( + confirmed.cleared_item_instance_ids, + vec![ + "apple-1".to_string(), + "apple-2".to_string(), + "apple-3".to_string() + ] + ); + } + #[test] fn tray_full_fails_when_no_triple_can_clear() { let mut run = Match3DRunSnapshot { diff --git a/server-rs/crates/module-puzzle/src/application.rs b/server-rs/crates/module-puzzle/src/application.rs index b592292d..eed25933 100644 --- a/server-rs/crates/module-puzzle/src/application.rs +++ b/server-rs/crates/module-puzzle/src/application.rs @@ -16,7 +16,7 @@ use crate::{domain::*, errors::PuzzleFieldError}; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleAgentSessionProcedureResult { pub ok: bool, - pub session_json: Option, + pub session: Option, pub error_message: Option, } @@ -24,7 +24,7 @@ pub struct PuzzleAgentSessionProcedureResult { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleWorksProcedureResult { pub ok: bool, - pub items_json: Option, + pub items: Vec, pub error_message: Option, } @@ -32,15 +32,15 @@ pub struct PuzzleWorksProcedureResult { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleWorkProcedureResult { pub ok: bool, - pub item_json: Option, + pub item: Option, pub error_message: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct PuzzleRunProcedureResult { pub ok: bool, - pub run_json: Option, + pub run: Option, pub error_message: Option, } @@ -786,6 +786,45 @@ fn first_profile_level(profile: &PuzzleWorkProfile) -> Option .next() } +fn first_profile_ui_background_level(profile: &PuzzleWorkProfile) -> Option { + normalize_puzzle_levels(profile.levels.clone(), &profile.theme_tags) + .unwrap_or_else(|_| profile.levels.clone()) + .into_iter() + .find(|level| { + level + .ui_background_image_src + .as_deref() + .and_then(normalize_required_string) + .is_some() + || level + .ui_background_image_object_key + .as_deref() + .and_then(normalize_required_string) + .is_some() + }) +} + +fn resolve_puzzle_runtime_ui_background_fields( + level: Option<&PuzzleDraftLevel>, + fallback_level: Option<&PuzzleDraftLevel>, +) -> (Option, Option) { + for candidate in [level, fallback_level].into_iter().flatten() { + let image_src = candidate + .ui_background_image_src + .as_deref() + .and_then(normalize_required_string); + let object_key = candidate + .ui_background_image_object_key + .as_deref() + .and_then(|value| normalize_required_string(value.trim_start_matches('/'))); + if image_src.is_some() || object_key.is_some() { + return (image_src, object_key); + } + } + + (None, None) +} + pub fn resolve_puzzle_runtime_remaining_ms(level: &PuzzleRuntimeLevelSnapshot, now_ms: u64) -> u64 { let time_limit_ms = if level.time_limit_ms == 0 { resolve_puzzle_level_time_limit_ms_by_index(level.level_index) @@ -1047,6 +1086,12 @@ pub fn start_run_with_shuffle_seed_at( let grid_size = level_config.grid_size; let board = build_initial_board_with_seed(grid_size, shuffle_seed)?; let current_profile_level = first_profile_level(entry_profile); + let ui_background_level = first_profile_ui_background_level(entry_profile); + let (ui_background_image_src, ui_background_image_object_key) = + resolve_puzzle_runtime_ui_background_fields( + current_profile_level.as_ref(), + ui_background_level.as_ref(), + ); Ok(PuzzleRunSnapshot { run_id: run_id.clone(), entry_profile_id: entry_profile.profile_id.clone(), @@ -1067,12 +1112,8 @@ pub fn start_run_with_shuffle_seed_at( author_display_name: entry_profile.author_display_name.clone(), theme_tags: entry_profile.theme_tags.clone(), cover_image_src: entry_profile.cover_image_src.clone(), - ui_background_image_src: current_profile_level - .as_ref() - .and_then(|level| level.ui_background_image_src.clone()), - ui_background_image_object_key: current_profile_level - .as_ref() - .and_then(|level| level.ui_background_image_object_key.clone()), + ui_background_image_src, + ui_background_image_object_key, background_music: current_profile_level .as_ref() .and_then(|level| level.background_music.clone()), @@ -1326,6 +1367,16 @@ pub fn advance_next_level_at( let mut played_profile_ids = run.played_profile_ids.clone(); played_profile_ids.push(next_profile.profile_id.clone()); let current_profile_level = first_profile_level(next_profile); + let ui_background_level = first_profile_ui_background_level(next_profile); + let (mut ui_background_image_src, mut ui_background_image_object_key) = + resolve_puzzle_runtime_ui_background_fields( + current_profile_level.as_ref(), + ui_background_level.as_ref(), + ); + if ui_background_image_src.is_none() && ui_background_image_object_key.is_none() { + ui_background_image_src = current_level.ui_background_image_src.clone(); + ui_background_image_object_key = current_level.ui_background_image_object_key.clone(); + } Ok(PuzzleRunSnapshot { run_id: run.run_id.clone(), @@ -1347,12 +1398,8 @@ pub fn advance_next_level_at( author_display_name: next_profile.author_display_name.clone(), theme_tags: next_profile.theme_tags.clone(), cover_image_src: next_profile.cover_image_src.clone(), - ui_background_image_src: current_profile_level - .as_ref() - .and_then(|level| level.ui_background_image_src.clone()), - ui_background_image_object_key: current_profile_level - .as_ref() - .and_then(|level| level.ui_background_image_object_key.clone()), + ui_background_image_src, + ui_background_image_object_key, background_music: current_profile_level .as_ref() .and_then(|level| level.background_music.clone()), @@ -1408,6 +1455,12 @@ pub fn advance_to_new_work_first_level_at( played_profile_ids.push(next_profile.profile_id.clone()); } let current_profile_level = first_profile_level(next_profile); + let ui_background_level = first_profile_ui_background_level(next_profile); + let (ui_background_image_src, ui_background_image_object_key) = + resolve_puzzle_runtime_ui_background_fields( + current_profile_level.as_ref(), + ui_background_level.as_ref(), + ); Ok(PuzzleRunSnapshot { run_id: run.run_id.clone(), @@ -1429,12 +1482,8 @@ pub fn advance_to_new_work_first_level_at( author_display_name: next_profile.author_display_name.clone(), theme_tags: next_profile.theme_tags.clone(), cover_image_src: next_profile.cover_image_src.clone(), - ui_background_image_src: current_profile_level - .as_ref() - .and_then(|level| level.ui_background_image_src.clone()), - ui_background_image_object_key: current_profile_level - .as_ref() - .and_then(|level| level.ui_background_image_object_key.clone()), + ui_background_image_src, + ui_background_image_object_key, background_music: current_profile_level .as_ref() .and_then(|level| level.background_music.clone()), @@ -3151,8 +3200,7 @@ mod tests { .background_music .as_ref() .map(|music| music.audio_src.as_str()), - Some("/generated-puzzle-assets/background.mp3".to_string()) - .as_deref() + Some("/generated-puzzle-assets/background.mp3".to_string()).as_deref() ); assert_eq!( current_level.ui_background_image_object_key.as_deref(), @@ -3175,8 +3223,8 @@ mod tests { current_level.cleared_at_ms = Some(2_000); current_level.elapsed_ms = Some(1_000); - let next_run = - advance_to_new_work_first_level_at(&cleared_run, &next_profile, 3_000).expect("next run"); + let next_run = advance_to_new_work_first_level_at(&cleared_run, &next_profile, 3_000) + .expect("next run"); assert_eq!( next_run @@ -3187,6 +3235,52 @@ mod tests { ); } + #[test] + fn same_work_next_level_inherits_first_available_ui_background() { + let mut profile = build_published_profile("entry", "owner-a", vec!["奇幻"]); + profile.levels[0].ui_background_image_src = + Some("/generated-puzzle-assets/entry-ui.png".to_string()); + profile.levels.push(PuzzleDraftLevel { + level_id: "puzzle-level-2".to_string(), + level_name: "第二关".to_string(), + picture_description: "第二关画面".to_string(), + picture_reference: None, + ui_background_prompt: None, + ui_background_image_src: None, + ui_background_image_object_key: None, + background_music: None, + candidates: Vec::new(), + selected_candidate_id: None, + cover_image_src: Some("/level-2.png".to_string()), + cover_asset_id: None, + generation_status: "ready".to_string(), + }); + + let mut run = start_run("run-same-work-ui".to_string(), &profile, 0).expect("run"); + run.cleared_level_count = run.current_level_index; + let current_level = run.current_level.as_mut().expect("level"); + current_level.status = PuzzleRuntimeLevelStatus::Cleared; + current_level.cleared_at_ms = Some(2_000); + current_level.elapsed_ms = Some(1_000); + let next_level = selected_profile_level_after_runtime_level(&profile, current_level) + .expect("same work next level"); + let mut next_profile = profile.clone(); + next_profile.level_name = next_level.level_name.clone(); + next_profile.cover_image_src = next_level.cover_image_src.clone(); + next_profile.cover_asset_id = next_level.cover_asset_id.clone(); + next_profile.levels = vec![next_level]; + + let next_run = advance_next_level_at(&run, &next_profile, 3_000).expect("next run"); + + assert_eq!( + next_run + .current_level + .as_ref() + .and_then(|level| level.ui_background_image_src.as_deref()), + Some("/generated-puzzle-assets/entry-ui.png") + ); + } + #[test] fn swap_pieces_marks_cleared_when_back_to_origin() { let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]); diff --git a/server-rs/crates/module-visual-novel/src/commands.rs b/server-rs/crates/module-visual-novel/src/commands.rs new file mode 100644 index 00000000..975799fa --- /dev/null +++ b/server-rs/crates/module-visual-novel/src/commands.rs @@ -0,0 +1 @@ +//! 中文注释:视觉小说命令归一化预留落位,当前命令校验仍由 application 承接。 diff --git a/server-rs/crates/module-visual-novel/src/events.rs b/server-rs/crates/module-visual-novel/src/events.rs new file mode 100644 index 00000000..9f691de8 --- /dev/null +++ b/server-rs/crates/module-visual-novel/src/events.rs @@ -0,0 +1 @@ +//! 中文注释:视觉小说领域事件预留落位,当前不导出独立事件类型。 diff --git a/server-rs/crates/module-visual-novel/src/lib.rs b/server-rs/crates/module-visual-novel/src/lib.rs index 290d744e..0b5c82a5 100644 --- a/server-rs/crates/module-visual-novel/src/lib.rs +++ b/server-rs/crates/module-visual-novel/src/lib.rs @@ -1,6 +1,8 @@ mod application; +mod commands; mod domain; mod errors; +mod events; pub use application::*; pub use domain::*; diff --git a/server-rs/crates/platform-auth/src/lib.rs b/server-rs/crates/platform-auth/src/lib.rs index c0efd58a..da9221b6 100644 --- a/server-rs/crates/platform-auth/src/lib.rs +++ b/server-rs/crates/platform-auth/src/lib.rs @@ -24,7 +24,7 @@ pub const DEFAULT_ACCESS_TOKEN_TTL_SECONDS: u64 = 2 * 60 * 60; pub const DEFAULT_REFRESH_COOKIE_NAME: &str = "genarrative_refresh_session"; pub const DEFAULT_REFRESH_COOKIE_PATH: &str = "/api/auth"; pub const DEFAULT_REFRESH_SESSION_TTL_DAYS: u32 = 30; -pub const DEFAULT_SMS_ENDPOINT: &str = "dypnsapi.aliyuncs.com"; +pub const DEFAULT_SMS_ENDPOINT: &str = "dysmsapi.aliyuncs.com"; pub const DEFAULT_SMS_COUNTRY_CODE: &str = "86"; pub const DEFAULT_SMS_TEMPLATE_PARAM_KEY: &str = "code"; pub const DEFAULT_SMS_MOCK_VERIFY_CODE: &str = "123456"; @@ -164,6 +164,7 @@ pub struct SmsAuthConfig { pub struct SmsSendCodeRequest { pub national_phone_number: String, pub scene: String, + pub verify_code: String, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -174,13 +175,6 @@ pub struct SmsSendCodeResult { pub provider_out_id: Option, } -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SmsVerifyCodeRequest { - pub national_phone_number: String, - pub verify_code: String, - pub provider_out_id: Option, -} - #[derive(Clone, Debug, PartialEq, Eq)] pub enum WechatAuthScene { Desktop, @@ -380,7 +374,7 @@ struct WechatPhoneNumberInfo { } #[derive(Debug, Deserialize)] -struct AliyunSendSmsVerifyCodeResponse { +struct AliyunSendSmsResponse { // 阿里云 RPC 原始 JSON 使用首字母大写字段名,这里必须显式映射,避免把成功响应误判成空值。 #[serde(default, rename = "Code")] code: Option, @@ -388,41 +382,8 @@ struct AliyunSendSmsVerifyCodeResponse { message: Option, #[serde(default, rename = "RequestId")] request_id: Option, - #[serde(default, rename = "Success")] - success: Option, - #[serde(default, rename = "Model")] - model: Option, -} - -#[derive(Debug, Deserialize)] -struct AliyunSendSmsVerifyCodeModel { #[serde(default, rename = "BizId")] - _biz_id: Option, - #[serde(default, rename = "OutId")] - out_id: Option, - #[serde(default, rename = "RequestId")] - request_id: Option, -} - -#[derive(Debug, Deserialize)] -struct AliyunCheckSmsVerifyCodeResponse { - // 校验接口同样返回首字母大写字段名,保持和发送接口一致的显式映射。 - #[serde(default, rename = "Code")] - code: Option, - #[serde(default, rename = "Message")] - message: Option, - #[serde(default, rename = "Success")] - success: Option, - #[serde(default, rename = "Model")] - model: Option, -} - -#[derive(Debug, Deserialize)] -struct AliyunCheckSmsVerifyCodeModel { - #[serde(default, rename = "OutId")] - _out_id: Option, - #[serde(default, rename = "VerifyResult")] - verify_result: Option, + biz_id: Option, } impl JwtConfig { @@ -681,10 +642,10 @@ impl SmsAuthProvider { } } - pub async fn verify_code(&self, request: SmsVerifyCodeRequest) -> Result<(), SmsProviderError> { + pub fn mock_verify_code(&self) -> Option<&str> { match self { - Self::Mock(provider) => provider.verify_code(request).await, - Self::Aliyun(provider) => provider.verify_code(request).await, + Self::Mock(provider) => Some(provider.mock_verify_code()), + Self::Aliyun(_) => None, } } } @@ -1228,6 +1189,7 @@ impl MockSmsAuthProvider { &self, request: SmsSendCodeRequest, ) -> Result { + let _verify_code = request.verify_code; let provider_out_id = build_sms_provider_out_id(&request.scene, &request.national_phone_number); @@ -1239,11 +1201,8 @@ impl MockSmsAuthProvider { }) } - async fn verify_code(&self, request: SmsVerifyCodeRequest) -> Result<(), SmsProviderError> { - if request.verify_code.trim() != self.config.mock_verify_code { - return Err(SmsProviderError::InvalidVerifyCode); - } - Ok(()) + fn mock_verify_code(&self) -> &str { + self.config.mock_verify_code.as_str() } } @@ -1256,8 +1215,7 @@ impl AliyunSmsAuthProvider { build_sms_provider_out_id(&request.scene, &request.national_phone_number); let phone_masked = mask_phone_number(&request.national_phone_number); let template_param = serde_json::json!({ - self.config.template_param_key.clone(): "##code##", - "min": self.config.valid_time_seconds, + self.config.template_param_key.clone(): request.verify_code.trim(), }) .to_string(); info!( @@ -1267,54 +1225,28 @@ impl AliyunSmsAuthProvider { endpoint = self.config.endpoint.as_str(), sign_name = self.config.sign_name.as_str(), template_code = self.config.template_code.as_str(), - code_length = self.config.code_length, valid_time_seconds = self.config.valid_time_seconds, interval_seconds = self.config.interval_seconds, provider_out_id = provider_out_id.as_str(), - "准备调用阿里云短信发送接口" + "准备调用阿里云 SendSms 短信发送接口" ); let mut query = BTreeMap::new(); - query.insert("Action".to_string(), "SendSmsVerifyCode".to_string()); + query.insert("Action".to_string(), "SendSms".to_string()); query.insert("Format".to_string(), "json".to_string()); query.insert("Version".to_string(), "2017-05-25".to_string()); query.insert( - "PhoneNumber".to_string(), + "PhoneNumbers".to_string(), request.national_phone_number.trim().to_string(), ); - query.insert("CountryCode".to_string(), self.config.country_code.clone()); query.insert("SignName".to_string(), self.config.sign_name.clone()); query.insert( "TemplateCode".to_string(), self.config.template_code.clone(), ); query.insert("TemplateParam".to_string(), template_param); - query.insert( - "CodeLength".to_string(), - self.config.code_length.to_string(), - ); - query.insert("CodeType".to_string(), self.config.code_type.to_string()); - query.insert( - "ValidTime".to_string(), - self.config.valid_time_seconds.to_string(), - ); - query.insert( - "Interval".to_string(), - self.config.interval_seconds.to_string(), - ); - query.insert( - "DuplicatePolicy".to_string(), - self.config.duplicate_policy.to_string(), - ); - query.insert( - "ReturnVerifyCode".to_string(), - self.config.return_verify_code.to_string(), - ); query.insert("OutId".to_string(), provider_out_id.clone()); - if let Some(scheme_name) = self.config.scheme_name.clone() { - query.insert("SchemeName".to_string(), scheme_name); - } - let signature_headers = self.build_signature_headers("SendSmsVerifyCode", &query)?; + let signature_headers = self.build_signature_headers("SendSms", &query)?; let payload = self .client @@ -1334,23 +1266,12 @@ impl AliyunSmsAuthProvider { http_status = http_status.as_u16(), provider_code = body.code.as_deref().unwrap_or("unknown"), provider_message = body.message.as_deref().unwrap_or("unknown"), - provider_request_id = body - .request_id - .as_deref() - .or_else(|| body - .model - .as_ref() - .and_then(|model| model.request_id.as_deref())) - .unwrap_or("unknown"), - provider_out_id = body - .model - .as_ref() - .and_then(|model| model.out_id.as_deref()) - .unwrap_or("unknown"), - success = body.success.unwrap_or(false), + provider_request_id = body.request_id.as_deref().unwrap_or("unknown"), + provider_out_id = provider_out_id.as_str(), + provider_biz_id = body.biz_id.as_deref().unwrap_or("unknown"), "阿里云短信发送接口返回响应" ); - if !body.success.unwrap_or(false) || body.code.as_deref() != Some("OK") { + if body.code.as_deref() != Some("OK") { warn!( provider = "aliyun", scene = request.scene.as_str(), @@ -1358,19 +1279,9 @@ impl AliyunSmsAuthProvider { http_status = http_status.as_u16(), provider_code = body.code.as_deref().unwrap_or("unknown"), provider_message = body.message.as_deref().unwrap_or("unknown"), - provider_request_id = body - .request_id - .as_deref() - .or_else(|| body - .model - .as_ref() - .and_then(|model| model.request_id.as_deref())) - .unwrap_or("unknown"), - provider_out_id = body - .model - .as_ref() - .and_then(|model| model.out_id.as_deref()) - .unwrap_or("unknown"), + provider_request_id = body.request_id.as_deref().unwrap_or("unknown"), + provider_out_id = provider_out_id.as_str(), + provider_biz_id = body.biz_id.as_deref().unwrap_or("unknown"), "阿里云短信发送接口返回业务失败" ); return Err(map_aliyun_provider_error( @@ -1383,65 +1294,11 @@ impl AliyunSmsAuthProvider { Ok(SmsSendCodeResult { cooldown_seconds: self.config.interval_seconds, expires_in_seconds: self.config.valid_time_seconds, - provider_request_id: body.request_id.or_else(|| { - body.model - .as_ref() - .and_then(|model| model.request_id.clone()) - }), - provider_out_id: body.model.and_then(|model| model.out_id), + provider_request_id: body.request_id, + provider_out_id: Some(provider_out_id), }) } - async fn verify_code(&self, request: SmsVerifyCodeRequest) -> Result<(), SmsProviderError> { - let mut query = BTreeMap::new(); - query.insert("Action".to_string(), "CheckSmsVerifyCode".to_string()); - query.insert("Format".to_string(), "json".to_string()); - query.insert("Version".to_string(), "2017-05-25".to_string()); - query.insert( - "PhoneNumber".to_string(), - request.national_phone_number.trim().to_string(), - ); - query.insert("CountryCode".to_string(), self.config.country_code.clone()); - query.insert( - "VerifyCode".to_string(), - request.verify_code.trim().to_string(), - ); - query.insert( - "CaseAuthPolicy".to_string(), - self.config.case_auth_policy.to_string(), - ); - if let Some(scheme_name) = self.config.scheme_name.clone() { - query.insert("SchemeName".to_string(), scheme_name); - } - if let Some(provider_out_id) = request.provider_out_id { - query.insert("OutId".to_string(), provider_out_id); - } - let signature_headers = self.build_signature_headers("CheckSmsVerifyCode", &query)?; - - let payload = self - .client - .post(build_aliyun_sms_url(&self.config.endpoint)?) - .headers(signature_headers) - .form(&query) - .send() - .await - .map_err(|error| SmsProviderError::Upstream(format!("验证码校验失败:{error}")))?; - - let body = parse_aliyun_json_response_for_verify(payload).await?; - if !body.success.unwrap_or(false) || body.code.as_deref() != Some("OK") { - return Err(map_aliyun_provider_error( - "验证码校验失败", - body.message, - body.code, - )); - } - if body.model.and_then(|model| model.verify_result).as_deref() != Some("PASS") { - return Err(SmsProviderError::InvalidVerifyCode); - } - - Ok(()) - } - fn build_signature_headers( &self, action: &str, @@ -1972,16 +1829,15 @@ fn aliyun_percent_encode(value: &str) -> String { async fn parse_aliyun_json_response( response: reqwest::Response, fallback_message: &str, -) -> Result { +) -> Result { let status = response.status(); let body = response .text() .await .map_err(|error| SmsProviderError::Upstream(format!("{fallback_message}:{error}")))?; - let payload = - serde_json::from_str::(&body).map_err(|error| { - SmsProviderError::Upstream(format!("{fallback_message}:响应解析失败:{error}")) - })?; + let payload = serde_json::from_str::(&body).map_err(|error| { + SmsProviderError::Upstream(format!("{fallback_message}:响应解析失败:{error}")) + })?; if status.is_client_error() || status.is_server_error() { return Err(map_http_status_to_sms_provider_error( fallback_message, @@ -1993,29 +1849,6 @@ async fn parse_aliyun_json_response( Ok(payload) } -async fn parse_aliyun_json_response_for_verify( - response: reqwest::Response, -) -> Result { - let status = response.status(); - let body = response - .text() - .await - .map_err(|error| SmsProviderError::Upstream(format!("验证码校验失败:{error}")))?; - let payload = - serde_json::from_str::(&body).map_err(|error| { - SmsProviderError::Upstream(format!("验证码校验失败:响应解析失败:{error}")) - })?; - if status.is_client_error() || status.is_server_error() { - return Err(map_http_status_to_sms_provider_error( - "验证码校验失败", - status, - serde_json::from_str::(&body).ok(), - )); - } - - Ok(payload) -} - fn map_http_status_to_sms_provider_error( fallback_message: &str, status: StatusCode, @@ -2053,13 +1886,6 @@ fn map_aliyun_provider_error( let provider_code = provider_code.unwrap_or_default(); let normalized_code = provider_code.trim().to_ascii_uppercase(); - if normalized_code.contains("VERIFY") - || normalized_code.contains("CODE") - || normalized_code.contains("CHECK") - { - return SmsProviderError::InvalidVerifyCode; - } - if normalized_code.contains("MOBILE") || normalized_code.contains("PHONE") || normalized_code.contains("SIGN") @@ -2350,6 +2176,48 @@ mod tests { .expect("mock sms config should be valid") } + fn required_env_for_real_sms_test(name: &str) -> String { + std::env::var(name) + .ok() + .and_then(|value| normalize_required_string(&value)) + .unwrap_or_else(|| panic!("{name} must be set to run the real Aliyun SMS test")) + } + + fn optional_env_for_real_sms_test(name: &str, default_value: &str) -> String { + std::env::var(name) + .ok() + .and_then(|value| normalize_required_string(&value)) + .unwrap_or_else(|| default_value.to_string()) + } + + fn build_real_aliyun_sms_config_from_env() -> SmsAuthConfig { + SmsAuthConfig::new( + SmsAuthProviderKind::Aliyun, + optional_env_for_real_sms_test("ALIYUN_SMS_ENDPOINT", DEFAULT_SMS_ENDPOINT), + Some(required_env_for_real_sms_test("ALIYUN_SMS_ACCESS_KEY_ID")), + Some(required_env_for_real_sms_test( + "ALIYUN_SMS_ACCESS_KEY_SECRET", + )), + optional_env_for_real_sms_test("ALIYUN_SMS_SIGN_NAME", "北京亓盒网络科技"), + optional_env_for_real_sms_test("ALIYUN_SMS_TEMPLATE_CODE", "SMS_506245486"), + optional_env_for_real_sms_test( + "ALIYUN_SMS_TEMPLATE_PARAM_KEY", + DEFAULT_SMS_TEMPLATE_PARAM_KEY, + ), + optional_env_for_real_sms_test("ALIYUN_SMS_COUNTRY_CODE", DEFAULT_SMS_COUNTRY_CODE), + None, + DEFAULT_SMS_CODE_LENGTH, + DEFAULT_SMS_CODE_TYPE, + DEFAULT_SMS_VALID_TIME_SECONDS, + DEFAULT_SMS_INTERVAL_SECONDS, + DEFAULT_SMS_DUPLICATE_POLICY, + DEFAULT_SMS_CASE_AUTH_POLICY, + false, + DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(), + ) + .expect("real aliyun sms config should be valid") + } + #[test] fn round_trip_sign_and_verify_access_token() { let config = build_jwt_config(); @@ -2491,13 +2359,14 @@ mod tests { } #[tokio::test] - async fn mock_sms_provider_sends_and_verifies_code() { + async fn mock_sms_provider_sends_code_and_exposes_fixed_verify_code() { let provider = SmsAuthProvider::new(build_mock_sms_config()).expect("provider should build"); let send_result = provider .send_code(SmsSendCodeRequest { national_phone_number: "13800138000".to_string(), scene: "login".to_string(), + verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(), }) .await .expect("send code should succeed"); @@ -2512,32 +2381,41 @@ mod tests { Some("mock-request-id") ); assert!(send_result.provider_out_id.is_some()); - - provider - .verify_code(SmsVerifyCodeRequest { - national_phone_number: "13800138000".to_string(), - verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(), - provider_out_id: send_result.provider_out_id, - }) - .await - .expect("verify code should succeed"); + assert_eq!( + provider.mock_verify_code(), + Some(DEFAULT_SMS_MOCK_VERIFY_CODE) + ); } #[tokio::test] - async fn mock_sms_provider_rejects_wrong_code() { - let provider = - SmsAuthProvider::new(build_mock_sms_config()).expect("provider should build"); + #[ignore = "requires real Aliyun SMS credentials and sends an actual SMS"] + async fn aliyun_send_sms_real_provider_sends_verify_code() { + let phone_number = required_env_for_real_sms_test("ALIYUN_SMS_REAL_TEST_PHONE_NUMBER"); + let provider = SmsAuthProvider::new(build_real_aliyun_sms_config_from_env()) + .expect("real aliyun provider should build"); - let error = provider - .verify_code(SmsVerifyCodeRequest { - national_phone_number: "13800138000".to_string(), - verify_code: "000000".to_string(), - provider_out_id: None, + let send_result = provider + .send_code(SmsSendCodeRequest { + national_phone_number: phone_number.clone(), + scene: "real_test".to_string(), + verify_code: "123456".to_string(), }) .await - .expect_err("wrong verify code should fail"); + .expect("real aliyun SendSms call should succeed"); - assert_eq!(error, SmsProviderError::InvalidVerifyCode); + println!( + "real Aliyun SendSms accepted phone={} request_id={:?} out_id={:?}", + mask_phone_number(&phone_number), + send_result.provider_request_id, + send_result.provider_out_id + ); + assert!(send_result.provider_request_id.is_some()); + assert!(send_result.provider_out_id.is_some()); + assert_eq!(send_result.cooldown_seconds, DEFAULT_SMS_INTERVAL_SECONDS); + assert_eq!( + send_result.expires_in_seconds, + DEFAULT_SMS_VALID_TIME_SECONDS + ); } #[test] @@ -2574,14 +2452,14 @@ mod tests { let mut params = BTreeMap::new(); params.insert( "TemplateParam".to_string(), - "{\"code\":\"##code##\"}".to_string(), + "{\"code\":\"123456\"}".to_string(), ); - params.insert("Action".to_string(), "SendSmsVerifyCode".to_string()); - params.insert("PhoneNumber".to_string(), "13800138000".to_string()); + params.insert("Action".to_string(), "SendSms".to_string()); + params.insert("PhoneNumbers".to_string(), "13800138000".to_string()); assert_eq!( canonicalize_aliyun_form_params(¶ms), - "Action=SendSmsVerifyCode&PhoneNumber=13800138000&TemplateParam=%7B%22code%22%3A%22%23%23code%23%23%22%7D" + "Action=SendSms&PhoneNumbers=13800138000&TemplateParam=%7B%22code%22%3A%22123456%22%7D" ); } @@ -2613,8 +2491,8 @@ mod tests { }; let headers = provider .build_signature_headers( - "SendSmsVerifyCode", - &BTreeMap::from([("Action".to_string(), "SendSmsVerifyCode".to_string())]), + "SendSms", + &BTreeMap::from([("Action".to_string(), "SendSms".to_string())]), ) .expect("signature headers should build"); @@ -2646,17 +2524,12 @@ mod tests { #[test] fn aliyun_send_response_deserializes_pascal_case_fields() { - let payload = serde_json::from_str::( + let payload = serde_json::from_str::( r#"{ "Code": "OK", "Message": "成功", "RequestId": "req_123", - "Success": true, - "Model": { - "BizId": "biz_456", - "OutId": "out_789", - "RequestId": "req_model_001" - } + "BizId": "biz_456" }"#, ) .expect("aliyun send response should deserialize"); @@ -2664,47 +2537,6 @@ mod tests { assert_eq!(payload.code.as_deref(), Some("OK")); assert_eq!(payload.message.as_deref(), Some("成功")); assert_eq!(payload.request_id.as_deref(), Some("req_123")); - assert_eq!(payload.success, Some(true)); - assert_eq!( - payload - .model - .as_ref() - .and_then(|model| model.out_id.as_deref()), - Some("out_789") - ); - assert_eq!( - payload - .model - .as_ref() - .and_then(|model| model.request_id.as_deref()), - Some("req_model_001") - ); - } - - #[test] - fn aliyun_verify_response_deserializes_pascal_case_fields() { - let payload = serde_json::from_str::( - r#"{ - "Code": "OK", - "Message": "成功", - "Success": true, - "Model": { - "OutId": "out_789", - "VerifyResult": "PASS" - } - }"#, - ) - .expect("aliyun verify response should deserialize"); - - assert_eq!(payload.code.as_deref(), Some("OK")); - assert_eq!(payload.message.as_deref(), Some("成功")); - assert_eq!(payload.success, Some(true)); - assert_eq!( - payload - .model - .as_ref() - .and_then(|model| model.verify_result.as_deref()), - Some("PASS") - ); + assert_eq!(payload.biz_id.as_deref(), Some("biz_456")); } } diff --git a/server-rs/crates/shared-contracts/src/match3d_works.rs b/server-rs/crates/shared-contracts/src/match3d_works.rs index 01c0efa5..bc68558b 100644 --- a/server-rs/crates/shared-contracts/src/match3d_works.rs +++ b/server-rs/crates/shared-contracts/src/match3d_works.rs @@ -151,6 +151,8 @@ pub struct Match3DWorkSummaryResponse { #[serde(default)] pub published_at: Option, pub publish_ready: bool, + #[serde(default)] + pub generation_status: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub background_prompt: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -282,4 +284,36 @@ mod tests { assert_eq!(payload["gameName"], json!("水果抓大鹅")); assert_eq!(payload["clearCount"], json!(4)); } + + #[test] + fn match3d_work_summary_uses_camel_case_generation_status() { + let payload = serde_json::to_value(Match3DWorkSummaryResponse { + work_id: "work-1".to_string(), + profile_id: "profile-1".to_string(), + owner_user_id: "user-1".to_string(), + source_session_id: Some("session-1".to_string()), + game_name: "水果抓大鹅".to_string(), + theme_text: "水果".to_string(), + summary: "水果主题".to_string(), + tags: vec!["水果".to_string()], + cover_image_src: None, + reference_image_src: None, + clear_count: 4, + difficulty: 5, + publication_status: "draft".to_string(), + play_count: 0, + updated_at: "2026-05-01T00:00:00Z".to_string(), + published_at: None, + publish_ready: false, + generation_status: Some("generating".to_string()), + background_prompt: None, + background_image_src: None, + background_image_object_key: None, + generated_background_asset: None, + generated_item_assets: Vec::new(), + }) + .expect("payload should serialize"); + + assert_eq!(payload["generationStatus"], json!("generating")); + } } diff --git a/server-rs/crates/shared-contracts/src/puzzle_gallery.rs b/server-rs/crates/shared-contracts/src/puzzle_gallery.rs index daed2603..ecf89149 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_gallery.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_gallery.rs @@ -6,6 +6,21 @@ use crate::puzzle_works::{PuzzleWorkProfileResponse, PuzzleWorkSummaryResponse}; #[serde(rename_all = "camelCase")] pub struct PuzzleGalleryResponse { pub items: Vec, + #[serde(default)] + pub preview_refs: Vec, + #[serde(default)] + pub has_more: bool, + #[serde(default)] + pub next_cursor: Option, + #[serde(default)] + pub total_count: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PuzzleGalleryWorkRefResponse { + pub work_id: String, + pub profile_id: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/server-rs/crates/shared-contracts/src/puzzle_works.rs b/server-rs/crates/shared-contracts/src/puzzle_works.rs index 339b4f52..8eb2afe0 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_works.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_works.rs @@ -57,6 +57,8 @@ pub struct PuzzleWorkSummaryResponse { pub point_incentive_claimable_points: u64, pub publish_ready: bool, #[serde(default)] + pub generation_status: Option, + #[serde(default)] pub levels: Vec, } @@ -91,6 +93,7 @@ mod tests { point_incentive_total_points: 1.5, point_incentive_claimable_points: 0, publish_ready: true, + generation_status: Some("ready".to_string()), levels: Vec::new(), }) .expect("payload should serialize"); @@ -99,6 +102,7 @@ mod tests { assert_eq!(payload["pointIncentiveClaimedPoints"], 1); assert_eq!(payload["pointIncentiveTotalPoints"], 1.5); assert_eq!(payload["pointIncentiveClaimablePoints"], 0); + assert_eq!(payload["generationStatus"], "ready"); } } diff --git a/server-rs/crates/shared-logging/Cargo.toml b/server-rs/crates/shared-logging/Cargo.toml index 75235916..e91655e9 100644 --- a/server-rs/crates/shared-logging/Cargo.toml +++ b/server-rs/crates/shared-logging/Cargo.toml @@ -5,4 +5,10 @@ version.workspace = true license.workspace = true [dependencies] -tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } +opentelemetry = { workspace = true } +opentelemetry-otlp = { workspace = true } +opentelemetry-appender-tracing = { workspace = true } +opentelemetry_sdk = { workspace = true } +tracing = { workspace = true } +tracing-opentelemetry = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter", "fmt", "registry"] } diff --git a/server-rs/crates/shared-logging/src/lib.rs b/server-rs/crates/shared-logging/src/lib.rs index a810340e..ad77a6fb 100644 --- a/server-rs/crates/shared-logging/src/lib.rs +++ b/server-rs/crates/shared-logging/src/lib.rs @@ -1,6 +1,23 @@ use std::io; -use tracing_subscriber::{EnvFilter, fmt}; +use opentelemetry::{KeyValue, global, trace::TracerProvider}; +use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge; +use opentelemetry_otlp::WithExportConfig; +use opentelemetry_sdk::{ + Resource, + logs::SdkLoggerProvider, + metrics::SdkMeterProvider, + trace::SdkTracerProvider, +}; +use tracing::warn; +use tracing_subscriber::{ + EnvFilter, Layer, filter::LevelFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt, +}; + +#[derive(Clone, Copy, Debug, Default)] +pub struct OtelConfig { + pub enabled: bool, +} // 统一解析工作区日志过滤器,优先环境变量,其次回落到调用方传入的默认值。 pub fn resolve_env_filter(default_filter: &str) -> EnvFilter { @@ -10,14 +27,196 @@ pub fn resolve_env_filter(default_filter: &str) -> EnvFilter { } // 统一初始化 tracing subscriber,避免各入口重复散落相同配置。 -pub fn init_tracing(default_filter: &str) -> Result<(), io::Error> { +pub fn init_tracing(default_filter: &str, otel_config: OtelConfig) -> Result<(), io::Error> { let env_filter = resolve_env_filter(default_filter); + let fmt_layer = fmt::layer().with_target(true).with_ansi(false).compact(); - fmt() - .with_env_filter(env_filter) - .with_target(true) - .with_ansi(false) - .compact() + if !otel_config.enabled { + return tracing_subscriber::registry() + .with(env_filter) + .with(fmt_layer) + .try_init() + .map_err(|error| io::Error::other(format!("初始化 tracing subscriber 失败:{error}"))); + } + + let Some(otel) = build_otel_pipeline() else { + return tracing_subscriber::registry() + .with(env_filter) + .with(fmt_layer) + .try_init() + .map_err(|error| io::Error::other(format!("初始化 tracing subscriber 失败:{error}"))); + }; + + tracing_subscriber::registry() + .with(env_filter) + .with(fmt_layer) + .with( + tracing_opentelemetry::layer() + .with_tracer(otel.tracer_provider.tracer("genarrative-api")), + ) + .with( + OpenTelemetryTracingBridge::new(&otel.logger_provider).with_filter(LevelFilter::INFO), + ) .try_init() .map_err(|error| io::Error::other(format!("初始化 tracing subscriber 失败:{error}"))) } + +struct OtelPipeline { + tracer_provider: SdkTracerProvider, + _meter_provider: SdkMeterProvider, + logger_provider: SdkLoggerProvider, +} + +fn build_otel_pipeline() -> Option { + let resource = Resource::builder() + .with_service_name(read_env_or_default("OTEL_SERVICE_NAME", "genarrative-api")) + .with_attribute(KeyValue::new("service.namespace", "genarrative")) + .build(); + + let span_exporter = match opentelemetry_otlp::SpanExporter::builder() + .with_http() + .with_endpoint(resolve_otlp_http_signal_endpoint( + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "/v1/traces", + )) + .build() + { + Ok(exporter) => exporter, + Err(error) => { + warn!(%error, "OpenTelemetry span exporter 初始化失败,已回退为本地日志"); + return None; + } + }; + + let metric_exporter = match opentelemetry_otlp::MetricExporter::builder() + .with_http() + .with_endpoint(resolve_otlp_http_signal_endpoint( + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "/v1/metrics", + )) + .build() + { + Ok(exporter) => exporter, + Err(error) => { + warn!(%error, "OpenTelemetry metric exporter 初始化失败,已回退为本地日志"); + return None; + } + }; + + let log_exporter = match opentelemetry_otlp::LogExporter::builder() + .with_http() + .with_endpoint(resolve_otlp_http_signal_endpoint( + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + "/v1/logs", + )) + .build() + { + Ok(exporter) => exporter, + Err(error) => { + warn!(%error, "OpenTelemetry log exporter 初始化失败,已回退为本地日志"); + return None; + } + }; + + let tracer_provider = SdkTracerProvider::builder() + .with_resource(resource.clone()) + .with_batch_exporter(span_exporter) + .build(); + let meter_provider = SdkMeterProvider::builder() + .with_resource(resource) + .with_periodic_exporter(metric_exporter) + .build(); + let logger_provider = SdkLoggerProvider::builder() + .with_resource(Resource::builder() + .with_service_name(read_env_or_default("OTEL_SERVICE_NAME", "genarrative-api")) + .with_attribute(KeyValue::new("service.namespace", "genarrative")) + .build()) + .with_batch_exporter(log_exporter) + .build(); + + global::set_tracer_provider(tracer_provider.clone()); + global::set_meter_provider(meter_provider.clone()); + + Some(OtelPipeline { + tracer_provider, + _meter_provider: meter_provider, + logger_provider, + }) +} + +fn read_env_or_default(key: &str, default_value: &str) -> String { + std::env::var(key) + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| default_value.to_string()) +} + +fn resolve_otlp_http_signal_endpoint(signal_key: &str, signal_path: &str) -> String { + if let Ok(value) = std::env::var(signal_key) + && !value.trim().is_empty() + { + return value; + } + + append_otlp_signal_path( + &read_env_or_default("OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:4318"), + signal_path, + ) +} + +fn append_otlp_signal_path(base_endpoint: &str, signal_path: &str) -> String { + let base_endpoint = base_endpoint.trim_end_matches('/'); + let signal_path = signal_path.trim_start_matches('/'); + format!("{base_endpoint}/{signal_path}") +} + +#[cfg(test)] +mod tests { + use std::sync::{Mutex, OnceLock}; + + use super::resolve_otlp_http_signal_endpoint; + + const OTEL_ENDPOINT_ENV_KEYS: [&str; 4] = [ + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + ]; + + fn env_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())).lock().unwrap() + } + + fn clear_otel_endpoint_env() { + unsafe { + for key in OTEL_ENDPOINT_ENV_KEYS { + std::env::remove_var(key); + } + } + } + + #[test] + fn generic_otlp_http_endpoint_expands_to_signal_paths() { + let _guard = env_lock(); + clear_otel_endpoint_env(); + unsafe { + std::env::set_var("OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:4318"); + } + + assert_eq!( + resolve_otlp_http_signal_endpoint("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "/v1/traces"), + "http://127.0.0.1:4318/v1/traces" + ); + assert_eq!( + resolve_otlp_http_signal_endpoint("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", "/v1/metrics"), + "http://127.0.0.1:4318/v1/metrics" + ); + assert_eq!( + resolve_otlp_http_signal_endpoint("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", "/v1/logs"), + "http://127.0.0.1:4318/v1/logs" + ); + + clear_otel_endpoint_env(); + } +} diff --git a/server-rs/crates/spacetime-client/Cargo.toml b/server-rs/crates/spacetime-client/Cargo.toml index 4499f545..734c0df9 100644 --- a/server-rs/crates/spacetime-client/Cargo.toml +++ b/server-rs/crates/spacetime-client/Cargo.toml @@ -27,3 +27,5 @@ shared-kernel = { workspace = true } spacetimedb-sdk = { workspace = true } time = { workspace = true } tokio = { workspace = true, features = ["rt", "sync", "time"] } +opentelemetry = { workspace = true } +tracing = { workspace = true } diff --git a/server-rs/crates/spacetime-client/src/ai.rs b/server-rs/crates/spacetime-client/src/ai.rs index 84a8041c..1ecbfd9d 100644 --- a/server-rs/crates/spacetime-client/src/ai.rs +++ b/server-rs/crates/spacetime-client/src/ai.rs @@ -8,7 +8,7 @@ impl SpacetimeClient { ) -> Result { let procedure_input = input.into(); - self.call_after_connect(move |connection, sender| { + self.call_after_connect("create_ai_task_and_return", move |connection, sender| { connection.procedures().create_ai_task_and_return_then( procedure_input, move |_, result| { @@ -28,7 +28,7 @@ impl SpacetimeClient { ) -> Result<(), SpacetimeClientError> { let reducer_input = input.into(); - self.call_reducer_after_connect(move |connection, sender| { + self.call_reducer_after_connect("start_ai_task", move |connection, sender| { let callback_sender = sender.clone(); if let Err(error) = connection @@ -52,7 +52,7 @@ impl SpacetimeClient { ) -> Result<(), SpacetimeClientError> { let reducer_input = input.into(); - self.call_reducer_after_connect(move |connection, sender| { + self.call_reducer_after_connect("start_ai_task_stage", move |connection, sender| { let callback_sender = sender.clone(); if let Err(error) = connection @@ -76,16 +76,19 @@ impl SpacetimeClient { ) -> Result { let procedure_input = input.into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .append_ai_text_chunk_and_return_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_ai_task_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "append_ai_text_chunk_and_return", + move |connection, sender| { + connection + .procedures() + .append_ai_text_chunk_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_ai_task_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -95,7 +98,7 @@ impl SpacetimeClient { ) -> Result { let procedure_input = input.into(); - self.call_after_connect(move |connection, sender| { + self.call_after_connect("complete_ai_stage_and_return", move |connection, sender| { connection.procedures().complete_ai_stage_and_return_then( procedure_input, move |_, result| { @@ -115,16 +118,22 @@ impl SpacetimeClient { ) -> Result { let procedure_input = input.into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .attach_ai_result_reference_and_return_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_ai_task_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "attach_ai_result_reference_and_return", + move |connection, sender| { + connection + .procedures() + .attach_ai_result_reference_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_ai_task_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -134,7 +143,7 @@ impl SpacetimeClient { ) -> Result { let procedure_input = input.into(); - self.call_after_connect(move |connection, sender| { + self.call_after_connect("complete_ai_task_and_return", move |connection, sender| { connection.procedures().complete_ai_task_and_return_then( procedure_input, move |_, result| { @@ -154,7 +163,7 @@ impl SpacetimeClient { ) -> Result { let procedure_input = input.into(); - self.call_after_connect(move |connection, sender| { + self.call_after_connect("fail_ai_task_and_return", move |connection, sender| { connection.procedures().fail_ai_task_and_return_then( procedure_input, move |_, result| { @@ -174,7 +183,7 @@ impl SpacetimeClient { ) -> Result { let procedure_input = input.into(); - self.call_after_connect(move |connection, sender| { + self.call_after_connect("cancel_ai_task_and_return", move |connection, sender| { connection.procedures().cancel_ai_task_and_return_then( procedure_input, move |_, result| { diff --git a/server-rs/crates/spacetime-client/src/assets.rs b/server-rs/crates/spacetime-client/src/assets.rs index ef0a910e..4cb2c2b7 100644 --- a/server-rs/crates/spacetime-client/src/assets.rs +++ b/server-rs/crates/spacetime-client/src/assets.rs @@ -7,17 +7,20 @@ impl SpacetimeClient { ) -> Result, SpacetimeClientError> { let procedure_input = input.into(); - self.call_after_connect(move |connection, sender| { - connection.procedures().list_asset_history_and_return_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_asset_history_list_result); - send_once(&sender, mapped); - }, - ); - }) + self.call_after_connect( + "list_asset_history_and_return", + move |connection, sender| { + connection.procedures().list_asset_history_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_asset_history_list_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -27,16 +30,19 @@ impl SpacetimeClient { ) -> Result { let procedure_input = input.into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .confirm_asset_object_and_return_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "confirm_asset_object_and_return", + move |connection, sender| { + connection + .procedures() + .confirm_asset_object_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -46,16 +52,22 @@ impl SpacetimeClient { ) -> Result { let procedure_input = input.into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .bind_asset_object_to_entity_and_return_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_entity_binding_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "bind_asset_object_to_entity_and_return", + move |connection, sender| { + connection + .procedures() + .bind_asset_object_to_entity_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_entity_binding_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } } diff --git a/server-rs/crates/spacetime-client/src/auth.rs b/server-rs/crates/spacetime-client/src/auth.rs index 438a2d69..e0f8faa1 100644 --- a/server-rs/crates/spacetime-client/src/auth.rs +++ b/server-rs/crates/spacetime-client/src/auth.rs @@ -4,23 +4,26 @@ impl SpacetimeClient { pub async fn export_auth_store_snapshot_from_tables( &self, ) -> Result { - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .export_auth_store_snapshot_from_tables_then(move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_auth_store_snapshot_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "export_auth_store_snapshot_from_tables", + move |connection, sender| { + connection + .procedures() + .export_auth_store_snapshot_from_tables_then(move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_auth_store_snapshot_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } pub async fn get_auth_store_snapshot( &self, ) -> Result { - self.call_after_connect(move |connection, sender| { + self.call_after_connect("get_auth_store_snapshot", move |connection, sender| { connection .procedures() .get_auth_store_snapshot_then(move |_, result| { @@ -43,7 +46,7 @@ impl SpacetimeClient { updated_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("upsert_auth_store_snapshot", move |connection, sender| { connection.procedures().upsert_auth_store_snapshot_then( procedure_input, move |_, result| { @@ -67,23 +70,26 @@ impl SpacetimeClient { updated_at_micros, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .import_auth_store_snapshot_json_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_auth_store_snapshot_import_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "import_auth_store_snapshot_json", + move |connection, sender| { + connection + .procedures() + .import_auth_store_snapshot_json_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_auth_store_snapshot_import_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } pub async fn import_auth_store_snapshot( &self, ) -> Result { - self.call_after_connect(move |connection, sender| { + self.call_after_connect("import_auth_store_snapshot", move |connection, sender| { connection .procedures() .import_auth_store_snapshot_then(move |_, result| { diff --git a/server-rs/crates/spacetime-client/src/bark_battle.rs b/server-rs/crates/spacetime-client/src/bark_battle.rs index 18985b15..19af1cb5 100644 --- a/server-rs/crates/spacetime-client/src/bark_battle.rs +++ b/server-rs/crates/spacetime-client/src/bark_battle.rs @@ -11,7 +11,7 @@ impl SpacetimeClient { &self, input: BarkBattleDraftCreateRecordInput, ) -> Result { - self.call_after_connect(move |connection, sender| { + self.call_after_connect("create_bark_battle_draft", move |connection, sender| { connection .procedures() .create_bark_battle_draft_then(input, move |_, result| { @@ -28,16 +28,19 @@ impl SpacetimeClient { &self, input: BarkBattleDraftConfigUpsertRecordInput, ) -> Result { - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .update_bark_battle_draft_config_then(input, move |_, result| { - let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) - .and_then(map_bark_battle_draft_config_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "update_bark_battle_draft_config", + move |connection, sender| { + connection + .procedures() + .update_bark_battle_draft_config_then(input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_bark_battle_draft_config_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -45,7 +48,7 @@ impl SpacetimeClient { &self, input: BarkBattleWorkPublishRecordInput, ) -> Result { - self.call_after_connect(move |connection, sender| { + self.call_after_connect("publish_bark_battle_work", move |connection, sender| { connection .procedures() .publish_bark_battle_work_then(input, move |_, result| { @@ -67,16 +70,20 @@ impl SpacetimeClient { work_id, owner_user_id, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .get_bark_battle_runtime_config_then(input, move |_, result| { - let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) - .and_then(map_bark_battle_runtime_config_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "get_bark_battle_runtime_config", + move |connection, sender| { + connection.procedures().get_bark_battle_runtime_config_then( + input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_bark_battle_runtime_config_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -84,7 +91,7 @@ impl SpacetimeClient { &self, input: BarkBattleRunStartRecordInput, ) -> Result { - self.call_after_connect(move |connection, sender| { + self.call_after_connect("start_bark_battle_run", move |connection, sender| { connection .procedures() .start_bark_battle_run_then(input, move |_, result| { @@ -101,7 +108,7 @@ impl SpacetimeClient { &self, input: BarkBattleRunFinishRecordInput, ) -> Result { - self.call_after_connect(move |connection, sender| { + self.call_after_connect("finish_bark_battle_run", move |connection, sender| { connection .procedures() .finish_bark_battle_run_then(input, move |_, result| { @@ -123,7 +130,7 @@ impl SpacetimeClient { run_id, owner_user_id, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("get_bark_battle_run", move |connection, sender| { connection .procedures() .get_bark_battle_run_then(input, move |_, result| { diff --git a/server-rs/crates/spacetime-client/src/big_fish.rs b/server-rs/crates/spacetime-client/src/big_fish.rs index 699cb5a6..63325f3c 100644 --- a/server-rs/crates/spacetime-client/src/big_fish.rs +++ b/server-rs/crates/spacetime-client/src/big_fish.rs @@ -7,7 +7,6 @@ use crate::module_bindings::record_big_fish_play_procedure::record_big_fish_play use crate::module_bindings::remix_big_fish_work_procedure::remix_big_fish_work; use crate::module_bindings::start_big_fish_run_procedure::start_big_fish_run; use crate::module_bindings::submit_big_fish_input_procedure::submit_big_fish_input; -use module_big_fish::PUBLIC_BIG_FISH_GALLERY_OWNER_USER_ID; impl SpacetimeClient { pub async fn create_big_fish_session( @@ -23,7 +22,7 @@ impl SpacetimeClient { created_at_micros: input.created_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("create_big_fish_session", move |connection, sender| { connection.procedures().create_big_fish_session_then( procedure_input, move |_, result| { @@ -47,7 +46,7 @@ impl SpacetimeClient { owner_user_id, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("get_big_fish_session", move |connection, sender| { connection .procedures() .get_big_fish_session_then(procedure_input, move |_, result| { @@ -75,10 +74,29 @@ impl SpacetimeClient { pub async fn list_big_fish_gallery( &self, ) -> Result, SpacetimeClientError> { - self.list_big_fish_works_with_input(BigFishWorksListInput { - // 中文注释:公开广场读取只依赖 published_only,但旧部署模块会先校验 owner_user_id 非空。 - owner_user_id: PUBLIC_BIG_FISH_GALLERY_OWNER_USER_ID.to_string(), - published_only: true, + self.read_after_connect("list_big_fish_gallery", move |connection| { + let recent_play_counts = public_work_recent_play_counts(connection, "big-fish"); + let mut items = connection + .db() + .big_fish_gallery_view() + .iter() + .collect::>(); + items.sort_by(|left, right| { + right + .updated_at_micros + .cmp(&left.updated_at_micros) + .then_with(|| left.source_session_id.cmp(&right.source_session_id)) + }); + Ok(items + .into_iter() + .map(|item| { + let recent_play_count_7d = recent_play_counts + .get(&item.source_session_id) + .copied() + .unwrap_or(0); + map_big_fish_gallery_view_row(item, recent_play_count_7d) + }) + .collect()) }) .await } @@ -87,7 +105,7 @@ impl SpacetimeClient { &self, procedure_input: BigFishWorksListInput, ) -> Result, SpacetimeClientError> { - self.call_after_connect(move |connection, sender| { + self.call_after_connect("list_big_fish_works", move |connection, sender| { let fallback_owner_user_id = if procedure_input.published_only { None } else { @@ -120,7 +138,7 @@ impl SpacetimeClient { owner_user_id, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("delete_big_fish_work", move |connection, sender| { let fallback_owner_user_id = Some(procedure_input.owner_user_id.clone()); connection .procedures() @@ -152,7 +170,7 @@ impl SpacetimeClient { submitted_at_micros: input.submitted_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("submit_big_fish_message", move |connection, sender| { connection.procedures().submit_big_fish_message_then( procedure_input, move |_, result| { @@ -182,16 +200,22 @@ impl SpacetimeClient { updated_at_micros: input.updated_at_micros, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .finalize_big_fish_agent_message_turn_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_big_fish_session_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "finalize_big_fish_agent_message_turn", + move |connection, sender| { + connection + .procedures() + .finalize_big_fish_agent_message_turn_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_big_fish_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -206,7 +230,7 @@ impl SpacetimeClient { compiled_at_micros: input.compiled_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("compile_big_fish_draft", move |connection, sender| { connection.procedures().compile_big_fish_draft_then( procedure_input, move |_, result| { @@ -234,7 +258,7 @@ impl SpacetimeClient { generated_at_micros: input.generated_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("generate_big_fish_asset", move |connection, sender| { connection.procedures().generate_big_fish_asset_then( procedure_input, move |_, result| { @@ -260,7 +284,7 @@ impl SpacetimeClient { published_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("publish_big_fish_game", move |connection, sender| { connection.procedures().publish_big_fish_game_then( procedure_input, move |_, result| { @@ -285,7 +309,7 @@ impl SpacetimeClient { played_at_micros: input.reported_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("record_big_fish_play", move |connection, sender| { connection .procedures() .record_big_fish_play_then(procedure_input, move |_, result| { @@ -309,7 +333,7 @@ impl SpacetimeClient { started_at_micros: input.started_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("start_big_fish_run", move |connection, sender| { connection .procedures() .start_big_fish_run_then(procedure_input, move |_, result| { @@ -332,7 +356,7 @@ impl SpacetimeClient { liked_at_micros: input.liked_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("record_big_fish_like", move |connection, sender| { connection .procedures() .record_big_fish_like_then(procedure_input, move |_, result| { @@ -355,7 +379,7 @@ impl SpacetimeClient { owner_user_id, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("get_big_fish_run", move |connection, sender| { connection .procedures() .get_big_fish_run_then(procedure_input, move |_, result| { @@ -380,7 +404,7 @@ impl SpacetimeClient { remixed_at_micros: input.remixed_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("remix_big_fish_work", move |connection, sender| { connection .procedures() .remix_big_fish_work_then(procedure_input, move |_, result| { @@ -405,7 +429,7 @@ impl SpacetimeClient { submitted_at_micros: input.submitted_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("submit_big_fish_input", move |connection, sender| { connection.procedures().submit_big_fish_input_then( procedure_input, move |_, result| { diff --git a/server-rs/crates/spacetime-client/src/combat.rs b/server-rs/crates/spacetime-client/src/combat.rs index 80ae53ae..e8b4814e 100644 --- a/server-rs/crates/spacetime-client/src/combat.rs +++ b/server-rs/crates/spacetime-client/src/combat.rs @@ -9,17 +9,20 @@ impl SpacetimeClient { validate_battle_state_input(&input).map_err(SpacetimeClientError::validation_failed)?; let procedure_input = input.into(); - self.call_after_connect(move |connection, sender| { - connection.procedures().create_battle_state_and_return_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_battle_state_procedure_result); - send_once(&sender, mapped); - }, - ); - }) + self.call_after_connect( + "create_battle_state_and_return", + move |connection, sender| { + connection.procedures().create_battle_state_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_battle_state_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -31,7 +34,7 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { + self.call_after_connect("get_battle_state", move |connection, sender| { connection .procedures() .get_battle_state_then(procedure_input, move |_, result| { @@ -52,16 +55,19 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)?; let procedure_input = input.into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .resolve_combat_action_and_return_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_resolve_combat_action_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "resolve_combat_action_and_return", + move |connection, sender| { + connection + .procedures() + .resolve_combat_action_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_resolve_combat_action_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } } diff --git a/server-rs/crates/spacetime-client/src/custom_world.rs b/server-rs/crates/spacetime-client/src/custom_world.rs index 3cbeb56f..9d5f5285 100644 --- a/server-rs/crates/spacetime-client/src/custom_world.rs +++ b/server-rs/crates/spacetime-client/src/custom_world.rs @@ -12,7 +12,7 @@ impl SpacetimeClient { ) -> Result, SpacetimeClientError> { let procedure_input = CustomWorldProfileListInput { owner_user_id }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("list_custom_world_profiles", move |connection, sender| { connection.procedures().list_custom_world_profiles_then( procedure_input, move |_, result| { @@ -36,16 +36,19 @@ impl SpacetimeClient { profile_id, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .get_custom_world_library_detail_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_custom_world_library_detail_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "get_custom_world_library_detail", + move |connection, sender| { + connection + .procedures() + .get_custom_world_library_detail_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_custom_world_library_detail_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -55,16 +58,22 @@ impl SpacetimeClient { ) -> Result { let procedure_input = input.into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .upsert_custom_world_profile_and_return_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_custom_world_library_mutation_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "upsert_custom_world_profile_and_return", + move |connection, sender| { + connection + .procedures() + .upsert_custom_world_profile_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_custom_world_library_mutation_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -86,16 +95,22 @@ impl SpacetimeClient { published_at_micros, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .publish_custom_world_profile_and_return_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_custom_world_library_mutation_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "publish_custom_world_profile_and_return", + move |connection, sender| { + connection + .procedures() + .publish_custom_world_profile_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_custom_world_library_mutation_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -113,19 +128,22 @@ impl SpacetimeClient { updated_at_micros, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .unpublish_custom_world_profile_and_return_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_custom_world_library_mutation_result); - send_once(&sender, mapped); - }, - ); - }) + self.call_after_connect( + "unpublish_custom_world_profile_and_return", + move |connection, sender| { + connection + .procedures() + .unpublish_custom_world_profile_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_custom_world_library_mutation_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -141,35 +159,93 @@ impl SpacetimeClient { deleted_at_micros, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .delete_custom_world_profile_and_return_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_custom_world_profile_list_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "delete_custom_world_profile_and_return", + move |connection, sender| { + connection + .procedures() + .delete_custom_world_profile_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_custom_world_profile_list_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } pub async fn list_custom_world_gallery_entries( &self, ) -> Result, SpacetimeClientError> { - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .list_custom_world_gallery_entries_then(move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_custom_world_gallery_list_result); - send_once(&sender, mapped); - }); + let records = self.read_custom_world_gallery_entries_from_cache().await?; + if !records.is_empty() + || self + .custom_world_gallery_legacy_sync_attempted + .swap(true, std::sync::atomic::Ordering::SeqCst) + { + return Ok(records); + } + + let _ = self + .sync_custom_world_gallery_entries_via_legacy_procedure() + .await; + self.read_custom_world_gallery_entries_from_cache().await + } + + async fn read_custom_world_gallery_entries_from_cache( + &self, + ) -> Result, SpacetimeClientError> { + self.read_after_connect("list_custom_world_gallery", move |connection| { + let recent_play_counts = public_work_recent_play_counts(connection, "custom-world"); + let mut entries = connection + .db() + .custom_world_gallery_entry() + .iter() + .collect::>(); + entries.sort_by(|left, right| { + right + .published_at + .cmp(&left.published_at) + .then(right.updated_at.cmp(&left.updated_at)) + }); + + Ok(entries + .into_iter() + .map(|entry| { + let recent_play_count_7d = recent_play_counts + .get(&entry.profile_id) + .copied() + .unwrap_or(0); + map_custom_world_gallery_entry_row(entry, recent_play_count_7d) + }) + .collect()) }) .await } + async fn sync_custom_world_gallery_entries_via_legacy_procedure( + &self, + ) -> Result<(), SpacetimeClientError> { + self.call_after_connect( + "list_custom_world_gallery_entries", + move |connection, sender| { + connection + .procedures() + .list_custom_world_gallery_entries_then(move |_, result| { + let mapped = result + .map(|_| ()) + .map_err(SpacetimeClientError::from_sdk_error); + send_once(&sender, mapped); + }); + }, + ) + .await + } + pub async fn get_custom_world_gallery_detail( &self, owner_user_id: String, @@ -180,16 +256,19 @@ impl SpacetimeClient { profile_id, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .get_custom_world_gallery_detail_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_custom_world_library_mutation_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "get_custom_world_gallery_detail", + move |connection, sender| { + connection + .procedures() + .get_custom_world_gallery_detail_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_custom_world_library_mutation_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -199,16 +278,22 @@ impl SpacetimeClient { ) -> Result { let procedure_input = CustomWorldGalleryDetailByCodeInput { public_work_code }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .get_custom_world_gallery_detail_by_code_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_custom_world_library_mutation_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "get_custom_world_gallery_detail_by_code", + move |connection, sender| { + connection + .procedures() + .get_custom_world_gallery_detail_by_code_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_custom_world_library_mutation_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -225,7 +310,7 @@ impl SpacetimeClient { remixed_at_micros: input.remixed_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("remix_custom_world_profile", move |connection, sender| { connection.procedures().remix_custom_world_profile_then( procedure_input, move |_, result| { @@ -249,16 +334,19 @@ impl SpacetimeClient { played_at_micros: input.played_at_micros, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .record_custom_world_profile_play_then(procedure_input, move |_, result| { - let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) - .and_then(map_custom_world_library_mutation_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "record_custom_world_profile_play", + move |connection, sender| { + connection + .procedures() + .record_custom_world_profile_play_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_library_mutation_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -273,16 +361,19 @@ impl SpacetimeClient { liked_at_micros: input.liked_at_micros, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .record_custom_world_profile_like_then(procedure_input, move |_, result| { - let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) - .and_then(map_custom_world_library_mutation_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "record_custom_world_profile_like", + move |connection, sender| { + connection + .procedures() + .record_custom_world_profile_like_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_library_mutation_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -292,7 +383,7 @@ impl SpacetimeClient { ) -> Result { let procedure_input = input.into(); - self.call_after_connect(move |connection, sender| { + self.call_after_connect("publish_custom_world_world", move |connection, sender| { connection.procedures().publish_custom_world_world_then( procedure_input, move |_, result| { @@ -331,16 +422,19 @@ impl SpacetimeClient { created_at_micros: input.created_at_micros, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .create_custom_world_agent_session_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_custom_world_agent_session_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "create_custom_world_agent_session", + move |connection, sender| { + connection + .procedures() + .create_custom_world_agent_session_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_custom_world_agent_session_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -354,17 +448,20 @@ impl SpacetimeClient { owner_user_id, }; - self.call_after_connect(move |connection, sender| { - connection.procedures().get_custom_world_agent_session_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_custom_world_agent_session_procedure_result); - send_once(&sender, mapped); - }, - ); - }) + self.call_after_connect( + "get_custom_world_agent_session", + move |connection, sender| { + connection.procedures().get_custom_world_agent_session_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_custom_world_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -374,7 +471,7 @@ impl SpacetimeClient { ) -> Result, SpacetimeClientError> { let procedure_input = CustomWorldWorksListInput { owner_user_id }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("list_custom_world_works", move |connection, sender| { connection.procedures().list_custom_world_works_then( procedure_input, move |_, result| { @@ -398,16 +495,19 @@ impl SpacetimeClient { owner_user_id, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .delete_custom_world_agent_session_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_custom_world_works_list_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "delete_custom_world_agent_session", + move |connection, sender| { + connection + .procedures() + .delete_custom_world_agent_session_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_custom_world_works_list_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -423,16 +523,19 @@ impl SpacetimeClient { card_id, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .get_custom_world_agent_card_detail_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_custom_world_draft_card_detail_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "get_custom_world_agent_card_detail", + move |connection, sender| { + connection + .procedures() + .get_custom_world_agent_card_detail_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_custom_world_draft_card_detail_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -449,16 +552,19 @@ impl SpacetimeClient { submitted_at_micros: input.submitted_at_micros, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .execute_custom_world_agent_action_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_custom_world_agent_action_execute_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "execute_custom_world_agent_action", + move |connection, sender| { + connection + .procedures() + .execute_custom_world_agent_action_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_custom_world_agent_action_execute_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -475,16 +581,19 @@ impl SpacetimeClient { submitted_at_micros: input.submitted_at_micros, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .submit_custom_world_agent_message_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_custom_world_agent_operation_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "submit_custom_world_agent_message", + move |connection, sender| { + connection + .procedures() + .submit_custom_world_agent_message_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_custom_world_agent_operation_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -521,19 +630,22 @@ impl SpacetimeClient { updated_at_micros: input.updated_at_micros, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .finalize_custom_world_agent_message_turn_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_custom_world_agent_operation_procedure_result); - send_once(&sender, mapped); - }, - ); - }) + self.call_after_connect( + "finalize_custom_world_agent_message_turn", + move |connection, sender| { + connection + .procedures() + .finalize_custom_world_agent_message_turn_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_custom_world_agent_operation_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -556,19 +668,22 @@ impl SpacetimeClient { updated_at_micros: input.updated_at_micros, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .upsert_custom_world_agent_operation_progress_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_custom_world_agent_operation_procedure_result); - send_once(&sender, mapped); - }, - ); - }) + self.call_after_connect( + "upsert_custom_world_agent_operation_progress", + move |connection, sender| { + connection + .procedures() + .upsert_custom_world_agent_operation_progress_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_custom_world_agent_operation_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -584,16 +699,19 @@ impl SpacetimeClient { operation_id, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .get_custom_world_agent_operation_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_custom_world_agent_operation_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "get_custom_world_agent_operation", + move |connection, sender| { + connection + .procedures() + .get_custom_world_agent_operation_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_custom_world_agent_operation_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } } diff --git a/server-rs/crates/spacetime-client/src/inventory.rs b/server-rs/crates/spacetime-client/src/inventory.rs index 470c8c45..490a9a4b 100644 --- a/server-rs/crates/spacetime-client/src/inventory.rs +++ b/server-rs/crates/spacetime-client/src/inventory.rs @@ -11,7 +11,7 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { + self.call_after_connect("get_runtime_inventory_state", move |connection, sender| { connection.procedures().get_runtime_inventory_state_then( procedure_input, move |_, result| { diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 95a674f4..b3b33e7d 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -3,6 +3,7 @@ pub mod module_bindings; mod mapper; +mod telemetry; use mapper::*; pub use mapper::{ AiResultReferenceRecord, AiTaskMutationRecord, AiTaskRecord, AiTaskStageRecord, @@ -43,7 +44,7 @@ pub use mapper::{ PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, PuzzleFormDraftRecord, - PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord, + PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, @@ -95,6 +96,7 @@ pub mod story_runtime; pub mod visual_novel; use std::{ + collections::HashMap, error::Error, fmt, sync::atomic::{AtomicBool, Ordering}, @@ -222,9 +224,9 @@ use module_story::{ build_story_continue_input, build_story_session_input, build_story_session_state_input, }; use shared_kernel::format_timestamp_micros; -use spacetimedb_sdk::DbContext; +use spacetimedb_sdk::{DbContext, Table}; use tokio::{ - sync::{OwnedSemaphorePermit, Semaphore, oneshot}, + sync::{OwnedSemaphorePermit, RwLock, Semaphore, oneshot}, time::timeout, }; @@ -256,6 +258,8 @@ pub struct AuthStoreSnapshotImportRecord { pub struct SpacetimeClient { config: SpacetimeClientConfig, pool: Arc, + creation_entry_config_cache: Arc>>, + custom_world_gallery_legacy_sync_attempted: Arc, } #[derive(Debug)] @@ -268,6 +272,8 @@ pub enum SpacetimeClientError { } const DEFAULT_PROCEDURE_TIMEOUT: Duration = Duration::from_secs(30); +const PUBLIC_WORK_PLAY_DAY_MICROS: i64 = 86_400_000_000; +const PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS: i64 = 7; type ProcedureResultSender = Arc>>>>; @@ -285,6 +291,7 @@ struct PooledConnectionSlot { struct PooledConnection { connection: DbConnection, + _read_model_subscriptions: Vec, runner: Option>, broken: Arc, } @@ -319,54 +326,104 @@ impl SpacetimeClient { permits: Arc::new(Semaphore::new(pool_size)), }); - Self { config, pool } + Self { + config, + pool, + creation_entry_config_cache: Arc::new(RwLock::new(None)), + custom_world_gallery_legacy_sync_attempted: Arc::new(AtomicBool::new(false)), + } } async fn call_after_connect( &self, + procedure: &'static str, call: impl FnOnce(&DbConnection, ProcedureResultSender) + Send + 'static, ) -> Result where T: Send + 'static, { + let metrics_guard = telemetry::begin_procedure(procedure); let (sender, receiver) = oneshot::channel(); let result_sender = Arc::new(Mutex::new(Some(sender))); - let lease = self.acquire_connection().await?; - let final_result = if let Some(connection) = lease.connection.as_ref() { - call(&connection.connection, result_sender.clone()); - match timeout(self.config.procedure_timeout, receiver).await { - Ok(inner) => match inner { - Ok(value) => value, - Err(_) => Err(SpacetimeClientError::ConnectDropped), - }, - Err(_) => Err(Self::resolve_timeout_error(Some(connection))), + let final_result = match self.acquire_connection().await { + Ok(lease) => { + let result = if let Some(connection) = lease.connection.as_ref() { + call(&connection.connection, result_sender.clone()); + match timeout(self.config.procedure_timeout, receiver).await { + Ok(inner) => match inner { + Ok(value) => value, + Err(_) => Err(SpacetimeClientError::ConnectDropped), + }, + Err(_) => Err(Self::resolve_timeout_error(Some(connection))), + } + } else { + Err(SpacetimeClientError::Runtime( + "SpacetimeDB 连接租约缺少连接".to_string(), + )) + }; + self.release_connection(lease).await; + result } - } else { - Err(SpacetimeClientError::Runtime( - "SpacetimeDB 连接租约缺少连接".to_string(), - )) + Err(error) => Err(error), }; - self.release_connection(lease).await; + metrics_guard.finish(&final_result); final_result } async fn call_reducer_after_connect( &self, + procedure: &'static str, call: impl FnOnce(&DbConnection, ReducerResultSender) + Send + 'static, ) -> Result<(), SpacetimeClientError> { + let metrics_guard = telemetry::begin_procedure(procedure); let (sender, receiver) = oneshot::channel(); let result_sender = Arc::new(Mutex::new(Some(sender))); - let lease = self.acquire_connection().await?; - let final_result = if let Some(connection) = lease.connection.as_ref() { - call(&connection.connection, result_sender.clone()); - match timeout(self.config.procedure_timeout, receiver).await { - Ok(inner) => match inner { - Ok(value) => value, - Err(_) => Err(SpacetimeClientError::ConnectDropped), - }, - Err(_) => Err(Self::resolve_timeout_error(Some(connection))), + let final_result = match self.acquire_connection().await { + Ok(lease) => { + let result = if let Some(connection) = lease.connection.as_ref() { + call(&connection.connection, result_sender.clone()); + match timeout(self.config.procedure_timeout, receiver).await { + Ok(inner) => match inner { + Ok(value) => value, + Err(_) => Err(SpacetimeClientError::ConnectDropped), + }, + Err(_) => Err(Self::resolve_timeout_error(Some(connection))), + } + } else { + Err(SpacetimeClientError::Runtime( + "SpacetimeDB 连接租约缺少连接".to_string(), + )) + }; + self.release_connection(lease).await; + result } + Err(error) => Err(error), + }; + + metrics_guard.finish(&final_result); + final_result + } + + async fn read_after_connect( + &self, + read_name: &'static str, + read: impl FnOnce(&DbConnection) -> Result + Send + 'static, + ) -> Result + where + T: Send + 'static, + { + let metrics_guard = telemetry::begin_read(read_name); + let lease = match self.acquire_connection().await { + Ok(lease) => lease, + Err(error) => { + let final_result = Err(error); + metrics_guard.finish(&final_result); + return final_result; + } + }; + let final_result = if let Some(connection) = lease.connection.as_ref() { + read(&connection.connection) } else { Err(SpacetimeClientError::Runtime( "SpacetimeDB 连接租约缺少连接".to_string(), @@ -374,9 +431,18 @@ impl SpacetimeClient { }; self.release_connection(lease).await; + metrics_guard.finish(&final_result); final_result } + async fn cache_creation_entry_config(&self, config: CreationEntryConfigRecord) { + *self.creation_entry_config_cache.write().await = Some(config); + } + + async fn read_cached_creation_entry_config(&self) -> Option { + self.creation_entry_config_cache.read().await.clone() + } + async fn acquire_connection(&self) -> Result { let permit = timeout( self.config.procedure_timeout, @@ -465,13 +531,95 @@ impl SpacetimeClient { .map_err(|_| SpacetimeClientError::Timeout)? .map_err(|_| SpacetimeClientError::ConnectDropped)??; + let read_model_subscriptions = self + .subscribe_cached_read_models(&connection, broken.clone()) + .await?; + Ok(PooledConnection { connection, + _read_model_subscriptions: read_model_subscriptions, runner: Some(runner), broken, }) } + async fn subscribe_cached_read_models( + &self, + connection: &DbConnection, + broken: Arc, + ) -> Result, SpacetimeClientError> { + let mut subscriptions = Vec::new(); + for query in [ + "SELECT * FROM puzzle_gallery_card_view", + "SELECT * FROM custom_world_gallery_entry", + "SELECT * FROM match_3_d_gallery_view", + "SELECT * FROM square_hole_gallery_view", + "SELECT * FROM visual_novel_gallery_view", + "SELECT * FROM big_fish_gallery_view", + ] { + let subscription = self + .subscribe_cached_read_model_query(connection, broken.clone(), query, true) + .await?; + subscriptions.push(subscription); + } + + for query in [ + "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle'", + "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'custom-world'", + "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'match3d'", + "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'square-hole'", + "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'visual-novel'", + "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'big-fish'", + "SELECT * FROM creation_entry_config", + "SELECT * FROM creation_entry_type_config", + ] { + if let Ok(subscription) = self + .subscribe_cached_read_model_query(connection, broken.clone(), query, false) + .await + { + subscriptions.push(subscription); + } + } + + Ok(subscriptions) + } + + async fn subscribe_cached_read_model_query( + &self, + connection: &DbConnection, + broken: Arc, + query: &'static str, + mark_broken_on_error: bool, + ) -> Result { + let (sender, receiver) = oneshot::channel::>(); + let applied_sender = Arc::new(Mutex::new(Some(sender))); + let on_applied_sender = applied_sender.clone(); + let on_error_sender = applied_sender.clone(); + let broken_flag = broken.clone(); + let subscription = connection + .subscription_builder() + .on_applied(move |_| { + send_connect_once(&on_applied_sender, Ok(())); + }) + .on_error(move |_, error| { + if mark_broken_on_error { + broken_flag.store(true, Ordering::SeqCst); + } + send_connect_once( + &on_error_sender, + Err(SpacetimeClientError::Procedure(error.to_string())), + ); + }) + .subscribe(query); + + timeout(self.config.procedure_timeout, receiver) + .await + .map_err(|_| SpacetimeClientError::Timeout)? + .map_err(|_| SpacetimeClientError::ConnectDropped)??; + + Ok(subscription) + } + async fn release_connection(&self, mut lease: PooledConnectionLease) { let mut slot_guard = self.pool.slots[lease.slot_index].lock().await; slot_guard.in_use = false; @@ -499,6 +647,39 @@ impl SpacetimeClient { } } +fn current_unix_micros() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_micros() as i64) + .unwrap_or(0) +} + +fn current_public_work_day() -> i64 { + current_unix_micros().div_euclid(PUBLIC_WORK_PLAY_DAY_MICROS) +} + +fn public_work_recent_play_counts( + connection: &DbConnection, + source_type: &str, +) -> HashMap { + let current_day = current_public_work_day(); + let first_day = current_day - (PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS - 1); + let mut counts = HashMap::new(); + + for row in connection.db().public_work_play_daily_stat().iter() { + if row.source_type != source_type + || row.played_day < first_day + || row.played_day > current_day + { + continue; + } + let entry: &mut u32 = counts.entry(row.profile_id).or_insert(0); + *entry = (*entry).saturating_add(row.play_count); + } + + counts +} + impl SpacetimeClientError { pub(crate) fn from_sdk_error(error: impl fmt::Display) -> Self { Self::Procedure(error.to_string()) diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 225b632c..7f6a1904 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -1,8650 +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 { - parse_bark_battle_row_json(result, "Bark Battle draft config") -} - -pub(crate) fn map_bark_battle_runtime_config_procedure_result( - result: BarkBattleProcedureResult, -) -> Result { - parse_bark_battle_row_json(result, "Bark Battle runtime config") -} - -pub(crate) fn map_bark_battle_run_procedure_result( - result: BarkBattleProcedureResult, -) -> Result { - parse_bark_battle_row_json(result, "Bark Battle run") -} - -fn parse_bark_battle_row_json( - result: BarkBattleProcedureResult, - label: &'static str, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - let row_json = result - .row_json - .ok_or_else(|| SpacetimeClientError::missing_snapshot(label))?; - serde_json::from_str(&row_json) - .map_err(|error| SpacetimeClientError::Runtime(format!("{label} JSON 解析失败: {error}"))) -} - -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), - )) -} - -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_json = result - .session_json - .ok_or_else(|| SpacetimeClientError::missing_snapshot("puzzle agent session 快照"))?; - let session: DomainPuzzleAgentSessionSnapshot = - serde_json::from_str(&session_json).map_err(|error| { - SpacetimeClientError::Runtime(format!("puzzle agent session_json 非法: {error}")) - })?; - 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_json = result - .item_json - .ok_or_else(|| SpacetimeClientError::missing_snapshot("puzzle work 快照"))?; - let item: DomainPuzzleWorkProfile = serde_json::from_str(&item_json).map_err(|error| { - SpacetimeClientError::Runtime(format!("puzzle work item_json 非法: {error}")) - })?; - 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)); - } - - let items_json = result - .items_json - .ok_or_else(|| SpacetimeClientError::missing_snapshot("puzzle works 快照"))?; - let items: Vec = - serde_json::from_str(&items_json).map_err(|error| { - SpacetimeClientError::Runtime(format!("puzzle works items_json 非法: {error}")) - })?; - Ok(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_json = result - .run_json - .ok_or_else(|| SpacetimeClientError::missing_snapshot("puzzle run 快照"))?; - let run: DomainPuzzleRunSnapshot = serde_json::from_str(&run_json).map_err(|error| { - SpacetimeClientError::Runtime(format!("puzzle run run_json 非法: {error}")) - })?; - 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)); - } - - let items_json = result - .items_json - .ok_or_else(|| SpacetimeClientError::missing_snapshot("big fish works 快照"))?; - serde_json::from_str::>(&items_json) - .map(|items| { - items - .into_iter() - .map(|item| item.into_record(fallback_owner_user_id)) - .collect() - }) - .map_err(|error| { - SpacetimeClientError::Runtime(format!("big fish works items_json 非法: {error}")) - }) -} - -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_json = result - .run_json - .ok_or_else(|| SpacetimeClientError::missing_snapshot("big fish run 快照"))?; - let run: module_big_fish::BigFishRuntimeSnapshot = - serde_json::from_str(&run_json).map_err(|error| { - SpacetimeClientError::Runtime(format!("big fish run run_json 非法: {error}")) - })?; - 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_json = result.session_json.ok_or_else(|| { - SpacetimeClientError::Procedure( - "SpacetimeDB procedure 未返回 match3d agent session 快照".to_string(), - ) - })?; - let session = - serde_json::from_str::(&session_json).map_err(|error| { - SpacetimeClientError::Runtime(format!("match3d session_json 非法: {error}")) - })?; - - 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_json = result.work_json.ok_or_else(|| { - SpacetimeClientError::Procedure( - "SpacetimeDB procedure 未返回 match3d work 快照".to_string(), - ) - })?; - let work = serde_json::from_str::(&work_json).map_err(|error| { - SpacetimeClientError::Runtime(format!("match3d work_json 非法: {error}")) - })?; - - 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()), - )); - } - - let items_json = result.items_json.ok_or_else(|| { - SpacetimeClientError::Procedure( - "SpacetimeDB procedure 未返回 match3d works 快照".to_string(), - ) - })?; - let items = - serde_json::from_str::>(&items_json).map_err(|error| { - SpacetimeClientError::Runtime(format!("match3d works items_json 非法: {error}")) - })?; - - Ok(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_json = result.run_json.ok_or_else(|| { - SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回 match3d run 快照".to_string()) - })?; - map_match3d_run_json(run_json) -} - -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_json = result.run_json.ok_or_else(|| { - SpacetimeClientError::Procedure( - "SpacetimeDB procedure 未返回 match3d click run 快照".to_string(), - ) - })?; - let run = map_match3d_run_json(run_json)?; - 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_json = result - .session_json - .ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole agent session 快照"))?; - let session = serde_json::from_str::(&session_json).map_err( - |error| SpacetimeClientError::Runtime(format!("square hole session_json 非法: {error}")), - )?; - - 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_json = result - .work_json - .ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole work 快照"))?; - let work = serde_json::from_str::(&work_json).map_err(|error| { - SpacetimeClientError::Runtime(format!("square hole work_json 非法: {error}")) - })?; - - 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)); - } - - let items_json = result - .items_json - .ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole works 快照"))?; - let items = - serde_json::from_str::>(&items_json).map_err(|error| { - SpacetimeClientError::Runtime(format!("square hole works items_json 非法: {error}")) - })?; - - Ok(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_json = result - .run_json - .ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole run 快照"))?; - map_square_hole_run_json(run_json) -} - -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_json = result - .run_json - .ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole drop run 快照"))?; - let feedback_json = result - .feedback_json - .ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole drop feedback 快照"))?; - let run = map_square_hole_run_json(run_json)?; - let feedback = serde_json::from_str::(&feedback_json) - .map_err(|error| { - SpacetimeClientError::Runtime(format!("square hole feedback_json 非法: {error}")) - })?; - - 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_json = result - .session_json - .ok_or_else(|| SpacetimeClientError::missing_snapshot("visual novel agent session 快照"))?; - let session = serde_json::from_str::(&session_json) - .map_err(|error| { - SpacetimeClientError::Runtime(format!("visual novel session_json 非法: {error}")) - })?; - - 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_json = result - .work_json - .ok_or_else(|| SpacetimeClientError::missing_snapshot("visual novel work 快照"))?; - let work = serde_json::from_str::(&work_json).map_err(|error| { - SpacetimeClientError::Runtime(format!("visual novel work_json 非法: {error}")) - })?; - - 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)); - } - - let items_json = result - .items_json - .ok_or_else(|| SpacetimeClientError::missing_snapshot("visual novel works 快照"))?; - let items = - serde_json::from_str::>(&items_json).map_err(|error| { - SpacetimeClientError::Runtime(format!("visual novel works items_json 非法: {error}")) - })?; - - Ok(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_json = result - .run_json - .ok_or_else(|| SpacetimeClientError::missing_snapshot("visual novel run 快照"))?; - let run = serde_json::from_str::(&run_json).map_err(|error| { - SpacetimeClientError::Runtime(format!("visual novel run_json 非法: {error}")) - })?; - - 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)); - } - - let items_json = result - .items_json - .ok_or_else(|| SpacetimeClientError::missing_snapshot("visual novel history 快照"))?; - let items = serde_json::from_str::>(&items_json) - .map_err(|error| { - SpacetimeClientError::Runtime(format!("visual novel history items_json 非法: {error}")) - })?; - - Ok(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_json = result - .event_json - .ok_or_else(|| SpacetimeClientError::missing_snapshot("visual novel runtime event 快照"))?; - let event = serde_json::from_str::(&event_json).map_err( - |error| SpacetimeClientError::Runtime(format!("visual novel event_json 非法: {error}")), - )?; - - 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_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: DomainPuzzleAgentSessionSnapshot, -) -> PuzzleAgentSessionRecord { - PuzzleAgentSessionRecord { - session_id: snapshot.session_id, - seed_text: snapshot.seed_text, - current_turn: snapshot.current_turn, - progress_percent: snapshot.progress_percent, - stage: snapshot.stage.as_str().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: DomainPuzzleAnchorPack) -> 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: DomainPuzzleAnchorItem) -> PuzzleAnchorItemRecord { - PuzzleAnchorItemRecord { - key: snapshot.key, - label: snapshot.label, - value: snapshot.value, - status: snapshot.status.as_str().to_string(), - } -} - -pub(crate) fn map_puzzle_result_draft( - snapshot: DomainPuzzleResultDraft, -) -> 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: module_puzzle::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: DomainPuzzleDraftLevel) -> 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: module_puzzle::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: DomainPuzzleCreatorIntent, -) -> 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: DomainPuzzleGeneratedImageCandidate, -) -> 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: DomainPuzzleAgentMessageSnapshot, -) -> PuzzleAgentMessageRecord { - PuzzleAgentMessageRecord { - message_id: snapshot.message_id, - role: snapshot.role.as_str().to_string(), - kind: snapshot.kind.as_str().to_string(), - text: snapshot.text, - created_at: format_timestamp_micros(snapshot.created_at_micros), - } -} - -fn map_match3d_agent_session_snapshot( - snapshot: Match3DAgentSessionJsonRecord, -) -> 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: Match3DCreatorConfigJsonRecord, -) -> 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: Match3DDraftJsonRecord, - 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, - total_item_count: snapshot.clear_count.saturating_mul(3), - publish_ready: false, - blockers: Vec::new(), - } -} - -fn map_match3d_agent_message_snapshot( - snapshot: Match3DAgentMessageJsonRecord, -) -> 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: Match3DWorkJsonRecord) -> 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, - } -} - -fn map_match3d_run_json(run_json: String) -> Result { - let run = serde_json::from_str::(&run_json).map_err(|error| { - SpacetimeClientError::Runtime(format!("match3d run_json 非法: {error}")) - })?; - Ok(map_match3d_run_snapshot(run)) -} - -fn map_match3d_run_snapshot(snapshot: Match3DRunJsonRecord) -> 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: Match3DItemJsonRecord, - 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: Match3DTraySlotJsonRecord) -> 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: SquareHoleAgentSessionJsonRecord, -) -> 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: SquareHoleCreatorConfigJsonRecord, -) -> 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: SquareHoleDraftJsonRecord, -) -> 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: SquareHoleAgentMessageJsonRecord, -) -> 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: SquareHoleWorkJsonRecord, -) -> 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, - } -} - -fn map_square_hole_run_json(run_json: String) -> Result { - let run = serde_json::from_str::(&run_json).map_err(|error| { - SpacetimeClientError::Runtime(format!("square hole run_json 非法: {error}")) - })?; - Ok(map_square_hole_run_snapshot(run)) -} - -fn map_square_hole_run_snapshot(snapshot: SquareHoleRunJsonRecord) -> 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: SquareHoleShapeJsonRecord, -) -> 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: SquareHoleHoleJsonRecord, -) -> 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: SquareHoleShapeOptionJsonRecord, -) -> 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: SquareHoleHoleOptionJsonRecord, -) -> 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: SquareHoleDropFeedbackJsonRecord, -) -> 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: VisualNovelAgentSessionJsonRecord, -) -> 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, - pending_action: snapshot.pending_action, - 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: VisualNovelAgentMessageJsonRecord, -) -> 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: VisualNovelWorkJsonRecord, -) -> 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: 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), - } -} - -fn map_visual_novel_run_snapshot(snapshot: VisualNovelRunJsonRecord) -> 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: snapshot.flags, - metrics: snapshot.metrics, - history: snapshot - .history - .into_iter() - .map(map_visual_novel_history_entry) - .collect(), - available_choices: 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: VisualNovelHistoryEntryJsonRecord, -) -> 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: 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: VisualNovelRuntimeEventJsonRecord, -) -> 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: snapshot.payload, - occurred_at: format_timestamp_micros(snapshot.occurred_at_micros), - } -} - -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: DomainPuzzleAgentSuggestedAction, -) -> PuzzleAgentSuggestedActionRecord { - PuzzleAgentSuggestedActionRecord { - action_id: snapshot.id, - action_type: snapshot.action_type, - label: snapshot.label, - } -} - -pub(crate) fn map_puzzle_result_preview( - snapshot: DomainPuzzleResultPreviewEnvelope, -) -> 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: DomainPuzzleResultPreviewBlocker, -) -> PuzzleResultPreviewBlockerRecord { - PuzzleResultPreviewBlockerRecord { - blocker_id: snapshot.id, - code: snapshot.code, - message: snapshot.message, - } -} - -pub(crate) fn map_puzzle_result_preview_finding( - snapshot: DomainPuzzleResultPreviewFinding, -) -> PuzzleResultPreviewFindingRecord { - PuzzleResultPreviewFindingRecord { - finding_id: snapshot.id, - severity: snapshot.severity, - code: snapshot.code, - message: snapshot.message, - } -} - -pub(crate) fn map_puzzle_work_profile( - snapshot: DomainPuzzleWorkProfile, -) -> 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: snapshot.publication_status.as_str().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_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, - 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_run_snapshot(snapshot: DomainPuzzleRunSnapshot) -> 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: module_puzzle::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: DomainPuzzleRuntimeLevelSnapshot, -) -> PuzzleRuntimeLevelRecord { - let started_at_ms = if snapshot.started_at_ms == 0 { - // 中文注释:旧 run_json 没有计时字段时只补一个可用开始时间,其余限时字段保持旧默认值。 - 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: snapshot.status.as_str().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: module_puzzle::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: DomainPuzzleBoardSnapshot) -> 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: DomainPuzzlePieceState) -> 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: DomainPuzzleMergedGroupState, -) -> 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: DomainPuzzleCellPosition, -) -> 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_runtime_snapshot( - snapshot: module_big_fish::BigFishRuntimeSnapshot, -) -> BigFishRuntimeRunRecord { - BigFishRuntimeRunRecord { - run_id: snapshot.run_id, - session_id: snapshot.session_id, - status: snapshot.status.as_str().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: module_big_fish::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: module_big_fish::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 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_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 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, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct Match3DCreatorConfigJsonRecord { - theme_text: String, - reference_image_src: Option, - clear_count: u32, - difficulty: u32, - #[serde(default)] - asset_style_id: Option, - #[serde(default)] - asset_style_label: Option, - #[serde(default)] - asset_style_prompt: Option, - #[serde(default)] - generate_click_sound: bool, -} - -#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct Match3DAgentMessageJsonRecord { - message_id: String, - #[allow(dead_code)] - session_id: String, - role: String, - kind: String, - text: String, - created_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct Match3DDraftJsonRecord { - profile_id: String, - game_name: String, - theme_text: String, - summary_text: String, - tags: Vec, - clear_count: u32, - difficulty: u32, -} - -#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct Match3DAgentSessionJsonRecord { - session_id: String, - #[allow(dead_code)] - owner_user_id: String, - #[allow(dead_code)] - seed_text: String, - current_turn: u32, - progress_percent: u32, - stage: String, - config: Match3DCreatorConfigJsonRecord, - draft: Option, - messages: Vec, - last_assistant_reply: String, - published_profile_id: Option, - #[allow(dead_code)] - created_at_micros: i64, - updated_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct Match3DWorkJsonRecord { - profile_id: String, - owner_user_id: String, - source_session_id: String, - author_display_name: String, - game_name: String, - theme_text: String, - summary_text: String, - tags: Vec, - cover_image_src: String, - cover_asset_id: String, - clear_count: u32, - difficulty: u32, - config: Match3DCreatorConfigJsonRecord, - publication_status: String, - publish_ready: bool, - play_count: u32, - updated_at_micros: i64, - published_at_micros: Option, - #[serde(default)] - generated_item_assets_json: Option, -} - -#[derive(Clone, Debug, PartialEq, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct Match3DItemJsonRecord { - item_instance_id: String, - item_type_id: String, - visual_key: String, - x: f32, - y: f32, - radius: f32, - layer: u32, - state: String, - clickable: bool, -} - -#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct Match3DTraySlotJsonRecord { - slot_index: u32, - item_instance_id: Option, - item_type_id: Option, - visual_key: Option, -} - -#[derive(Clone, Debug, PartialEq, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct Match3DRunJsonRecord { - run_id: String, - profile_id: String, - status: String, - snapshot_version: u32, - started_at_ms: i64, - duration_limit_ms: i64, - server_now_ms: i64, - remaining_ms: i64, - clear_count: u32, - total_item_count: u32, - cleared_item_count: u32, - tray_slots: Vec, - items: Vec, - failure_reason: Option, -} - -#[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, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct VisualNovelAgentMessageJsonRecord { - message_id: String, - session_id: String, - role: String, - kind: String, - text: String, - created_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct VisualNovelAgentSessionJsonRecord { - session_id: String, - owner_user_id: String, - source_mode: String, - status: String, - seed_text: String, - source_asset_ids: Vec, - current_turn: u32, - progress_percent: u32, - messages: Vec, - draft: Option, - pending_action: Option, - last_assistant_reply: Option, - published_profile_id: Option, - created_at_micros: i64, - updated_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct VisualNovelWorkJsonRecord { - work_id: String, - profile_id: String, - owner_user_id: String, - source_session_id: Option, - author_display_name: String, - work_title: String, - work_description: String, - tags: Vec, - cover_image_src: Option, - source_asset_ids: Vec, - draft: serde_json::Value, - publication_status: String, - publish_ready: bool, - play_count: u32, - created_at_micros: i64, - updated_at_micros: i64, - published_at_micros: Option, -} - -#[derive(Clone, Debug, PartialEq, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct VisualNovelHistoryEntryJsonRecord { - entry_id: String, - run_id: String, - owner_user_id: String, - profile_id: String, - turn_index: u32, - source: String, - action_text: Option, - steps: serde_json::Value, - snapshot_before_hash: Option, - snapshot_after_hash: Option, - created_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct VisualNovelRunJsonRecord { - run_id: String, - owner_user_id: String, - profile_id: String, - mode: String, - status: String, - current_scene_id: Option, - current_phase_id: Option, - visible_character_ids: Vec, - flags: serde_json::Value, - metrics: serde_json::Value, - history: Vec, - available_choices: serde_json::Value, - text_mode_enabled: bool, - created_at_micros: i64, - updated_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct VisualNovelRuntimeEventJsonRecord { - event_id: String, - run_id: Option, - owner_user_id: String, - profile_id: Option, - event_kind: String, - client_event_id: Option, - history_entry_id: Option, - payload: serde_json::Value, - occurred_at_micros: i64, -} - -#[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, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct SquareHoleCreatorConfigJsonRecord { - theme_text: String, - twist_rule: String, - shape_count: u32, - difficulty: u32, - #[serde(default)] - shape_options: Vec, - #[serde(default)] - hole_options: Vec, - #[serde(default)] - background_prompt: String, - #[serde(default)] - cover_image_src: String, - #[serde(default)] - background_image_src: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct SquareHoleShapeOptionJsonRecord { - option_id: String, - shape_kind: String, - label: String, - #[serde(default)] - target_hole_id: String, - image_prompt: String, - #[serde(default)] - image_src: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct SquareHoleHoleOptionJsonRecord { - hole_id: String, - hole_kind: String, - label: String, - #[serde(default)] - image_prompt: String, - #[serde(default)] - image_src: String, - #[serde(default)] - bonus: bool, -} - -#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct SquareHoleAgentMessageJsonRecord { - message_id: String, - #[allow(dead_code)] - session_id: String, - role: String, - kind: String, - text: String, - created_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct SquareHoleDraftJsonRecord { - profile_id: String, - game_name: String, - theme_text: String, - twist_rule: String, - summary_text: String, - tags: Vec, - #[serde(default)] - cover_image_src: String, - #[serde(default)] - background_prompt: String, - #[serde(default)] - background_image_src: String, - #[serde(default)] - shape_options: Vec, - #[serde(default)] - hole_options: Vec, - shape_count: u32, - difficulty: u32, -} - -#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct SquareHoleAgentSessionJsonRecord { - session_id: String, - #[allow(dead_code)] - owner_user_id: String, - #[allow(dead_code)] - seed_text: String, - current_turn: u32, - progress_percent: u32, - stage: String, - config: SquareHoleCreatorConfigJsonRecord, - draft: Option, - messages: Vec, - last_assistant_reply: String, - published_profile_id: Option, - #[allow(dead_code)] - created_at_micros: i64, - updated_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct SquareHoleWorkJsonRecord { - work_id: String, - profile_id: String, - owner_user_id: String, - source_session_id: String, - author_display_name: String, - game_name: String, - theme_text: String, - twist_rule: String, - summary_text: String, - tags: Vec, - cover_image_src: String, - #[serde(default)] - background_prompt: String, - #[serde(default)] - background_image_src: String, - #[serde(default)] - shape_options: Vec, - #[serde(default)] - hole_options: Vec, - shape_count: u32, - difficulty: u32, - #[allow(dead_code)] - config: SquareHoleCreatorConfigJsonRecord, - publication_status: String, - publish_ready: bool, - play_count: u32, - updated_at_micros: i64, - published_at_micros: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct SquareHoleShapeJsonRecord { - shape_id: String, - shape_kind: String, - label: String, - #[serde(default)] - target_hole_id: String, - color: String, - #[serde(default)] - image_src: String, -} - -#[derive(Clone, Debug, PartialEq, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct SquareHoleHoleJsonRecord { - hole_id: String, - hole_kind: String, - label: String, - x: f32, - y: f32, - #[serde(default)] - image_src: String, - #[serde(default)] - bonus: bool, -} - -#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct SquareHoleDropFeedbackJsonRecord { - accepted: bool, - reject_reason: Option, - message: String, -} - -#[derive(Clone, Debug, PartialEq, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct SquareHoleRunJsonRecord { - run_id: String, - profile_id: String, - owner_user_id: String, - status: String, - snapshot_version: u64, - started_at_ms: i64, - duration_limit_ms: i64, - server_now_ms: i64, - remaining_ms: i64, - total_shape_count: u32, - completed_shape_count: u32, - combo: u32, - best_combo: u32, - score: u32, - rule_label: String, - #[serde(default)] - background_image_src: String, - #[serde(default)] - #[allow(dead_code)] - shape_options: Vec, - current_shape: Option, - holes: Vec, - last_feedback: Option, -} - -#[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 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, -} - -#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] -struct CompatibleBigFishWorkSummaryRecord { - work_id: String, - source_session_id: String, - #[serde(default)] - owner_user_id: Option, - title: String, - subtitle: String, - summary: String, - cover_image_src: Option, - status: String, - updated_at_micros: i64, - #[serde(default)] - published_at_micros: Option, - publish_ready: bool, - level_count: u32, - level_main_image_ready_count: u32, - level_motion_ready_count: u32, - background_ready: bool, - #[serde(default)] - play_count: u32, - #[serde(default)] - remix_count: u32, - #[serde(default)] - like_count: u32, - #[serde(default)] - recent_play_count_7d: u32, -} - -impl CompatibleBigFishWorkSummaryRecord { - fn into_record(self, fallback_owner_user_id: Option<&str>) -> BigFishWorkSummaryRecord { - BigFishWorkSummaryRecord { - work_id: self.work_id, - source_session_id: self.source_session_id, - // 中文注释:兼容旧 works JSON 没有 owner_user_id 的历史数据,避免一次字段升级把整个作品列表打崩。 - owner_user_id: self.owner_user_id.unwrap_or_else(|| { - fallback_owner_user_id - .map(str::to_string) - .unwrap_or_default() - }), - title: self.title, - subtitle: self.subtitle, - summary: self.summary, - cover_image_src: self.cover_image_src, - status: self.status, - updated_at_micros: self.updated_at_micros, - published_at_micros: self.published_at_micros, - publish_ready: self.publish_ready, - level_count: self.level_count, - level_main_image_ready_count: self.level_main_image_ready_count, - level_motion_ready_count: self.level_motion_ready_count, - background_ready: self.background_ready, - play_count: self.play_count, - remix_count: self.remix_count, - like_count: self.like_count, - recent_play_count_7d: self.recent_play_count_7d, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn puzzle_works_mapper_backfills_missing_public_stat_fields() { - let result = PuzzleWorksProcedureResult { - ok: true, - items_json: Some( - r#"[{ - "work_id":"puzzle-work-1", - "profile_id":"puzzle-profile-1", - "owner_user_id":"user-1", - "source_session_id":null, - "author_display_name":"测试作者", - "level_name":"雨夜拼图", - "summary":"旧公开作品摘要", - "theme_tags":["雨夜","猫咪","神庙"], - "cover_image_src":null, - "cover_asset_id":null, - "publication_status":"Published", - "updated_at_micros":123000000, - "published_at_micros":123000000, - "publish_ready":true, - "anchor_pack":{ - "theme_promise":{ - "key":"themePromise", - "label":"题材承诺", - "value":"雨夜冒险", - "status":"Inferred" - }, - "visual_subject":{ - "key":"visualSubject", - "label":"画面主体", - "value":"猫咪神庙", - "status":"Inferred" - }, - "visual_mood":{ - "key":"visualMood", - "label":"视觉气质", - "value":"温暖", - "status":"Inferred" - }, - "composition_hooks":{ - "key":"compositionHooks", - "label":"拼图记忆点", - "value":"灯光", - "status":"Inferred" - }, - "tags_and_forbidden":{ - "key":"tagsAndForbidden", - "label":"标签与禁忌", - "value":"雨夜, 猫咪, 神庙", - "status":"Inferred" - } - } - }]"# - .to_string(), - ), - error_message: None, - }; - - let items = map_puzzle_works_procedure_result(result) - .expect("旧 puzzle works JSON 缺统计字段时应按 0 兼容"); - - assert_eq!(items.len(), 1); - assert_eq!(items[0].play_count, 0); - assert_eq!(items[0].remix_count, 0); - assert_eq!(items[0].like_count, 0); - } - - #[test] - fn puzzle_run_mapper_backfills_missing_timer_fields() { - let result = PuzzleRunProcedureResult { - ok: true, - run_json: Some( - r#"{ - "run_id":"puzzle-run-1", - "entry_profile_id":"puzzle-profile-1", - "cleared_level_count":0, - "current_level_index":1, - "current_grid_size":3, - "played_profile_ids":["puzzle-profile-1"], - "previous_level_tags":["雨夜","猫咪","神庙"], - "current_level":{ - "run_id":"puzzle-run-1", - "level_index":1, - "grid_size":3, - "profile_id":"puzzle-profile-1", - "level_name":"雨夜拼图", - "author_display_name":"测试作者", - "theme_tags":["雨夜","猫咪","神庙"], - "cover_image_src":null, - "board":{ - "rows":3, - "cols":3, - "pieces":[{ - "piece_id":"piece-1", - "correct_row":0, - "correct_col":0, - "current_row":0, - "current_col":0, - "merged_group_id":null - }], - "merged_groups":[], - "selected_piece_id":null - }, - "status":"Playing" - }, - "recommended_next_profile_id":null - }"# - .to_string(), - ), - error_message: None, - }; - - let run = map_puzzle_run_procedure_result(result) - .expect("旧 puzzle run JSON 缺计时字段时应按默认值兼容"); - 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_backfills_missing_owner_user_id_for_private_lists() { - let result = BigFishWorksProcedureResult { - ok: true, - items_json: Some( - r#"[{ - "work_id":"big-fish-work-session-1", - "source_session_id":"session-1", - "title":"深海草稿", - "subtitle":"副标题", - "summary":"摘要", - "cover_image_src":null, - "status":"draft", - "updated_at_micros":123, - "publish_ready":false, - "level_count":8, - "level_main_image_ready_count":0, - "level_motion_ready_count":0, - "background_ready":false - }]"# - .to_string(), - ), - error_message: None, - }; - - let items = map_big_fish_works_procedure_result(result, Some("user-1")) - .expect("旧 works JSON 应能被兼容解析"); - - 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, 0); - assert_eq!(items[0].remix_count, 0); - assert_eq!(items[0].like_count, 0); - } - - #[test] - fn big_fish_works_mapper_keeps_empty_owner_when_gallery_legacy_json_lacks_field() { - let result = BigFishWorksProcedureResult { - ok: true, - items_json: Some( - r#"[{ - "work_id":"big-fish-work-session-2", - "source_session_id":"session-2", - "title":"公开作品", - "subtitle":"副标题", - "summary":"摘要", - "cover_image_src":null, - "status":"published", - "updated_at_micros":456, - "publish_ready":true, - "level_count":8, - "level_main_image_ready_count":8, - "level_motion_ready_count":16, - "background_ready":true - }]"# - .to_string(), - ), - error_message: None, - }; - - let items = map_big_fish_works_procedure_result(result, None) - .expect("公开 works 旧 JSON 也不应因缺字段报错"); - - assert_eq!(items.len(), 1); - assert!(items[0].owner_user_id.is_empty()); - assert_eq!(items[0].published_at_micros, None); - assert_eq!(items[0].play_count, 0); - assert_eq!(items[0].remix_count, 0); - assert_eq!(items[0].like_count, 0); - } - - #[test] - fn match3d_work_mapper_keeps_generated_item_assets_json() { - let result = Match3DWorkProcedureResult { - ok: true, - work_json: Some( - r#"{ - "profileId":"match3d-profile-1", - "ownerUserId":"user-1", - "sourceSessionId":"match3d-session-1", - "authorDisplayName":"测试作者", - "gameName":"水果抓大鹅", - "themeText":"水果", - "summaryText":"水果主题", - "tags":["水果"], - "coverImageSrc":"", - "coverAssetId":"", - "clearCount":3, - "difficulty":3, - "config":{ - "themeText":"水果", - "referenceImageSrc":null, - "clearCount":3, - "difficulty":3 - }, - "publicationStatus":"Draft", - "publishReady":false, - "playCount":0, - "updatedAtMicros":123000000, - "publishedAtMicros":null, - "generatedItemAssetsJson":"[{\"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("match3d work JSON 应保留生成素材 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"}]"# - ) - ); - } -} - -#[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/match3d.rs b/server-rs/crates/spacetime-client/src/match3d.rs index eb9efa7e..df7fb762 100644 --- a/server-rs/crates/spacetime-client/src/match3d.rs +++ b/server-rs/crates/spacetime-client/src/match3d.rs @@ -16,17 +16,20 @@ impl SpacetimeClient { created_at_micros: input.created_at_micros, }; - self.call_after_connect(move |connection, sender| { - connection.procedures().create_match_3_d_agent_session_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) - .and_then(map_match3d_agent_session_procedure_result); - send_once(&sender, mapped); - }, - ); - }) + self.call_after_connect( + "create_match_3_d_agent_session", + move |connection, sender| { + connection.procedures().create_match_3_d_agent_session_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_match3d_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -40,7 +43,7 @@ impl SpacetimeClient { owner_user_id, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("get_match_3_d_agent_session", move |connection, sender| { connection.procedures().get_match_3_d_agent_session_then( procedure_input, move |_, result| { @@ -66,17 +69,20 @@ impl SpacetimeClient { submitted_at_micros: input.submitted_at_micros, }; - self.call_after_connect(move |connection, sender| { - connection.procedures().submit_match_3_d_agent_message_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) - .and_then(map_match3d_agent_session_procedure_result); - send_once(&sender, mapped); - }, - ); - }) + self.call_after_connect( + "submit_match_3_d_agent_message", + move |connection, sender| { + connection.procedures().submit_match_3_d_agent_message_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_match3d_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -96,16 +102,22 @@ impl SpacetimeClient { error_message: input.error_message, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .finalize_match_3_d_agent_message_turn_then(procedure_input, move |_, result| { - let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) - .and_then(map_match3d_agent_session_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "finalize_match_3_d_agent_message_turn", + move |connection, sender| { + connection + .procedures() + .finalize_match_3_d_agent_message_turn_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_match3d_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -127,7 +139,7 @@ impl SpacetimeClient { generated_item_assets_json: input.generated_item_assets_json, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("compile_match_3_d_draft", move |connection, sender| { connection.procedures().compile_match_3_d_draft_then( procedure_input, move |_, result| { @@ -159,7 +171,7 @@ impl SpacetimeClient { updated_at_micros: input.updated_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("update_match_3_d_work", move |connection, sender| { connection.procedures().update_match_3_d_work_then( procedure_input, move |_, result| { @@ -185,7 +197,7 @@ impl SpacetimeClient { published_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("publish_match_3_d_work", move |connection, sender| { connection.procedures().publish_match_3_d_work_then( procedure_input, move |_, result| { @@ -213,10 +225,22 @@ impl SpacetimeClient { pub async fn list_match3d_gallery( &self, ) -> Result, SpacetimeClientError> { - self.list_match3d_works_with_input(Match3DWorksListInput { - // 中文注释:公开广场读取只依赖 published_only,owner_user_id 保持非空便于兼容校验。 - owner_user_id: "match3d-public-gallery".to_string(), - published_only: true, + self.read_after_connect("list_match3d_gallery", move |connection| { + let mut items = connection + .db() + .match_3_d_gallery_view() + .iter() + .collect::>(); + items.sort_by(|left, right| { + right + .updated_at_micros + .cmp(&left.updated_at_micros) + .then_with(|| left.profile_id.cmp(&right.profile_id)) + }); + Ok(items + .into_iter() + .map(map_match3d_gallery_view_row) + .collect()) }) .await } @@ -225,7 +249,7 @@ impl SpacetimeClient { &self, procedure_input: Match3DWorksListInput, ) -> Result, SpacetimeClientError> { - self.call_after_connect(move |connection, sender| { + self.call_after_connect("list_match_3_d_works", move |connection, sender| { connection .procedures() .list_match_3_d_works_then(procedure_input, move |_, result| { @@ -248,7 +272,7 @@ impl SpacetimeClient { owner_user_id, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("get_match_3_d_work_detail", move |connection, sender| { connection.procedures().get_match_3_d_work_detail_then( procedure_input, move |_, result| { @@ -272,7 +296,7 @@ impl SpacetimeClient { owner_user_id, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("delete_match_3_d_work", move |connection, sender| { connection.procedures().delete_match_3_d_work_then( procedure_input, move |_, result| { @@ -299,7 +323,7 @@ impl SpacetimeClient { item_type_count_override: input.item_type_count_override, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("start_match_3_d_run", move |connection, sender| { connection .procedures() .start_match_3_d_run_then(procedure_input, move |_, result| { @@ -327,7 +351,7 @@ impl SpacetimeClient { owner_user_id, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("get_match_3_d_run", move |connection, sender| { connection .procedures() .get_match_3_d_run_then(procedure_input, move |_, result| { @@ -359,7 +383,7 @@ impl SpacetimeClient { clicked_at_ms: input.clicked_at_ms, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("click_match_3_d_item", move |connection, sender| { connection .procedures() .click_match_3_d_item_then(procedure_input, move |_, result| { @@ -390,7 +414,7 @@ impl SpacetimeClient { stopped_at_ms: input.stopped_at_ms, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("stop_match_3_d_run", move |connection, sender| { connection .procedures() .stop_match_3_d_run_then(procedure_input, move |_, result| { @@ -419,7 +443,7 @@ impl SpacetimeClient { restarted_at_ms: input.restarted_at_ms, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("restart_match_3_d_run", move |connection, sender| { connection.procedures().restart_match_3_d_run_then( procedure_input, move |_, result| { @@ -448,7 +472,7 @@ impl SpacetimeClient { finished_at_ms: input.finished_at_ms, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("finish_match_3_d_time_up", move |connection, sender| { connection.procedures().finish_match_3_d_time_up_then( procedure_input, move |_, result| { 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 94% rename from server-rs/crates/spacetime-client/src/module_bindings/mod.rs rename to server-rs/crates/spacetime-client/src/module_bindings.rs index 379a2436..984ccd36 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -95,6 +95,7 @@ pub mod auth_store_snapshot_type; pub mod auth_store_snapshot_upsert_input_type; pub mod authorize_database_migration_operator_procedure; pub mod bark_battle_draft_config_row_type; +pub mod bark_battle_draft_config_snapshot_type; pub mod bark_battle_draft_config_table; pub mod bark_battle_draft_config_upsert_input_type; pub mod bark_battle_draft_create_input_type; @@ -107,8 +108,10 @@ pub mod bark_battle_published_config_row_type; pub mod bark_battle_published_config_table; pub mod bark_battle_run_finish_input_type; pub mod bark_battle_run_get_input_type; +pub mod bark_battle_run_snapshot_type; pub mod bark_battle_run_start_input_type; pub mod bark_battle_runtime_config_get_input_type; +pub mod bark_battle_runtime_config_snapshot_type; pub mod bark_battle_runtime_run_row_type; pub mod bark_battle_runtime_run_table; pub mod bark_battle_score_record_row_type; @@ -149,6 +152,7 @@ pub mod big_fish_draft_compile_input_type; pub mod big_fish_event_kind_type; pub mod big_fish_event_table; pub mod big_fish_event_type; +pub mod big_fish_gallery_view_table; pub mod big_fish_game_draft_type; pub mod big_fish_input_submit_input_type; pub mod big_fish_level_blueprint_type; @@ -160,16 +164,20 @@ pub mod big_fish_run_get_input_type; pub mod big_fish_run_procedure_result_type; pub mod big_fish_run_start_input_type; pub mod big_fish_run_status_type; +pub mod big_fish_runtime_entity_snapshot_type; pub mod big_fish_runtime_params_type; pub mod big_fish_runtime_run_table; pub mod big_fish_runtime_run_type; +pub mod big_fish_runtime_snapshot_type; pub mod big_fish_session_create_input_type; pub mod big_fish_session_get_input_type; pub mod big_fish_session_procedure_result_type; pub mod big_fish_session_snapshot_type; +pub mod big_fish_vector_2_type; pub mod big_fish_work_delete_input_type; pub mod big_fish_work_like_record_input_type; pub mod big_fish_work_remix_input_type; +pub mod big_fish_work_summary_snapshot_type; pub mod big_fish_works_list_input_type; pub mod big_fish_works_procedure_result_type; pub mod bind_asset_object_to_entity_and_return_procedure; @@ -402,30 +410,40 @@ pub mod list_visual_novel_works_procedure; pub mod mark_profile_recharge_order_paid_and_return_procedure; pub mod match_3_d_agent_message_finalize_input_type; pub mod match_3_d_agent_message_row_type; +pub mod match_3_d_agent_message_snapshot_type; pub mod match_3_d_agent_message_submit_input_type; pub mod match_3_d_agent_message_table; pub mod match_3_d_agent_session_create_input_type; pub mod match_3_d_agent_session_get_input_type; pub mod match_3_d_agent_session_procedure_result_type; pub mod match_3_d_agent_session_row_type; +pub mod match_3_d_agent_session_snapshot_type; pub mod match_3_d_agent_session_table; pub mod match_3_d_click_item_procedure_result_type; +pub mod match_3_d_creator_config_snapshot_type; pub mod match_3_d_draft_compile_input_type; +pub mod match_3_d_draft_snapshot_type; +pub mod match_3_d_gallery_view_row_type; +pub mod match_3_d_gallery_view_table; +pub mod match_3_d_item_snapshot_type; pub mod match_3_d_run_click_input_type; pub mod match_3_d_run_get_input_type; pub mod match_3_d_run_procedure_result_type; pub mod match_3_d_run_restart_input_type; +pub mod match_3_d_run_snapshot_type; pub mod match_3_d_run_start_input_type; pub mod match_3_d_run_stop_input_type; pub mod match_3_d_run_time_up_input_type; pub mod match_3_d_runtime_run_row_type; pub mod match_3_d_runtime_run_table; +pub mod match_3_d_tray_slot_snapshot_type; pub mod match_3_d_work_delete_input_type; pub mod match_3_d_work_get_input_type; pub mod match_3_d_work_procedure_result_type; pub mod match_3_d_work_profile_row_type; pub mod match_3_d_work_profile_table; pub mod match_3_d_work_publish_input_type; +pub mod match_3_d_work_snapshot_type; pub mod match_3_d_work_update_input_type; pub mod match_3_d_works_list_input_type; pub mod match_3_d_works_procedure_result_type; @@ -499,33 +517,60 @@ pub mod puzzle_agent_message_finalize_input_type; pub mod puzzle_agent_message_kind_type; pub mod puzzle_agent_message_role_type; pub mod puzzle_agent_message_row_type; +pub mod puzzle_agent_message_snapshot_type; pub mod puzzle_agent_message_submit_input_type; pub mod puzzle_agent_message_table; pub mod puzzle_agent_session_create_input_type; pub mod puzzle_agent_session_get_input_type; pub mod puzzle_agent_session_procedure_result_type; pub mod puzzle_agent_session_row_type; +pub mod puzzle_agent_session_snapshot_type; pub mod puzzle_agent_session_table; pub mod puzzle_agent_stage_type; +pub mod puzzle_agent_suggested_action_type; +pub mod puzzle_anchor_item_type; +pub mod puzzle_anchor_pack_type; +pub mod puzzle_anchor_status_type; +pub mod puzzle_audio_asset_type; +pub mod puzzle_board_snapshot_type; +pub mod puzzle_cell_position_type; +pub mod puzzle_creator_intent_type; pub mod puzzle_draft_compile_input_type; +pub mod puzzle_draft_level_type; pub mod puzzle_event_kind_type; pub mod puzzle_event_table; pub mod puzzle_event_type; pub mod puzzle_form_draft_save_input_type; +pub mod puzzle_form_draft_type; +pub mod puzzle_gallery_card_view_row_type; +pub mod puzzle_gallery_card_view_table; +pub mod puzzle_gallery_view_table; +pub mod puzzle_generated_image_candidate_type; pub mod puzzle_generated_images_save_input_type; pub mod puzzle_leaderboard_entry_row_type; pub mod puzzle_leaderboard_entry_table; +pub mod puzzle_leaderboard_entry_type; pub mod puzzle_leaderboard_submit_input_type; +pub mod puzzle_merged_group_state_type; +pub mod puzzle_piece_state_type; pub mod puzzle_publication_status_type; pub mod puzzle_publish_input_type; +pub mod puzzle_recommended_next_work_type; +pub mod puzzle_result_draft_type; +pub mod puzzle_result_preview_blocker_type; +pub mod puzzle_result_preview_envelope_type; +pub mod puzzle_result_preview_finding_type; pub mod puzzle_run_drag_input_type; pub mod puzzle_run_get_input_type; pub mod puzzle_run_next_level_input_type; pub mod puzzle_run_pause_input_type; pub mod puzzle_run_procedure_result_type; pub mod puzzle_run_prop_input_type; +pub mod puzzle_run_snapshot_type; pub mod puzzle_run_start_input_type; pub mod puzzle_run_swap_input_type; +pub mod puzzle_runtime_level_snapshot_type; +pub mod puzzle_runtime_level_status_type; pub mod puzzle_runtime_run_row_type; pub mod puzzle_runtime_run_table; pub mod puzzle_select_cover_image_input_type; @@ -537,6 +582,7 @@ pub mod puzzle_work_point_incentive_claim_input_type; pub mod puzzle_work_procedure_result_type; pub mod puzzle_work_profile_row_type; pub mod puzzle_work_profile_table; +pub mod puzzle_work_profile_type; pub mod puzzle_work_remix_input_type; pub mod puzzle_work_upsert_input_type; pub mod puzzle_works_list_input_type; @@ -728,30 +774,43 @@ pub mod seed_analytics_date_dimensions_reducer; pub mod select_puzzle_cover_image_procedure; pub mod square_hole_agent_message_finalize_input_type; pub mod square_hole_agent_message_row_type; +pub mod square_hole_agent_message_snapshot_type; pub mod square_hole_agent_message_submit_input_type; pub mod square_hole_agent_message_table; pub mod square_hole_agent_session_create_input_type; pub mod square_hole_agent_session_get_input_type; pub mod square_hole_agent_session_procedure_result_type; pub mod square_hole_agent_session_row_type; +pub mod square_hole_agent_session_snapshot_type; pub mod square_hole_agent_session_table; +pub mod square_hole_creator_config_snapshot_type; pub mod square_hole_draft_compile_input_type; +pub mod square_hole_draft_snapshot_type; +pub mod square_hole_drop_feedback_snapshot_type; pub mod square_hole_drop_shape_procedure_result_type; +pub mod square_hole_gallery_view_row_type; +pub mod square_hole_gallery_view_table; +pub mod square_hole_hole_option_snapshot_type; +pub mod square_hole_hole_snapshot_type; pub mod square_hole_run_drop_input_type; pub mod square_hole_run_get_input_type; pub mod square_hole_run_procedure_result_type; pub mod square_hole_run_restart_input_type; +pub mod square_hole_run_snapshot_type; pub mod square_hole_run_start_input_type; pub mod square_hole_run_stop_input_type; pub mod square_hole_run_time_up_input_type; pub mod square_hole_runtime_run_row_type; pub mod square_hole_runtime_run_table; +pub mod square_hole_shape_option_snapshot_type; +pub mod square_hole_shape_snapshot_type; pub mod square_hole_work_delete_input_type; pub mod square_hole_work_get_input_type; pub mod square_hole_work_procedure_result_type; pub mod square_hole_work_profile_row_type; pub mod square_hole_work_profile_table; pub mod square_hole_work_publish_input_type; +pub mod square_hole_work_snapshot_type; pub mod square_hole_work_update_input_type; pub mod square_hole_works_list_input_type; pub mod square_hole_works_procedure_result_type; @@ -828,24 +887,33 @@ pub mod user_browse_history_table; pub mod user_browse_history_type; pub mod visual_novel_agent_message_finalize_input_type; pub mod visual_novel_agent_message_row_type; +pub mod visual_novel_agent_message_snapshot_type; pub mod visual_novel_agent_message_submit_input_type; pub mod visual_novel_agent_message_table; pub mod visual_novel_agent_session_create_input_type; pub mod visual_novel_agent_session_get_input_type; pub mod visual_novel_agent_session_procedure_result_type; pub mod visual_novel_agent_session_row_type; +pub mod visual_novel_agent_session_snapshot_type; pub mod visual_novel_agent_session_table; +pub mod visual_novel_gallery_view_row_type; +pub mod visual_novel_gallery_view_table; pub mod visual_novel_history_procedure_result_type; +pub mod visual_novel_json_field_type; +pub mod visual_novel_json_value_type; pub mod visual_novel_run_get_input_type; pub mod visual_novel_run_procedure_result_type; +pub mod visual_novel_run_snapshot_type; pub mod visual_novel_run_snapshot_upsert_input_type; pub mod visual_novel_run_start_input_type; pub mod visual_novel_runtime_event_procedure_result_type; pub mod visual_novel_runtime_event_record_input_type; +pub mod visual_novel_runtime_event_snapshot_type; pub mod visual_novel_runtime_event_table; pub mod visual_novel_runtime_event_type; pub mod visual_novel_runtime_history_append_input_type; pub mod visual_novel_runtime_history_entry_row_type; +pub mod visual_novel_runtime_history_entry_snapshot_type; pub mod visual_novel_runtime_history_entry_table; pub mod visual_novel_runtime_history_list_input_type; pub mod visual_novel_runtime_run_row_type; @@ -857,6 +925,7 @@ pub mod visual_novel_work_procedure_result_type; pub mod visual_novel_work_profile_row_type; pub mod visual_novel_work_profile_table; pub mod visual_novel_work_publish_input_type; +pub mod visual_novel_work_snapshot_type; pub mod visual_novel_work_update_input_type; pub mod visual_novel_works_list_input_type; pub mod visual_novel_works_procedure_result_type; @@ -950,6 +1019,7 @@ pub use auth_store_snapshot_type::AuthStoreSnapshot; pub use auth_store_snapshot_upsert_input_type::AuthStoreSnapshotUpsertInput; pub use authorize_database_migration_operator_procedure::authorize_database_migration_operator; pub use bark_battle_draft_config_row_type::BarkBattleDraftConfigRow; +pub use bark_battle_draft_config_snapshot_type::BarkBattleDraftConfigSnapshot; pub use bark_battle_draft_config_table::*; pub use bark_battle_draft_config_upsert_input_type::BarkBattleDraftConfigUpsertInput; pub use bark_battle_draft_create_input_type::BarkBattleDraftCreateInput; @@ -962,8 +1032,10 @@ pub use bark_battle_published_config_row_type::BarkBattlePublishedConfigRow; pub use bark_battle_published_config_table::*; pub use bark_battle_run_finish_input_type::BarkBattleRunFinishInput; pub use bark_battle_run_get_input_type::BarkBattleRunGetInput; +pub use bark_battle_run_snapshot_type::BarkBattleRunSnapshot; pub use bark_battle_run_start_input_type::BarkBattleRunStartInput; pub use bark_battle_runtime_config_get_input_type::BarkBattleRuntimeConfigGetInput; +pub use bark_battle_runtime_config_snapshot_type::BarkBattleRuntimeConfigSnapshot; pub use bark_battle_runtime_run_row_type::BarkBattleRuntimeRunRow; pub use bark_battle_runtime_run_table::*; pub use bark_battle_score_record_row_type::BarkBattleScoreRecordRow; @@ -1004,6 +1076,7 @@ pub use big_fish_draft_compile_input_type::BigFishDraftCompileInput; pub use big_fish_event_kind_type::BigFishEventKind; pub use big_fish_event_table::*; pub use big_fish_event_type::BigFishEvent; +pub use big_fish_gallery_view_table::*; pub use big_fish_game_draft_type::BigFishGameDraft; pub use big_fish_input_submit_input_type::BigFishInputSubmitInput; pub use big_fish_level_blueprint_type::BigFishLevelBlueprint; @@ -1015,16 +1088,20 @@ pub use big_fish_run_get_input_type::BigFishRunGetInput; pub use big_fish_run_procedure_result_type::BigFishRunProcedureResult; pub use big_fish_run_start_input_type::BigFishRunStartInput; pub use big_fish_run_status_type::BigFishRunStatus; +pub use big_fish_runtime_entity_snapshot_type::BigFishRuntimeEntitySnapshot; pub use big_fish_runtime_params_type::BigFishRuntimeParams; pub use big_fish_runtime_run_table::*; pub use big_fish_runtime_run_type::BigFishRuntimeRun; +pub use big_fish_runtime_snapshot_type::BigFishRuntimeSnapshot; pub use big_fish_session_create_input_type::BigFishSessionCreateInput; pub use big_fish_session_get_input_type::BigFishSessionGetInput; pub use big_fish_session_procedure_result_type::BigFishSessionProcedureResult; pub use big_fish_session_snapshot_type::BigFishSessionSnapshot; +pub use big_fish_vector_2_type::BigFishVector2; pub use big_fish_work_delete_input_type::BigFishWorkDeleteInput; pub use big_fish_work_like_record_input_type::BigFishWorkLikeRecordInput; pub use big_fish_work_remix_input_type::BigFishWorkRemixInput; +pub use big_fish_work_summary_snapshot_type::BigFishWorkSummarySnapshot; pub use big_fish_works_list_input_type::BigFishWorksListInput; pub use big_fish_works_procedure_result_type::BigFishWorksProcedureResult; pub use bind_asset_object_to_entity_and_return_procedure::bind_asset_object_to_entity_and_return; @@ -1257,30 +1334,40 @@ pub use list_visual_novel_works_procedure::list_visual_novel_works; pub use mark_profile_recharge_order_paid_and_return_procedure::mark_profile_recharge_order_paid_and_return; pub use match_3_d_agent_message_finalize_input_type::Match3DAgentMessageFinalizeInput; pub use match_3_d_agent_message_row_type::Match3DAgentMessageRow; +pub use match_3_d_agent_message_snapshot_type::Match3DAgentMessageSnapshot; pub use match_3_d_agent_message_submit_input_type::Match3DAgentMessageSubmitInput; pub use match_3_d_agent_message_table::*; pub use match_3_d_agent_session_create_input_type::Match3DAgentSessionCreateInput; pub use match_3_d_agent_session_get_input_type::Match3DAgentSessionGetInput; pub use match_3_d_agent_session_procedure_result_type::Match3DAgentSessionProcedureResult; pub use match_3_d_agent_session_row_type::Match3DAgentSessionRow; +pub use match_3_d_agent_session_snapshot_type::Match3DAgentSessionSnapshot; pub use match_3_d_agent_session_table::*; pub use match_3_d_click_item_procedure_result_type::Match3DClickItemProcedureResult; +pub use match_3_d_creator_config_snapshot_type::Match3DCreatorConfigSnapshot; pub use match_3_d_draft_compile_input_type::Match3DDraftCompileInput; +pub use match_3_d_draft_snapshot_type::Match3DDraftSnapshot; +pub use match_3_d_gallery_view_row_type::Match3DGalleryViewRow; +pub use match_3_d_gallery_view_table::*; +pub use match_3_d_item_snapshot_type::Match3DItemSnapshot; pub use match_3_d_run_click_input_type::Match3DRunClickInput; pub use match_3_d_run_get_input_type::Match3DRunGetInput; pub use match_3_d_run_procedure_result_type::Match3DRunProcedureResult; pub use match_3_d_run_restart_input_type::Match3DRunRestartInput; +pub use match_3_d_run_snapshot_type::Match3DRunSnapshot; pub use match_3_d_run_start_input_type::Match3DRunStartInput; pub use match_3_d_run_stop_input_type::Match3DRunStopInput; pub use match_3_d_run_time_up_input_type::Match3DRunTimeUpInput; pub use match_3_d_runtime_run_row_type::Match3DRuntimeRunRow; pub use match_3_d_runtime_run_table::*; +pub use match_3_d_tray_slot_snapshot_type::Match3DTraySlotSnapshot; pub use match_3_d_work_delete_input_type::Match3DWorkDeleteInput; pub use match_3_d_work_get_input_type::Match3DWorkGetInput; pub use match_3_d_work_procedure_result_type::Match3DWorkProcedureResult; pub use match_3_d_work_profile_row_type::Match3DWorkProfileRow; pub use match_3_d_work_profile_table::*; pub use match_3_d_work_publish_input_type::Match3DWorkPublishInput; +pub use match_3_d_work_snapshot_type::Match3DWorkSnapshot; pub use match_3_d_work_update_input_type::Match3DWorkUpdateInput; pub use match_3_d_works_list_input_type::Match3DWorksListInput; pub use match_3_d_works_procedure_result_type::Match3DWorksProcedureResult; @@ -1354,33 +1441,60 @@ pub use puzzle_agent_message_finalize_input_type::PuzzleAgentMessageFinalizeInpu pub use puzzle_agent_message_kind_type::PuzzleAgentMessageKind; pub use puzzle_agent_message_role_type::PuzzleAgentMessageRole; pub use puzzle_agent_message_row_type::PuzzleAgentMessageRow; +pub use puzzle_agent_message_snapshot_type::PuzzleAgentMessageSnapshot; pub use puzzle_agent_message_submit_input_type::PuzzleAgentMessageSubmitInput; pub use puzzle_agent_message_table::*; pub use puzzle_agent_session_create_input_type::PuzzleAgentSessionCreateInput; pub use puzzle_agent_session_get_input_type::PuzzleAgentSessionGetInput; pub use puzzle_agent_session_procedure_result_type::PuzzleAgentSessionProcedureResult; pub use puzzle_agent_session_row_type::PuzzleAgentSessionRow; +pub use puzzle_agent_session_snapshot_type::PuzzleAgentSessionSnapshot; pub use puzzle_agent_session_table::*; pub use puzzle_agent_stage_type::PuzzleAgentStage; +pub use puzzle_agent_suggested_action_type::PuzzleAgentSuggestedAction; +pub use puzzle_anchor_item_type::PuzzleAnchorItem; +pub use puzzle_anchor_pack_type::PuzzleAnchorPack; +pub use puzzle_anchor_status_type::PuzzleAnchorStatus; +pub use puzzle_audio_asset_type::PuzzleAudioAsset; +pub use puzzle_board_snapshot_type::PuzzleBoardSnapshot; +pub use puzzle_cell_position_type::PuzzleCellPosition; +pub use puzzle_creator_intent_type::PuzzleCreatorIntent; pub use puzzle_draft_compile_input_type::PuzzleDraftCompileInput; +pub use puzzle_draft_level_type::PuzzleDraftLevel; pub use puzzle_event_kind_type::PuzzleEventKind; pub use puzzle_event_table::*; pub use puzzle_event_type::PuzzleEvent; pub use puzzle_form_draft_save_input_type::PuzzleFormDraftSaveInput; +pub use puzzle_form_draft_type::PuzzleFormDraft; +pub use puzzle_gallery_card_view_row_type::PuzzleGalleryCardViewRow; +pub use puzzle_gallery_card_view_table::*; +pub use puzzle_gallery_view_table::*; +pub use puzzle_generated_image_candidate_type::PuzzleGeneratedImageCandidate; pub use puzzle_generated_images_save_input_type::PuzzleGeneratedImagesSaveInput; pub use puzzle_leaderboard_entry_row_type::PuzzleLeaderboardEntryRow; pub use puzzle_leaderboard_entry_table::*; +pub use puzzle_leaderboard_entry_type::PuzzleLeaderboardEntry; pub use puzzle_leaderboard_submit_input_type::PuzzleLeaderboardSubmitInput; +pub use puzzle_merged_group_state_type::PuzzleMergedGroupState; +pub use puzzle_piece_state_type::PuzzlePieceState; pub use puzzle_publication_status_type::PuzzlePublicationStatus; pub use puzzle_publish_input_type::PuzzlePublishInput; +pub use puzzle_recommended_next_work_type::PuzzleRecommendedNextWork; +pub use puzzle_result_draft_type::PuzzleResultDraft; +pub use puzzle_result_preview_blocker_type::PuzzleResultPreviewBlocker; +pub use puzzle_result_preview_envelope_type::PuzzleResultPreviewEnvelope; +pub use puzzle_result_preview_finding_type::PuzzleResultPreviewFinding; pub use puzzle_run_drag_input_type::PuzzleRunDragInput; pub use puzzle_run_get_input_type::PuzzleRunGetInput; pub use puzzle_run_next_level_input_type::PuzzleRunNextLevelInput; pub use puzzle_run_pause_input_type::PuzzleRunPauseInput; pub use puzzle_run_procedure_result_type::PuzzleRunProcedureResult; pub use puzzle_run_prop_input_type::PuzzleRunPropInput; +pub use puzzle_run_snapshot_type::PuzzleRunSnapshot; pub use puzzle_run_start_input_type::PuzzleRunStartInput; pub use puzzle_run_swap_input_type::PuzzleRunSwapInput; +pub use puzzle_runtime_level_snapshot_type::PuzzleRuntimeLevelSnapshot; +pub use puzzle_runtime_level_status_type::PuzzleRuntimeLevelStatus; pub use puzzle_runtime_run_row_type::PuzzleRuntimeRunRow; pub use puzzle_runtime_run_table::*; pub use puzzle_select_cover_image_input_type::PuzzleSelectCoverImageInput; @@ -1392,6 +1506,7 @@ pub use puzzle_work_point_incentive_claim_input_type::PuzzleWorkPointIncentiveCl pub use puzzle_work_procedure_result_type::PuzzleWorkProcedureResult; pub use puzzle_work_profile_row_type::PuzzleWorkProfileRow; pub use puzzle_work_profile_table::*; +pub use puzzle_work_profile_type::PuzzleWorkProfile; pub use puzzle_work_remix_input_type::PuzzleWorkRemixInput; pub use puzzle_work_upsert_input_type::PuzzleWorkUpsertInput; pub use puzzle_works_list_input_type::PuzzleWorksListInput; @@ -1583,30 +1698,43 @@ pub use seed_analytics_date_dimensions_reducer::seed_analytics_date_dimensions; pub use select_puzzle_cover_image_procedure::select_puzzle_cover_image; pub use square_hole_agent_message_finalize_input_type::SquareHoleAgentMessageFinalizeInput; pub use square_hole_agent_message_row_type::SquareHoleAgentMessageRow; +pub use square_hole_agent_message_snapshot_type::SquareHoleAgentMessageSnapshot; pub use square_hole_agent_message_submit_input_type::SquareHoleAgentMessageSubmitInput; pub use square_hole_agent_message_table::*; pub use square_hole_agent_session_create_input_type::SquareHoleAgentSessionCreateInput; pub use square_hole_agent_session_get_input_type::SquareHoleAgentSessionGetInput; pub use square_hole_agent_session_procedure_result_type::SquareHoleAgentSessionProcedureResult; pub use square_hole_agent_session_row_type::SquareHoleAgentSessionRow; +pub use square_hole_agent_session_snapshot_type::SquareHoleAgentSessionSnapshot; pub use square_hole_agent_session_table::*; +pub use square_hole_creator_config_snapshot_type::SquareHoleCreatorConfigSnapshot; pub use square_hole_draft_compile_input_type::SquareHoleDraftCompileInput; +pub use square_hole_draft_snapshot_type::SquareHoleDraftSnapshot; +pub use square_hole_drop_feedback_snapshot_type::SquareHoleDropFeedbackSnapshot; pub use square_hole_drop_shape_procedure_result_type::SquareHoleDropShapeProcedureResult; +pub use square_hole_gallery_view_row_type::SquareHoleGalleryViewRow; +pub use square_hole_gallery_view_table::*; +pub use square_hole_hole_option_snapshot_type::SquareHoleHoleOptionSnapshot; +pub use square_hole_hole_snapshot_type::SquareHoleHoleSnapshot; pub use square_hole_run_drop_input_type::SquareHoleRunDropInput; pub use square_hole_run_get_input_type::SquareHoleRunGetInput; pub use square_hole_run_procedure_result_type::SquareHoleRunProcedureResult; pub use square_hole_run_restart_input_type::SquareHoleRunRestartInput; +pub use square_hole_run_snapshot_type::SquareHoleRunSnapshot; pub use square_hole_run_start_input_type::SquareHoleRunStartInput; pub use square_hole_run_stop_input_type::SquareHoleRunStopInput; pub use square_hole_run_time_up_input_type::SquareHoleRunTimeUpInput; pub use square_hole_runtime_run_row_type::SquareHoleRuntimeRunRow; pub use square_hole_runtime_run_table::*; +pub use square_hole_shape_option_snapshot_type::SquareHoleShapeOptionSnapshot; +pub use square_hole_shape_snapshot_type::SquareHoleShapeSnapshot; pub use square_hole_work_delete_input_type::SquareHoleWorkDeleteInput; pub use square_hole_work_get_input_type::SquareHoleWorkGetInput; pub use square_hole_work_procedure_result_type::SquareHoleWorkProcedureResult; pub use square_hole_work_profile_row_type::SquareHoleWorkProfileRow; pub use square_hole_work_profile_table::*; pub use square_hole_work_publish_input_type::SquareHoleWorkPublishInput; +pub use square_hole_work_snapshot_type::SquareHoleWorkSnapshot; pub use square_hole_work_update_input_type::SquareHoleWorkUpdateInput; pub use square_hole_works_list_input_type::SquareHoleWorksListInput; pub use square_hole_works_procedure_result_type::SquareHoleWorksProcedureResult; @@ -1683,24 +1811,33 @@ pub use user_browse_history_table::*; pub use user_browse_history_type::UserBrowseHistory; pub use visual_novel_agent_message_finalize_input_type::VisualNovelAgentMessageFinalizeInput; pub use visual_novel_agent_message_row_type::VisualNovelAgentMessageRow; +pub use visual_novel_agent_message_snapshot_type::VisualNovelAgentMessageSnapshot; pub use visual_novel_agent_message_submit_input_type::VisualNovelAgentMessageSubmitInput; pub use visual_novel_agent_message_table::*; pub use visual_novel_agent_session_create_input_type::VisualNovelAgentSessionCreateInput; pub use visual_novel_agent_session_get_input_type::VisualNovelAgentSessionGetInput; pub use visual_novel_agent_session_procedure_result_type::VisualNovelAgentSessionProcedureResult; pub use visual_novel_agent_session_row_type::VisualNovelAgentSessionRow; +pub use visual_novel_agent_session_snapshot_type::VisualNovelAgentSessionSnapshot; pub use visual_novel_agent_session_table::*; +pub use visual_novel_gallery_view_row_type::VisualNovelGalleryViewRow; +pub use visual_novel_gallery_view_table::*; pub use visual_novel_history_procedure_result_type::VisualNovelHistoryProcedureResult; +pub use visual_novel_json_field_type::VisualNovelJsonField; +pub use visual_novel_json_value_type::VisualNovelJsonValue; pub use visual_novel_run_get_input_type::VisualNovelRunGetInput; pub use visual_novel_run_procedure_result_type::VisualNovelRunProcedureResult; +pub use visual_novel_run_snapshot_type::VisualNovelRunSnapshot; pub use visual_novel_run_snapshot_upsert_input_type::VisualNovelRunSnapshotUpsertInput; pub use visual_novel_run_start_input_type::VisualNovelRunStartInput; pub use visual_novel_runtime_event_procedure_result_type::VisualNovelRuntimeEventProcedureResult; pub use visual_novel_runtime_event_record_input_type::VisualNovelRuntimeEventRecordInput; +pub use visual_novel_runtime_event_snapshot_type::VisualNovelRuntimeEventSnapshot; pub use visual_novel_runtime_event_table::*; pub use visual_novel_runtime_event_type::VisualNovelRuntimeEvent; pub use visual_novel_runtime_history_append_input_type::VisualNovelRuntimeHistoryAppendInput; pub use visual_novel_runtime_history_entry_row_type::VisualNovelRuntimeHistoryEntryRow; +pub use visual_novel_runtime_history_entry_snapshot_type::VisualNovelRuntimeHistoryEntrySnapshot; pub use visual_novel_runtime_history_entry_table::*; pub use visual_novel_runtime_history_list_input_type::VisualNovelRuntimeHistoryListInput; pub use visual_novel_runtime_run_row_type::VisualNovelRuntimeRunRow; @@ -1712,6 +1849,7 @@ pub use visual_novel_work_procedure_result_type::VisualNovelWorkProcedureResult; pub use visual_novel_work_profile_row_type::VisualNovelWorkProfileRow; pub use visual_novel_work_profile_table::*; pub use visual_novel_work_publish_input_type::VisualNovelWorkPublishInput; +pub use visual_novel_work_snapshot_type::VisualNovelWorkSnapshot; pub use visual_novel_work_update_input_type::VisualNovelWorkUpdateInput; pub use visual_novel_works_list_input_type::VisualNovelWorksListInput; pub use visual_novel_works_procedure_result_type::VisualNovelWorksProcedureResult; @@ -2012,6 +2150,7 @@ pub struct DbUpdate { big_fish_asset_slot: __sdk::TableUpdate, big_fish_creation_session: __sdk::TableUpdate, big_fish_event: __sdk::TableUpdate, + big_fish_gallery_view: __sdk::TableUpdate, big_fish_runtime_run: __sdk::TableUpdate, chapter_progression: __sdk::TableUpdate, creation_entry_config: __sdk::TableUpdate, @@ -2028,6 +2167,7 @@ pub struct DbUpdate { inventory_slot: __sdk::TableUpdate, match_3_d_agent_message: __sdk::TableUpdate, match_3_d_agent_session: __sdk::TableUpdate, + match_3_d_gallery_view: __sdk::TableUpdate, match_3_d_runtime_run: __sdk::TableUpdate, match_3_d_work_profile: __sdk::TableUpdate, npc_state: __sdk::TableUpdate, @@ -2052,6 +2192,8 @@ pub struct DbUpdate { puzzle_agent_message: __sdk::TableUpdate, puzzle_agent_session: __sdk::TableUpdate, puzzle_event: __sdk::TableUpdate, + puzzle_gallery_card_view: __sdk::TableUpdate, + puzzle_gallery_view: __sdk::TableUpdate, puzzle_leaderboard_entry: __sdk::TableUpdate, puzzle_runtime_run: __sdk::TableUpdate, puzzle_work_profile: __sdk::TableUpdate, @@ -2062,6 +2204,7 @@ pub struct DbUpdate { runtime_snapshot: __sdk::TableUpdate, square_hole_agent_message: __sdk::TableUpdate, square_hole_agent_session: __sdk::TableUpdate, + square_hole_gallery_view: __sdk::TableUpdate, square_hole_runtime_run: __sdk::TableUpdate, square_hole_work_profile: __sdk::TableUpdate, story_event: __sdk::TableUpdate, @@ -2073,6 +2216,7 @@ pub struct DbUpdate { user_browse_history: __sdk::TableUpdate, visual_novel_agent_message: __sdk::TableUpdate, visual_novel_agent_session: __sdk::TableUpdate, + visual_novel_gallery_view: __sdk::TableUpdate, visual_novel_runtime_event: __sdk::TableUpdate, visual_novel_runtime_history_entry: __sdk::TableUpdate, visual_novel_runtime_run: __sdk::TableUpdate, @@ -2163,6 +2307,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "big_fish_event" => db_update .big_fish_event .append(big_fish_event_table::parse_table_update(table_update)?), + "big_fish_gallery_view" => db_update.big_fish_gallery_view.append( + big_fish_gallery_view_table::parse_table_update(table_update)?, + ), "big_fish_runtime_run" => db_update.big_fish_runtime_run.append( big_fish_runtime_run_table::parse_table_update(table_update)?, ), @@ -2213,6 +2360,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "match_3_d_agent_session" => db_update.match_3_d_agent_session.append( match_3_d_agent_session_table::parse_table_update(table_update)?, ), + "match_3_d_gallery_view" => db_update.match_3_d_gallery_view.append( + match_3_d_gallery_view_table::parse_table_update(table_update)?, + ), "match_3_d_runtime_run" => db_update.match_3_d_runtime_run.append( match_3_d_runtime_run_table::parse_table_update(table_update)?, ), @@ -2287,6 +2437,12 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "puzzle_event" => db_update .puzzle_event .append(puzzle_event_table::parse_table_update(table_update)?), + "puzzle_gallery_card_view" => db_update.puzzle_gallery_card_view.append( + puzzle_gallery_card_view_table::parse_table_update(table_update)?, + ), + "puzzle_gallery_view" => db_update + .puzzle_gallery_view + .append(puzzle_gallery_view_table::parse_table_update(table_update)?), "puzzle_leaderboard_entry" => db_update.puzzle_leaderboard_entry.append( puzzle_leaderboard_entry_table::parse_table_update(table_update)?, ), @@ -2317,6 +2473,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "square_hole_agent_session" => db_update.square_hole_agent_session.append( square_hole_agent_session_table::parse_table_update(table_update)?, ), + "square_hole_gallery_view" => db_update.square_hole_gallery_view.append( + square_hole_gallery_view_table::parse_table_update(table_update)?, + ), "square_hole_runtime_run" => db_update.square_hole_runtime_run.append( square_hole_runtime_run_table::parse_table_update(table_update)?, ), @@ -2350,6 +2509,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "visual_novel_agent_session" => db_update.visual_novel_agent_session.append( visual_novel_agent_session_table::parse_table_update(table_update)?, ), + "visual_novel_gallery_view" => db_update.visual_novel_gallery_view.append( + visual_novel_gallery_view_table::parse_table_update(table_update)?, + ), "visual_novel_runtime_event" => db_update.visual_novel_runtime_event.append( visual_novel_runtime_event_table::parse_table_update(table_update)?, ), @@ -2842,6 +3004,30 @@ impl __sdk::DbUpdate for DbUpdate { &self.visual_novel_work_profile, ) .with_updates_by_pk(|row| &row.profile_id); + diff.big_fish_gallery_view = cache.apply_diff_to_table::( + "big_fish_gallery_view", + &self.big_fish_gallery_view, + ); + diff.match_3_d_gallery_view = cache.apply_diff_to_table::( + "match_3_d_gallery_view", + &self.match_3_d_gallery_view, + ); + diff.puzzle_gallery_card_view = cache.apply_diff_to_table::( + "puzzle_gallery_card_view", + &self.puzzle_gallery_card_view, + ); + diff.puzzle_gallery_view = cache.apply_diff_to_table::( + "puzzle_gallery_view", + &self.puzzle_gallery_view, + ); + diff.square_hole_gallery_view = cache.apply_diff_to_table::( + "square_hole_gallery_view", + &self.square_hole_gallery_view, + ); + diff.visual_novel_gallery_view = cache.apply_diff_to_table::( + "visual_novel_gallery_view", + &self.visual_novel_gallery_view, + ); diff } @@ -2921,6 +3107,9 @@ impl __sdk::DbUpdate for DbUpdate { "big_fish_event" => db_update .big_fish_event .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "big_fish_gallery_view" => db_update + .big_fish_gallery_view + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "big_fish_runtime_run" => db_update .big_fish_runtime_run .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -2969,6 +3158,9 @@ impl __sdk::DbUpdate for DbUpdate { "match_3_d_agent_session" => db_update .match_3_d_agent_session .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "match_3_d_gallery_view" => db_update + .match_3_d_gallery_view + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "match_3_d_runtime_run" => db_update .match_3_d_runtime_run .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -3041,6 +3233,12 @@ impl __sdk::DbUpdate for DbUpdate { "puzzle_event" => db_update .puzzle_event .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "puzzle_gallery_card_view" => db_update + .puzzle_gallery_card_view + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "puzzle_gallery_view" => db_update + .puzzle_gallery_view + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "puzzle_leaderboard_entry" => db_update .puzzle_leaderboard_entry .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -3071,6 +3269,9 @@ impl __sdk::DbUpdate for DbUpdate { "square_hole_agent_session" => db_update .square_hole_agent_session .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "square_hole_gallery_view" => db_update + .square_hole_gallery_view + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "square_hole_runtime_run" => db_update .square_hole_runtime_run .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -3104,6 +3305,9 @@ impl __sdk::DbUpdate for DbUpdate { "visual_novel_agent_session" => db_update .visual_novel_agent_session .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "visual_novel_gallery_view" => db_update + .visual_novel_gallery_view + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "visual_novel_runtime_event" => db_update .visual_novel_runtime_event .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -3201,6 +3405,9 @@ impl __sdk::DbUpdate for DbUpdate { "big_fish_event" => db_update .big_fish_event .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "big_fish_gallery_view" => db_update + .big_fish_gallery_view + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "big_fish_runtime_run" => db_update .big_fish_runtime_run .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -3249,6 +3456,9 @@ impl __sdk::DbUpdate for DbUpdate { "match_3_d_agent_session" => db_update .match_3_d_agent_session .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "match_3_d_gallery_view" => db_update + .match_3_d_gallery_view + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "match_3_d_runtime_run" => db_update .match_3_d_runtime_run .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -3321,6 +3531,12 @@ impl __sdk::DbUpdate for DbUpdate { "puzzle_event" => db_update .puzzle_event .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "puzzle_gallery_card_view" => db_update + .puzzle_gallery_card_view + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "puzzle_gallery_view" => db_update + .puzzle_gallery_view + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "puzzle_leaderboard_entry" => db_update .puzzle_leaderboard_entry .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -3351,6 +3567,9 @@ impl __sdk::DbUpdate for DbUpdate { "square_hole_agent_session" => db_update .square_hole_agent_session .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "square_hole_gallery_view" => db_update + .square_hole_gallery_view + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "square_hole_runtime_run" => db_update .square_hole_runtime_run .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -3384,6 +3603,9 @@ impl __sdk::DbUpdate for DbUpdate { "visual_novel_agent_session" => db_update .visual_novel_agent_session .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "visual_novel_gallery_view" => db_update + .visual_novel_gallery_view + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "visual_novel_runtime_event" => db_update .visual_novel_runtime_event .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -3437,6 +3659,7 @@ pub struct AppliedDiff<'r> { big_fish_asset_slot: __sdk::TableAppliedDiff<'r, BigFishAssetSlot>, big_fish_creation_session: __sdk::TableAppliedDiff<'r, BigFishCreationSession>, big_fish_event: __sdk::TableAppliedDiff<'r, BigFishEvent>, + big_fish_gallery_view: __sdk::TableAppliedDiff<'r, BigFishWorkSummarySnapshot>, big_fish_runtime_run: __sdk::TableAppliedDiff<'r, BigFishRuntimeRun>, chapter_progression: __sdk::TableAppliedDiff<'r, ChapterProgression>, creation_entry_config: __sdk::TableAppliedDiff<'r, CreationEntryConfig>, @@ -3453,6 +3676,7 @@ pub struct AppliedDiff<'r> { inventory_slot: __sdk::TableAppliedDiff<'r, InventorySlot>, match_3_d_agent_message: __sdk::TableAppliedDiff<'r, Match3DAgentMessageRow>, match_3_d_agent_session: __sdk::TableAppliedDiff<'r, Match3DAgentSessionRow>, + match_3_d_gallery_view: __sdk::TableAppliedDiff<'r, Match3DGalleryViewRow>, match_3_d_runtime_run: __sdk::TableAppliedDiff<'r, Match3DRuntimeRunRow>, match_3_d_work_profile: __sdk::TableAppliedDiff<'r, Match3DWorkProfileRow>, npc_state: __sdk::TableAppliedDiff<'r, NpcState>, @@ -3477,6 +3701,8 @@ pub struct AppliedDiff<'r> { puzzle_agent_message: __sdk::TableAppliedDiff<'r, PuzzleAgentMessageRow>, puzzle_agent_session: __sdk::TableAppliedDiff<'r, PuzzleAgentSessionRow>, puzzle_event: __sdk::TableAppliedDiff<'r, PuzzleEvent>, + puzzle_gallery_card_view: __sdk::TableAppliedDiff<'r, PuzzleGalleryCardViewRow>, + puzzle_gallery_view: __sdk::TableAppliedDiff<'r, PuzzleWorkProfile>, puzzle_leaderboard_entry: __sdk::TableAppliedDiff<'r, PuzzleLeaderboardEntryRow>, puzzle_runtime_run: __sdk::TableAppliedDiff<'r, PuzzleRuntimeRunRow>, puzzle_work_profile: __sdk::TableAppliedDiff<'r, PuzzleWorkProfileRow>, @@ -3487,6 +3713,7 @@ pub struct AppliedDiff<'r> { runtime_snapshot: __sdk::TableAppliedDiff<'r, RuntimeSnapshotRow>, square_hole_agent_message: __sdk::TableAppliedDiff<'r, SquareHoleAgentMessageRow>, square_hole_agent_session: __sdk::TableAppliedDiff<'r, SquareHoleAgentSessionRow>, + square_hole_gallery_view: __sdk::TableAppliedDiff<'r, SquareHoleGalleryViewRow>, square_hole_runtime_run: __sdk::TableAppliedDiff<'r, SquareHoleRuntimeRunRow>, square_hole_work_profile: __sdk::TableAppliedDiff<'r, SquareHoleWorkProfileRow>, story_event: __sdk::TableAppliedDiff<'r, StoryEvent>, @@ -3498,6 +3725,7 @@ pub struct AppliedDiff<'r> { user_browse_history: __sdk::TableAppliedDiff<'r, UserBrowseHistory>, visual_novel_agent_message: __sdk::TableAppliedDiff<'r, VisualNovelAgentMessageRow>, visual_novel_agent_session: __sdk::TableAppliedDiff<'r, VisualNovelAgentSessionRow>, + visual_novel_gallery_view: __sdk::TableAppliedDiff<'r, VisualNovelGalleryViewRow>, visual_novel_runtime_event: __sdk::TableAppliedDiff<'r, VisualNovelRuntimeEvent>, visual_novel_runtime_history_entry: __sdk::TableAppliedDiff<'r, VisualNovelRuntimeHistoryEntryRow>, @@ -3628,6 +3856,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.big_fish_event, event, ); + callbacks.invoke_table_row_callbacks::( + "big_fish_gallery_view", + &self.big_fish_gallery_view, + event, + ); callbacks.invoke_table_row_callbacks::( "big_fish_runtime_run", &self.big_fish_runtime_run, @@ -3708,6 +3941,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.match_3_d_agent_session, event, ); + callbacks.invoke_table_row_callbacks::( + "match_3_d_gallery_view", + &self.match_3_d_gallery_view, + event, + ); callbacks.invoke_table_row_callbacks::( "match_3_d_runtime_run", &self.match_3_d_runtime_run, @@ -3824,6 +4062,16 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.puzzle_event, event, ); + callbacks.invoke_table_row_callbacks::( + "puzzle_gallery_card_view", + &self.puzzle_gallery_card_view, + event, + ); + callbacks.invoke_table_row_callbacks::( + "puzzle_gallery_view", + &self.puzzle_gallery_view, + event, + ); callbacks.invoke_table_row_callbacks::( "puzzle_leaderboard_entry", &self.puzzle_leaderboard_entry, @@ -3870,6 +4118,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.square_hole_agent_session, event, ); + callbacks.invoke_table_row_callbacks::( + "square_hole_gallery_view", + &self.square_hole_gallery_view, + event, + ); callbacks.invoke_table_row_callbacks::( "square_hole_runtime_run", &self.square_hole_runtime_run, @@ -3921,6 +4174,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.visual_novel_agent_session, event, ); + callbacks.invoke_table_row_callbacks::( + "visual_novel_gallery_view", + &self.visual_novel_gallery_view, + event, + ); callbacks.invoke_table_row_callbacks::( "visual_novel_runtime_event", &self.visual_novel_runtime_event, @@ -4625,6 +4883,7 @@ impl __sdk::SpacetimeModule for RemoteModule { big_fish_asset_slot_table::register_table(client_cache); big_fish_creation_session_table::register_table(client_cache); big_fish_event_table::register_table(client_cache); + big_fish_gallery_view_table::register_table(client_cache); big_fish_runtime_run_table::register_table(client_cache); chapter_progression_table::register_table(client_cache); creation_entry_config_table::register_table(client_cache); @@ -4641,6 +4900,7 @@ impl __sdk::SpacetimeModule for RemoteModule { inventory_slot_table::register_table(client_cache); match_3_d_agent_message_table::register_table(client_cache); match_3_d_agent_session_table::register_table(client_cache); + match_3_d_gallery_view_table::register_table(client_cache); match_3_d_runtime_run_table::register_table(client_cache); match_3_d_work_profile_table::register_table(client_cache); npc_state_table::register_table(client_cache); @@ -4665,6 +4925,8 @@ impl __sdk::SpacetimeModule for RemoteModule { puzzle_agent_message_table::register_table(client_cache); puzzle_agent_session_table::register_table(client_cache); puzzle_event_table::register_table(client_cache); + puzzle_gallery_card_view_table::register_table(client_cache); + puzzle_gallery_view_table::register_table(client_cache); puzzle_leaderboard_entry_table::register_table(client_cache); puzzle_runtime_run_table::register_table(client_cache); puzzle_work_profile_table::register_table(client_cache); @@ -4675,6 +4937,7 @@ impl __sdk::SpacetimeModule for RemoteModule { runtime_snapshot_table::register_table(client_cache); square_hole_agent_message_table::register_table(client_cache); square_hole_agent_session_table::register_table(client_cache); + square_hole_gallery_view_table::register_table(client_cache); square_hole_runtime_run_table::register_table(client_cache); square_hole_work_profile_table::register_table(client_cache); story_event_table::register_table(client_cache); @@ -4686,6 +4949,7 @@ impl __sdk::SpacetimeModule for RemoteModule { user_browse_history_table::register_table(client_cache); visual_novel_agent_message_table::register_table(client_cache); visual_novel_agent_session_table::register_table(client_cache); + visual_novel_gallery_view_table::register_table(client_cache); visual_novel_runtime_event_table::register_table(client_cache); visual_novel_runtime_history_entry_table::register_table(client_cache); visual_novel_runtime_run_table::register_table(client_cache); @@ -4716,6 +4980,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "big_fish_asset_slot", "big_fish_creation_session", "big_fish_event", + "big_fish_gallery_view", "big_fish_runtime_run", "chapter_progression", "creation_entry_config", @@ -4732,6 +4997,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "inventory_slot", "match_3_d_agent_message", "match_3_d_agent_session", + "match_3_d_gallery_view", "match_3_d_runtime_run", "match_3_d_work_profile", "npc_state", @@ -4756,6 +5022,8 @@ impl __sdk::SpacetimeModule for RemoteModule { "puzzle_agent_message", "puzzle_agent_session", "puzzle_event", + "puzzle_gallery_card_view", + "puzzle_gallery_view", "puzzle_leaderboard_entry", "puzzle_runtime_run", "puzzle_work_profile", @@ -4766,6 +5034,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "runtime_snapshot", "square_hole_agent_message", "square_hole_agent_session", + "square_hole_gallery_view", "square_hole_runtime_run", "square_hole_work_profile", "story_event", @@ -4777,6 +5046,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "user_browse_history", "visual_novel_agent_message", "visual_novel_agent_session", + "visual_novel_gallery_view", "visual_novel_runtime_event", "visual_novel_runtime_history_entry", "visual_novel_runtime_run", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_draft_config_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_draft_config_snapshot_type.rs new file mode 100644 index 00000000..1271082f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_draft_config_snapshot_type.rs @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct BarkBattleDraftConfigSnapshot { + pub draft_id: String, + pub owner_user_id: String, + pub work_id: String, + pub config_version: u64, + pub ruleset_version: String, + pub difficulty_preset: String, + pub leaderboard_enabled: bool, + pub config_json: String, + pub editor_state_json: String, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for BarkBattleDraftConfigSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_procedure_result_type.rs index 6fe7a3ee..03e6fe2c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_procedure_result_type.rs @@ -4,11 +4,17 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::bark_battle_draft_config_snapshot_type::BarkBattleDraftConfigSnapshot; +use super::bark_battle_run_snapshot_type::BarkBattleRunSnapshot; +use super::bark_battle_runtime_config_snapshot_type::BarkBattleRuntimeConfigSnapshot; + #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct BarkBattleProcedureResult { pub ok: bool, - pub row_json: Option, + pub draft_config: Option, + pub runtime_config: Option, + pub run: Option, pub error_message: Option, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_run_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_run_snapshot_type.rs new file mode 100644 index 00000000..474af775 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_run_snapshot_type.rs @@ -0,0 +1,32 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct BarkBattleRunSnapshot { + pub run_id: String, + pub owner_user_id: String, + pub work_id: String, + pub config_version: u64, + pub ruleset_version: String, + pub difficulty_preset: String, + pub leaderboard_enabled: bool, + pub status: String, + pub client_started_at_micros: i64, + pub server_started_at_micros: i64, + pub client_finished_at_micros: Option, + pub server_finished_at_micros: Option, + pub metrics_json: String, + pub server_result: Option, + pub validation_status: String, + pub anti_cheat_flags_json: String, + pub leaderboard_score: Option, + pub score_id: Option, +} + +impl __sdk::InModule for BarkBattleRunSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_runtime_config_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_runtime_config_snapshot_type.rs new file mode 100644 index 00000000..e176ca63 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_runtime_config_snapshot_type.rs @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct BarkBattleRuntimeConfigSnapshot { + pub work_id: String, + pub owner_user_id: String, + pub source_draft_id: Option, + pub config_version: u64, + pub ruleset_version: String, + pub difficulty_preset: String, + pub leaderboard_enabled: bool, + pub config_json: String, + pub published_snapshot_json: String, + pub published_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for BarkBattleRuntimeConfigSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_creation_session_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_creation_session_type.rs index 572889b8..d87690de 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_creation_session_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_creation_session_type.rs @@ -92,6 +92,7 @@ impl __sdk::__query_builder::HasCols for BigFishCreationSession { pub struct BigFishCreationSessionIxCols { pub owner_user_id: __sdk::__query_builder::IxCol, pub session_id: __sdk::__query_builder::IxCol, + pub stage: __sdk::__query_builder::IxCol, } impl __sdk::__query_builder::HasIxCols for BigFishCreationSession { @@ -100,6 +101,7 @@ impl __sdk::__query_builder::HasIxCols for BigFishCreationSession { BigFishCreationSessionIxCols { owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), session_id: __sdk::__query_builder::IxCol::new(table_name, "session_id"), + stage: __sdk::__query_builder::IxCol::new(table_name, "stage"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_gallery_view_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_gallery_view_table.rs new file mode 100644 index 00000000..2d9419c4 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_gallery_view_table.rs @@ -0,0 +1,114 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::big_fish_work_summary_snapshot_type::BigFishWorkSummarySnapshot; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `big_fish_gallery_view`. +/// +/// Obtain a handle from the [`BigFishGalleryViewTableAccess::big_fish_gallery_view`] method on [`super::RemoteTables`], +/// like `ctx.db.big_fish_gallery_view()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.big_fish_gallery_view().on_insert(...)`. +pub struct BigFishGalleryViewTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `big_fish_gallery_view`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait BigFishGalleryViewTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`BigFishGalleryViewTableHandle`], which mediates access to the table `big_fish_gallery_view`. + fn big_fish_gallery_view(&self) -> BigFishGalleryViewTableHandle<'_>; +} + +impl BigFishGalleryViewTableAccess for super::RemoteTables { + fn big_fish_gallery_view(&self) -> BigFishGalleryViewTableHandle<'_> { + BigFishGalleryViewTableHandle { + imp: self + .imp + .get_table::("big_fish_gallery_view"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct BigFishGalleryViewInsertCallbackId(__sdk::CallbackId); +pub struct BigFishGalleryViewDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for BigFishGalleryViewTableHandle<'ctx> { + type Row = BigFishWorkSummarySnapshot; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = BigFishGalleryViewInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> BigFishGalleryViewInsertCallbackId { + BigFishGalleryViewInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: BigFishGalleryViewInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = BigFishGalleryViewDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> BigFishGalleryViewDeleteCallbackId { + BigFishGalleryViewDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: BigFishGalleryViewDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("big_fish_gallery_view"); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `BigFishWorkSummarySnapshot`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait big_fish_gallery_viewQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `BigFishWorkSummarySnapshot`. + fn big_fish_gallery_view(&self) -> __sdk::__query_builder::Table; +} + +impl big_fish_gallery_viewQueryTableAccess for __sdk::QueryTableAccessor { + fn big_fish_gallery_view(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("big_fish_gallery_view") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_run_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_run_procedure_result_type.rs index 2dc3db1d..86d73fc2 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_run_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_run_procedure_result_type.rs @@ -4,11 +4,13 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::big_fish_runtime_snapshot_type::BigFishRuntimeSnapshot; + #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct BigFishRunProcedureResult { pub ok: bool, - pub run_json: Option, + pub run: Option, pub error_message: Option, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_runtime_entity_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_runtime_entity_snapshot_type.rs new file mode 100644 index 00000000..ce829b70 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_runtime_entity_snapshot_type.rs @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::big_fish_vector_2_type::BigFishVector2; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct BigFishRuntimeEntitySnapshot { + pub entity_id: String, + pub level: u32, + pub position: BigFishVector2, + pub radius: f32, + pub offscreen_seconds: f32, +} + +impl __sdk::InModule for BigFishRuntimeEntitySnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_runtime_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_runtime_snapshot_type.rs new file mode 100644 index 00000000..48f71186 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_runtime_snapshot_type.rs @@ -0,0 +1,31 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::big_fish_run_status_type::BigFishRunStatus; +use super::big_fish_runtime_entity_snapshot_type::BigFishRuntimeEntitySnapshot; +use super::big_fish_vector_2_type::BigFishVector2; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct BigFishRuntimeSnapshot { + pub run_id: String, + pub session_id: String, + pub status: BigFishRunStatus, + 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: BigFishVector2, + pub last_input: BigFishVector2, + pub event_log: Vec, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for BigFishRuntimeSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_vector_2_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_vector_2_type.rs new file mode 100644 index 00000000..745063ad --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_vector_2_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct BigFishVector2 { + pub x: f32, + pub y: f32, +} + +impl __sdk::InModule for BigFishVector2 { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_work_summary_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_work_summary_snapshot_type.rs new file mode 100644 index 00000000..9bdeeedb --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_work_summary_snapshot_type.rs @@ -0,0 +1,97 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct BigFishWorkSummarySnapshot { + 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 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_7_d: u32, + pub published_at_micros: Option, +} + +impl __sdk::InModule for BigFishWorkSummarySnapshot { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `BigFishWorkSummarySnapshot`. +/// +/// Provides typed access to columns for query building. +pub struct BigFishWorkSummarySnapshotCols { + pub work_id: __sdk::__query_builder::Col, + pub source_session_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub title: __sdk::__query_builder::Col, + pub subtitle: __sdk::__query_builder::Col, + pub summary: __sdk::__query_builder::Col, + pub cover_image_src: __sdk::__query_builder::Col>, + pub status: __sdk::__query_builder::Col, + pub updated_at_micros: __sdk::__query_builder::Col, + pub publish_ready: __sdk::__query_builder::Col, + pub level_count: __sdk::__query_builder::Col, + pub level_main_image_ready_count: __sdk::__query_builder::Col, + pub level_motion_ready_count: __sdk::__query_builder::Col, + pub background_ready: __sdk::__query_builder::Col, + pub play_count: __sdk::__query_builder::Col, + pub remix_count: __sdk::__query_builder::Col, + pub like_count: __sdk::__query_builder::Col, + pub recent_play_count_7_d: __sdk::__query_builder::Col, + pub published_at_micros: __sdk::__query_builder::Col>, +} + +impl __sdk::__query_builder::HasCols for BigFishWorkSummarySnapshot { + type Cols = BigFishWorkSummarySnapshotCols; + fn cols(table_name: &'static str) -> Self::Cols { + BigFishWorkSummarySnapshotCols { + work_id: __sdk::__query_builder::Col::new(table_name, "work_id"), + source_session_id: __sdk::__query_builder::Col::new(table_name, "source_session_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + title: __sdk::__query_builder::Col::new(table_name, "title"), + subtitle: __sdk::__query_builder::Col::new(table_name, "subtitle"), + summary: __sdk::__query_builder::Col::new(table_name, "summary"), + cover_image_src: __sdk::__query_builder::Col::new(table_name, "cover_image_src"), + status: __sdk::__query_builder::Col::new(table_name, "status"), + updated_at_micros: __sdk::__query_builder::Col::new(table_name, "updated_at_micros"), + publish_ready: __sdk::__query_builder::Col::new(table_name, "publish_ready"), + level_count: __sdk::__query_builder::Col::new(table_name, "level_count"), + level_main_image_ready_count: __sdk::__query_builder::Col::new( + table_name, + "level_main_image_ready_count", + ), + level_motion_ready_count: __sdk::__query_builder::Col::new( + table_name, + "level_motion_ready_count", + ), + background_ready: __sdk::__query_builder::Col::new(table_name, "background_ready"), + play_count: __sdk::__query_builder::Col::new(table_name, "play_count"), + remix_count: __sdk::__query_builder::Col::new(table_name, "remix_count"), + like_count: __sdk::__query_builder::Col::new(table_name, "like_count"), + recent_play_count_7_d: __sdk::__query_builder::Col::new( + table_name, + "recent_play_count_7_d", + ), + published_at_micros: __sdk::__query_builder::Col::new( + table_name, + "published_at_micros", + ), + } + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_works_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_works_procedure_result_type.rs index 37d7c7b6..ea3cac68 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_works_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_works_procedure_result_type.rs @@ -4,11 +4,13 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::big_fish_work_summary_snapshot_type::BigFishWorkSummarySnapshot; + #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct BigFishWorksProcedureResult { pub ok: bool, - pub items_json: Option, + pub items: Vec, pub error_message: Option, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_agent_message_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_agent_message_snapshot_type.rs new file mode 100644 index 00000000..b157584f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_agent_message_snapshot_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DAgentMessageSnapshot { + pub message_id: String, + pub session_id: String, + pub role: String, + pub kind: String, + pub text: String, + pub created_at_micros: i64, +} + +impl __sdk::InModule for Match3DAgentMessageSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_agent_session_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_agent_session_procedure_result_type.rs index 45f54f93..ca860890 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_agent_session_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_agent_session_procedure_result_type.rs @@ -4,11 +4,13 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::match_3_d_agent_session_snapshot_type::Match3DAgentSessionSnapshot; + #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct Match3DAgentSessionProcedureResult { pub ok: bool, - pub session_json: Option, + pub session: Option, pub error_message: Option, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_agent_session_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_agent_session_snapshot_type.rs new file mode 100644 index 00000000..f0ea685a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_agent_session_snapshot_type.rs @@ -0,0 +1,31 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::match_3_d_agent_message_snapshot_type::Match3DAgentMessageSnapshot; +use super::match_3_d_creator_config_snapshot_type::Match3DCreatorConfigSnapshot; +use super::match_3_d_draft_snapshot_type::Match3DDraftSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DAgentSessionSnapshot { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub current_turn: u32, + pub progress_percent: u32, + pub stage: String, + pub config: Match3DCreatorConfigSnapshot, + pub draft: Option, + pub messages: Vec, + pub last_assistant_reply: String, + pub published_profile_id: Option, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for Match3DAgentSessionSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_click_item_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_click_item_procedure_result_type.rs index 80f32a59..c8a58510 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_click_item_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_click_item_procedure_result_type.rs @@ -4,12 +4,14 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::match_3_d_run_snapshot_type::Match3DRunSnapshot; + #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct Match3DClickItemProcedureResult { pub ok: bool, pub status: String, - pub run_json: Option, + pub run: Option, pub accepted_item_instance_id: Option, pub cleared_item_instance_ids: Vec, pub failure_reason: Option, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_creator_config_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_creator_config_snapshot_type.rs new file mode 100644 index 00000000..a0fd2d61 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_creator_config_snapshot_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DCreatorConfigSnapshot { + 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, +} + +impl __sdk::InModule for Match3DCreatorConfigSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_draft_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_draft_snapshot_type.rs new file mode 100644 index 00000000..32a67c83 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_draft_snapshot_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DDraftSnapshot { + pub profile_id: String, + pub game_name: String, + pub theme_text: String, + pub summary_text: String, + pub tags: Vec, + pub clear_count: u32, + pub difficulty: u32, + pub generated_item_assets_json: Option, +} + +impl __sdk::InModule for Match3DDraftSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_gallery_view_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_gallery_view_row_type.rs new file mode 100644 index 00000000..03b768d3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_gallery_view_row_type.rs @@ -0,0 +1,98 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DGalleryViewRow { + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: String, + pub author_display_name: String, + pub game_name: String, + pub theme_text: String, + pub summary_text: String, + pub tags: Vec, + pub cover_image_src: String, + pub cover_asset_id: String, + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, + pub publication_status: String, + pub publish_ready: bool, + pub play_count: u32, + pub updated_at_micros: i64, + pub published_at_micros: Option, + pub generated_item_assets_json: Option, +} + +impl __sdk::InModule for Match3DGalleryViewRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `Match3DGalleryViewRow`. +/// +/// Provides typed access to columns for query building. +pub struct Match3DGalleryViewRowCols { + pub profile_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub source_session_id: __sdk::__query_builder::Col, + pub author_display_name: __sdk::__query_builder::Col, + pub game_name: __sdk::__query_builder::Col, + pub theme_text: __sdk::__query_builder::Col, + pub summary_text: __sdk::__query_builder::Col, + pub tags: __sdk::__query_builder::Col>, + pub cover_image_src: __sdk::__query_builder::Col, + pub cover_asset_id: __sdk::__query_builder::Col, + pub reference_image_src: __sdk::__query_builder::Col>, + pub clear_count: __sdk::__query_builder::Col, + pub difficulty: __sdk::__query_builder::Col, + pub publication_status: __sdk::__query_builder::Col, + pub publish_ready: __sdk::__query_builder::Col, + pub play_count: __sdk::__query_builder::Col, + pub updated_at_micros: __sdk::__query_builder::Col, + pub published_at_micros: __sdk::__query_builder::Col>, + pub generated_item_assets_json: + __sdk::__query_builder::Col>, +} + +impl __sdk::__query_builder::HasCols for Match3DGalleryViewRow { + type Cols = Match3DGalleryViewRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + Match3DGalleryViewRowCols { + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + source_session_id: __sdk::__query_builder::Col::new(table_name, "source_session_id"), + author_display_name: __sdk::__query_builder::Col::new( + table_name, + "author_display_name", + ), + game_name: __sdk::__query_builder::Col::new(table_name, "game_name"), + theme_text: __sdk::__query_builder::Col::new(table_name, "theme_text"), + summary_text: __sdk::__query_builder::Col::new(table_name, "summary_text"), + tags: __sdk::__query_builder::Col::new(table_name, "tags"), + cover_image_src: __sdk::__query_builder::Col::new(table_name, "cover_image_src"), + cover_asset_id: __sdk::__query_builder::Col::new(table_name, "cover_asset_id"), + reference_image_src: __sdk::__query_builder::Col::new( + table_name, + "reference_image_src", + ), + clear_count: __sdk::__query_builder::Col::new(table_name, "clear_count"), + difficulty: __sdk::__query_builder::Col::new(table_name, "difficulty"), + publication_status: __sdk::__query_builder::Col::new(table_name, "publication_status"), + publish_ready: __sdk::__query_builder::Col::new(table_name, "publish_ready"), + play_count: __sdk::__query_builder::Col::new(table_name, "play_count"), + updated_at_micros: __sdk::__query_builder::Col::new(table_name, "updated_at_micros"), + published_at_micros: __sdk::__query_builder::Col::new( + table_name, + "published_at_micros", + ), + generated_item_assets_json: __sdk::__query_builder::Col::new( + table_name, + "generated_item_assets_json", + ), + } + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_gallery_view_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_gallery_view_table.rs new file mode 100644 index 00000000..b47d62a3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_gallery_view_table.rs @@ -0,0 +1,113 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::match_3_d_gallery_view_row_type::Match3DGalleryViewRow; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `match_3_d_gallery_view`. +/// +/// Obtain a handle from the [`Match3DGalleryViewTableAccess::match_3_d_gallery_view`] method on [`super::RemoteTables`], +/// like `ctx.db.match_3_d_gallery_view()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.match_3_d_gallery_view().on_insert(...)`. +pub struct Match3DGalleryViewTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `match_3_d_gallery_view`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait Match3DGalleryViewTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`Match3DGalleryViewTableHandle`], which mediates access to the table `match_3_d_gallery_view`. + fn match_3_d_gallery_view(&self) -> Match3DGalleryViewTableHandle<'_>; +} + +impl Match3DGalleryViewTableAccess for super::RemoteTables { + fn match_3_d_gallery_view(&self) -> Match3DGalleryViewTableHandle<'_> { + Match3DGalleryViewTableHandle { + imp: self + .imp + .get_table::("match_3_d_gallery_view"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct Match3DGalleryViewInsertCallbackId(__sdk::CallbackId); +pub struct Match3DGalleryViewDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for Match3DGalleryViewTableHandle<'ctx> { + type Row = Match3DGalleryViewRow; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = Match3DGalleryViewInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> Match3DGalleryViewInsertCallbackId { + Match3DGalleryViewInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: Match3DGalleryViewInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = Match3DGalleryViewDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> Match3DGalleryViewDeleteCallbackId { + Match3DGalleryViewDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: Match3DGalleryViewDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("match_3_d_gallery_view"); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `Match3DGalleryViewRow`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait match_3_d_gallery_viewQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `Match3DGalleryViewRow`. + fn match_3_d_gallery_view(&self) -> __sdk::__query_builder::Table; +} + +impl match_3_d_gallery_viewQueryTableAccess for __sdk::QueryTableAccessor { + fn match_3_d_gallery_view(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("match_3_d_gallery_view") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_item_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_item_snapshot_type.rs new file mode 100644 index 00000000..fefdd184 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_item_snapshot_type.rs @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DItemSnapshot { + 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, +} + +impl __sdk::InModule for Match3DItemSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_run_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_run_procedure_result_type.rs index f3c4ceec..56da83e6 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_run_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_run_procedure_result_type.rs @@ -4,11 +4,13 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::match_3_d_run_snapshot_type::Match3DRunSnapshot; + #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct Match3DRunProcedureResult { pub ok: bool, - pub run_json: Option, + pub run: Option, pub error_message: Option, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_run_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_run_snapshot_type.rs new file mode 100644 index 00000000..3165a471 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_run_snapshot_type.rs @@ -0,0 +1,31 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::match_3_d_item_snapshot_type::Match3DItemSnapshot; +use super::match_3_d_tray_slot_snapshot_type::Match3DTraySlotSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DRunSnapshot { + pub run_id: String, + pub profile_id: String, + pub status: String, + pub snapshot_version: u32, + pub started_at_ms: i64, + pub duration_limit_ms: i64, + pub server_now_ms: i64, + pub remaining_ms: i64, + pub clear_count: u32, + pub total_item_count: u32, + pub cleared_item_count: u32, + pub tray_slots: Vec, + pub items: Vec, + pub failure_reason: Option, +} + +impl __sdk::InModule for Match3DRunSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_tray_slot_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_tray_slot_snapshot_type.rs new file mode 100644 index 00000000..823cff0c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_tray_slot_snapshot_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DTraySlotSnapshot { + pub slot_index: u32, + pub item_instance_id: Option, + pub item_type_id: Option, + pub visual_key: Option, +} + +impl __sdk::InModule for Match3DTraySlotSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_work_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_work_procedure_result_type.rs index 9cb5d518..d4d589f1 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_work_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_work_procedure_result_type.rs @@ -4,11 +4,13 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::match_3_d_work_snapshot_type::Match3DWorkSnapshot; + #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct Match3DWorkProcedureResult { pub ok: bool, - pub work_json: Option, + pub work: Option, pub error_message: Option, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_work_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_work_snapshot_type.rs new file mode 100644 index 00000000..fc1a862f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_work_snapshot_type.rs @@ -0,0 +1,35 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::match_3_d_creator_config_snapshot_type::Match3DCreatorConfigSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Match3DWorkSnapshot { + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: String, + pub author_display_name: String, + pub game_name: String, + pub theme_text: String, + pub summary_text: String, + pub tags: Vec, + pub cover_image_src: String, + pub cover_asset_id: String, + pub clear_count: u32, + pub difficulty: u32, + pub config: Match3DCreatorConfigSnapshot, + pub publication_status: String, + pub publish_ready: bool, + pub play_count: u32, + pub updated_at_micros: i64, + pub published_at_micros: Option, + pub generated_item_assets_json: Option, +} + +impl __sdk::InModule for Match3DWorkSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_works_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_works_procedure_result_type.rs index f1cfd0be..0bc07ad4 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_works_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/match_3_d_works_procedure_result_type.rs @@ -4,11 +4,13 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::match_3_d_work_snapshot_type::Match3DWorkSnapshot; + #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct Match3DWorksProcedureResult { pub ok: bool, - pub items_json: Option, + pub items: Vec, pub error_message: Option, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_agent_message_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_agent_message_snapshot_type.rs new file mode 100644 index 00000000..00dbec45 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_agent_message_snapshot_type.rs @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::puzzle_agent_message_kind_type::PuzzleAgentMessageKind; +use super::puzzle_agent_message_role_type::PuzzleAgentMessageRole; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleAgentMessageSnapshot { + pub message_id: String, + pub session_id: String, + pub role: PuzzleAgentMessageRole, + pub kind: PuzzleAgentMessageKind, + pub text: String, + pub created_at_micros: i64, +} + +impl __sdk::InModule for PuzzleAgentMessageSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_agent_session_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_agent_session_procedure_result_type.rs index 39506659..00de9f76 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_agent_session_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_agent_session_procedure_result_type.rs @@ -4,11 +4,13 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::puzzle_agent_session_snapshot_type::PuzzleAgentSessionSnapshot; + #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct PuzzleAgentSessionProcedureResult { pub ok: bool, - pub session_json: Option, + pub session: Option, pub error_message: Option, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_agent_session_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_agent_session_snapshot_type.rs new file mode 100644 index 00000000..d099e6ac --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_agent_session_snapshot_type.rs @@ -0,0 +1,36 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::puzzle_agent_message_snapshot_type::PuzzleAgentMessageSnapshot; +use super::puzzle_agent_stage_type::PuzzleAgentStage; +use super::puzzle_agent_suggested_action_type::PuzzleAgentSuggestedAction; +use super::puzzle_anchor_pack_type::PuzzleAnchorPack; +use super::puzzle_result_draft_type::PuzzleResultDraft; +use super::puzzle_result_preview_envelope_type::PuzzleResultPreviewEnvelope; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleAgentSessionSnapshot { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub current_turn: u32, + pub progress_percent: u32, + pub stage: PuzzleAgentStage, + pub anchor_pack: PuzzleAnchorPack, + 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 created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for PuzzleAgentSessionSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_agent_suggested_action_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_agent_suggested_action_type.rs new file mode 100644 index 00000000..56593222 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_agent_suggested_action_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleAgentSuggestedAction { + pub id: String, + pub action_type: String, + pub label: String, +} + +impl __sdk::InModule for PuzzleAgentSuggestedAction { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_anchor_item_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_anchor_item_type.rs new file mode 100644 index 00000000..1280d719 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_anchor_item_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::puzzle_anchor_status_type::PuzzleAnchorStatus; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleAnchorItem { + pub key: String, + pub label: String, + pub value: String, + pub status: PuzzleAnchorStatus, +} + +impl __sdk::InModule for PuzzleAnchorItem { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_anchor_pack_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_anchor_pack_type.rs new file mode 100644 index 00000000..db006609 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_anchor_pack_type.rs @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::puzzle_anchor_item_type::PuzzleAnchorItem; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleAnchorPack { + pub theme_promise: PuzzleAnchorItem, + pub visual_subject: PuzzleAnchorItem, + pub visual_mood: PuzzleAnchorItem, + pub composition_hooks: PuzzleAnchorItem, + pub tags_and_forbidden: PuzzleAnchorItem, +} + +impl __sdk::InModule for PuzzleAnchorPack { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_anchor_status_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_anchor_status_type.rs new file mode 100644 index 00000000..feb7a650 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_anchor_status_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum PuzzleAnchorStatus { + Missing, + + Inferred, + + Confirmed, + + Locked, +} + +impl __sdk::InModule for PuzzleAnchorStatus { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_audio_asset_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_audio_asset_type.rs new file mode 100644 index 00000000..e430a9c9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_audio_asset_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleAudioAsset { + 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, +} + +impl __sdk::InModule for PuzzleAudioAsset { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_board_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_board_snapshot_type.rs new file mode 100644 index 00000000..2408ef0c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_board_snapshot_type.rs @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::puzzle_merged_group_state_type::PuzzleMergedGroupState; +use super::puzzle_piece_state_type::PuzzlePieceState; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleBoardSnapshot { + pub rows: u32, + pub cols: u32, + pub pieces: Vec, + pub merged_groups: Vec, + pub selected_piece_id: Option, + pub all_tiles_resolved: bool, +} + +impl __sdk::InModule for PuzzleBoardSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_cell_position_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_cell_position_type.rs new file mode 100644 index 00000000..92942799 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_cell_position_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleCellPosition { + pub row: u32, + pub col: u32, +} + +impl __sdk::InModule for PuzzleCellPosition { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_creator_intent_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_creator_intent_type.rs new file mode 100644 index 00000000..9d1cff85 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_creator_intent_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleCreatorIntent { + 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, +} + +impl __sdk::InModule for PuzzleCreatorIntent { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_draft_level_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_draft_level_type.rs new file mode 100644 index 00000000..36f12999 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_draft_level_type.rs @@ -0,0 +1,30 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::puzzle_audio_asset_type::PuzzleAudioAsset; +use super::puzzle_generated_image_candidate_type::PuzzleGeneratedImageCandidate; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleDraftLevel { + 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, +} + +impl __sdk::InModule for PuzzleDraftLevel { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_form_draft_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_form_draft_type.rs new file mode 100644 index 00000000..c949aae1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_form_draft_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleFormDraft { + pub work_title: Option, + pub work_description: Option, + pub picture_description: Option, +} + +impl __sdk::InModule for PuzzleFormDraft { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_gallery_card_view_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_gallery_card_view_row_type.rs new file mode 100644 index 00000000..3828a2c2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_gallery_card_view_row_type.rs @@ -0,0 +1,110 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::puzzle_publication_status_type::PuzzlePublicationStatus; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleGalleryCardViewRow { + 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: PuzzlePublicationStatus, + pub updated_at_micros: i64, + pub published_at_micros: Option, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, + pub point_incentive_total_half_points: u64, + pub point_incentive_claimed_points: u64, + pub publish_ready: bool, + pub generation_status: Option, +} + +impl __sdk::InModule for PuzzleGalleryCardViewRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `PuzzleGalleryCardViewRow`. +/// +/// Provides typed access to columns for query building. +pub struct PuzzleGalleryCardViewRowCols { + pub work_id: __sdk::__query_builder::Col, + pub profile_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub source_session_id: __sdk::__query_builder::Col>, + pub author_display_name: __sdk::__query_builder::Col, + pub work_title: __sdk::__query_builder::Col, + pub work_description: __sdk::__query_builder::Col, + pub level_name: __sdk::__query_builder::Col, + pub summary: __sdk::__query_builder::Col, + pub theme_tags: __sdk::__query_builder::Col>, + pub cover_image_src: __sdk::__query_builder::Col>, + pub cover_asset_id: __sdk::__query_builder::Col>, + pub publication_status: + __sdk::__query_builder::Col, + pub updated_at_micros: __sdk::__query_builder::Col, + pub published_at_micros: __sdk::__query_builder::Col>, + pub play_count: __sdk::__query_builder::Col, + pub remix_count: __sdk::__query_builder::Col, + pub like_count: __sdk::__query_builder::Col, + pub point_incentive_total_half_points: + __sdk::__query_builder::Col, + pub point_incentive_claimed_points: __sdk::__query_builder::Col, + pub publish_ready: __sdk::__query_builder::Col, + pub generation_status: __sdk::__query_builder::Col>, +} + +impl __sdk::__query_builder::HasCols for PuzzleGalleryCardViewRow { + type Cols = PuzzleGalleryCardViewRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + PuzzleGalleryCardViewRowCols { + work_id: __sdk::__query_builder::Col::new(table_name, "work_id"), + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + source_session_id: __sdk::__query_builder::Col::new(table_name, "source_session_id"), + author_display_name: __sdk::__query_builder::Col::new( + table_name, + "author_display_name", + ), + work_title: __sdk::__query_builder::Col::new(table_name, "work_title"), + work_description: __sdk::__query_builder::Col::new(table_name, "work_description"), + level_name: __sdk::__query_builder::Col::new(table_name, "level_name"), + summary: __sdk::__query_builder::Col::new(table_name, "summary"), + theme_tags: __sdk::__query_builder::Col::new(table_name, "theme_tags"), + cover_image_src: __sdk::__query_builder::Col::new(table_name, "cover_image_src"), + cover_asset_id: __sdk::__query_builder::Col::new(table_name, "cover_asset_id"), + publication_status: __sdk::__query_builder::Col::new(table_name, "publication_status"), + updated_at_micros: __sdk::__query_builder::Col::new(table_name, "updated_at_micros"), + published_at_micros: __sdk::__query_builder::Col::new( + table_name, + "published_at_micros", + ), + play_count: __sdk::__query_builder::Col::new(table_name, "play_count"), + remix_count: __sdk::__query_builder::Col::new(table_name, "remix_count"), + like_count: __sdk::__query_builder::Col::new(table_name, "like_count"), + point_incentive_total_half_points: __sdk::__query_builder::Col::new( + table_name, + "point_incentive_total_half_points", + ), + point_incentive_claimed_points: __sdk::__query_builder::Col::new( + table_name, + "point_incentive_claimed_points", + ), + publish_ready: __sdk::__query_builder::Col::new(table_name, "publish_ready"), + generation_status: __sdk::__query_builder::Col::new(table_name, "generation_status"), + } + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_gallery_card_view_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_gallery_card_view_table.rs new file mode 100644 index 00000000..58c1659b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_gallery_card_view_table.rs @@ -0,0 +1,115 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::puzzle_gallery_card_view_row_type::PuzzleGalleryCardViewRow; +use super::puzzle_publication_status_type::PuzzlePublicationStatus; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `puzzle_gallery_card_view`. +/// +/// Obtain a handle from the [`PuzzleGalleryCardViewTableAccess::puzzle_gallery_card_view`] method on [`super::RemoteTables`], +/// like `ctx.db.puzzle_gallery_card_view()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.puzzle_gallery_card_view().on_insert(...)`. +pub struct PuzzleGalleryCardViewTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `puzzle_gallery_card_view`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait PuzzleGalleryCardViewTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`PuzzleGalleryCardViewTableHandle`], which mediates access to the table `puzzle_gallery_card_view`. + fn puzzle_gallery_card_view(&self) -> PuzzleGalleryCardViewTableHandle<'_>; +} + +impl PuzzleGalleryCardViewTableAccess for super::RemoteTables { + fn puzzle_gallery_card_view(&self) -> PuzzleGalleryCardViewTableHandle<'_> { + PuzzleGalleryCardViewTableHandle { + imp: self + .imp + .get_table::("puzzle_gallery_card_view"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct PuzzleGalleryCardViewInsertCallbackId(__sdk::CallbackId); +pub struct PuzzleGalleryCardViewDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for PuzzleGalleryCardViewTableHandle<'ctx> { + type Row = PuzzleGalleryCardViewRow; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = PuzzleGalleryCardViewInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PuzzleGalleryCardViewInsertCallbackId { + PuzzleGalleryCardViewInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: PuzzleGalleryCardViewInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = PuzzleGalleryCardViewDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PuzzleGalleryCardViewDeleteCallbackId { + PuzzleGalleryCardViewDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: PuzzleGalleryCardViewDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("puzzle_gallery_card_view"); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `PuzzleGalleryCardViewRow`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait puzzle_gallery_card_viewQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `PuzzleGalleryCardViewRow`. + fn puzzle_gallery_card_view(&self) -> __sdk::__query_builder::Table; +} + +impl puzzle_gallery_card_viewQueryTableAccess for __sdk::QueryTableAccessor { + fn puzzle_gallery_card_view(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("puzzle_gallery_card_view") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_gallery_view_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_gallery_view_table.rs new file mode 100644 index 00000000..24857cee --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_gallery_view_table.rs @@ -0,0 +1,116 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::puzzle_anchor_pack_type::PuzzleAnchorPack; +use super::puzzle_draft_level_type::PuzzleDraftLevel; +use super::puzzle_publication_status_type::PuzzlePublicationStatus; +use super::puzzle_work_profile_type::PuzzleWorkProfile; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `puzzle_gallery_view`. +/// +/// Obtain a handle from the [`PuzzleGalleryViewTableAccess::puzzle_gallery_view`] method on [`super::RemoteTables`], +/// like `ctx.db.puzzle_gallery_view()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.puzzle_gallery_view().on_insert(...)`. +pub struct PuzzleGalleryViewTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `puzzle_gallery_view`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait PuzzleGalleryViewTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`PuzzleGalleryViewTableHandle`], which mediates access to the table `puzzle_gallery_view`. + fn puzzle_gallery_view(&self) -> PuzzleGalleryViewTableHandle<'_>; +} + +impl PuzzleGalleryViewTableAccess for super::RemoteTables { + fn puzzle_gallery_view(&self) -> PuzzleGalleryViewTableHandle<'_> { + PuzzleGalleryViewTableHandle { + imp: self + .imp + .get_table::("puzzle_gallery_view"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct PuzzleGalleryViewInsertCallbackId(__sdk::CallbackId); +pub struct PuzzleGalleryViewDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for PuzzleGalleryViewTableHandle<'ctx> { + type Row = PuzzleWorkProfile; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = PuzzleGalleryViewInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PuzzleGalleryViewInsertCallbackId { + PuzzleGalleryViewInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: PuzzleGalleryViewInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = PuzzleGalleryViewDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PuzzleGalleryViewDeleteCallbackId { + PuzzleGalleryViewDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: PuzzleGalleryViewDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("puzzle_gallery_view"); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `PuzzleWorkProfile`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait puzzle_gallery_viewQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `PuzzleWorkProfile`. + fn puzzle_gallery_view(&self) -> __sdk::__query_builder::Table; +} + +impl puzzle_gallery_viewQueryTableAccess for __sdk::QueryTableAccessor { + fn puzzle_gallery_view(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("puzzle_gallery_view") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_generated_image_candidate_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_generated_image_candidate_type.rs new file mode 100644 index 00000000..6dd003d7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_generated_image_candidate_type.rs @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleGeneratedImageCandidate { + 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, +} + +impl __sdk::InModule for PuzzleGeneratedImageCandidate { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_leaderboard_entry_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_leaderboard_entry_type.rs new file mode 100644 index 00000000..474c7ffa --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_leaderboard_entry_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleLeaderboardEntry { + pub rank: u32, + pub nickname: String, + pub elapsed_ms: u64, + pub visible_tags: Vec, + pub is_current_player: bool, +} + +impl __sdk::InModule for PuzzleLeaderboardEntry { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_merged_group_state_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_merged_group_state_type.rs new file mode 100644 index 00000000..b6cba30c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_merged_group_state_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::puzzle_cell_position_type::PuzzleCellPosition; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleMergedGroupState { + pub group_id: String, + pub piece_ids: Vec, + pub occupied_cells: Vec, +} + +impl __sdk::InModule for PuzzleMergedGroupState { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_piece_state_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_piece_state_type.rs new file mode 100644 index 00000000..7cb0ef6d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_piece_state_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzlePieceState { + 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, +} + +impl __sdk::InModule for PuzzlePieceState { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_recommended_next_work_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_recommended_next_work_type.rs new file mode 100644 index 00000000..69d26ad1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_recommended_next_work_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleRecommendedNextWork { + 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, +} + +impl __sdk::InModule for PuzzleRecommendedNextWork { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_result_draft_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_result_draft_type.rs new file mode 100644 index 00000000..adb2dff4 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_result_draft_type.rs @@ -0,0 +1,35 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::puzzle_anchor_pack_type::PuzzleAnchorPack; +use super::puzzle_creator_intent_type::PuzzleCreatorIntent; +use super::puzzle_draft_level_type::PuzzleDraftLevel; +use super::puzzle_form_draft_type::PuzzleFormDraft; +use super::puzzle_generated_image_candidate_type::PuzzleGeneratedImageCandidate; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleResultDraft { + 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: PuzzleAnchorPack, + 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, +} + +impl __sdk::InModule for PuzzleResultDraft { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_result_preview_blocker_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_result_preview_blocker_type.rs new file mode 100644 index 00000000..e604eb40 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_result_preview_blocker_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleResultPreviewBlocker { + pub id: String, + pub code: String, + pub message: String, +} + +impl __sdk::InModule for PuzzleResultPreviewBlocker { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_result_preview_envelope_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_result_preview_envelope_type.rs new file mode 100644 index 00000000..4e09e613 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_result_preview_envelope_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::puzzle_result_draft_type::PuzzleResultDraft; +use super::puzzle_result_preview_blocker_type::PuzzleResultPreviewBlocker; +use super::puzzle_result_preview_finding_type::PuzzleResultPreviewFinding; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleResultPreviewEnvelope { + pub draft: PuzzleResultDraft, + pub blockers: Vec, + pub quality_findings: Vec, + pub publish_ready: bool, +} + +impl __sdk::InModule for PuzzleResultPreviewEnvelope { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_result_preview_finding_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_result_preview_finding_type.rs new file mode 100644 index 00000000..a43c4a16 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_result_preview_finding_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleResultPreviewFinding { + pub id: String, + pub severity: String, + pub code: String, + pub message: String, +} + +impl __sdk::InModule for PuzzleResultPreviewFinding { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_procedure_result_type.rs index 54f6349b..5b1a430c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_procedure_result_type.rs @@ -4,11 +4,13 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::puzzle_run_snapshot_type::PuzzleRunSnapshot; + #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct PuzzleRunProcedureResult { pub ok: bool, - pub run_json: Option, + pub run: Option, pub error_message: Option, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_snapshot_type.rs new file mode 100644 index 00000000..b32fe5d0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_snapshot_type.rs @@ -0,0 +1,32 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::puzzle_leaderboard_entry_type::PuzzleLeaderboardEntry; +use super::puzzle_recommended_next_work_type::PuzzleRecommendedNextWork; +use super::puzzle_runtime_level_snapshot_type::PuzzleRuntimeLevelSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleRunSnapshot { + 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, +} + +impl __sdk::InModule for PuzzleRunSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_runtime_level_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_runtime_level_snapshot_type.rs new file mode 100644 index 00000000..3554ed20 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_runtime_level_snapshot_type.rs @@ -0,0 +1,44 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::puzzle_audio_asset_type::PuzzleAudioAsset; +use super::puzzle_board_snapshot_type::PuzzleBoardSnapshot; +use super::puzzle_leaderboard_entry_type::PuzzleLeaderboardEntry; +use super::puzzle_runtime_level_status_type::PuzzleRuntimeLevelStatus; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleRuntimeLevelSnapshot { + 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: PuzzleBoardSnapshot, + pub status: PuzzleRuntimeLevelStatus, + 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, +} + +impl __sdk::InModule for PuzzleRuntimeLevelSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_runtime_level_status_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_runtime_level_status_type.rs new file mode 100644 index 00000000..dd491ccf --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_runtime_level_status_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum PuzzleRuntimeLevelStatus { + Playing, + + Cleared, + + Failed, +} + +impl __sdk::InModule for PuzzleRuntimeLevelStatus { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_procedure_result_type.rs index d59a56cc..019c8f94 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_procedure_result_type.rs @@ -4,11 +4,13 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::puzzle_work_profile_type::PuzzleWorkProfile; + #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct PuzzleWorkProcedureResult { pub ok: bool, - pub item_json: Option, + pub item: Option, pub error_message: Option, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_profile_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_profile_type.rs new file mode 100644 index 00000000..6b41228e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_profile_type.rs @@ -0,0 +1,119 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::puzzle_anchor_pack_type::PuzzleAnchorPack; +use super::puzzle_draft_level_type::PuzzleDraftLevel; +use super::puzzle_publication_status_type::PuzzlePublicationStatus; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleWorkProfile { + 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 levels: Vec, + pub publication_status: PuzzlePublicationStatus, + pub updated_at_micros: i64, + pub published_at_micros: Option, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, + pub recent_play_count_7_d: u32, + pub point_incentive_total_half_points: u64, + pub point_incentive_claimed_points: u64, + pub publish_ready: bool, + pub anchor_pack: PuzzleAnchorPack, +} + +impl __sdk::InModule for PuzzleWorkProfile { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `PuzzleWorkProfile`. +/// +/// Provides typed access to columns for query building. +pub struct PuzzleWorkProfileCols { + pub work_id: __sdk::__query_builder::Col, + pub profile_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub source_session_id: __sdk::__query_builder::Col>, + pub author_display_name: __sdk::__query_builder::Col, + pub work_title: __sdk::__query_builder::Col, + pub work_description: __sdk::__query_builder::Col, + pub level_name: __sdk::__query_builder::Col, + pub summary: __sdk::__query_builder::Col, + pub theme_tags: __sdk::__query_builder::Col>, + pub cover_image_src: __sdk::__query_builder::Col>, + pub cover_asset_id: __sdk::__query_builder::Col>, + pub levels: __sdk::__query_builder::Col>, + pub publication_status: __sdk::__query_builder::Col, + pub updated_at_micros: __sdk::__query_builder::Col, + pub published_at_micros: __sdk::__query_builder::Col>, + pub play_count: __sdk::__query_builder::Col, + pub remix_count: __sdk::__query_builder::Col, + pub like_count: __sdk::__query_builder::Col, + pub recent_play_count_7_d: __sdk::__query_builder::Col, + pub point_incentive_total_half_points: __sdk::__query_builder::Col, + pub point_incentive_claimed_points: __sdk::__query_builder::Col, + pub publish_ready: __sdk::__query_builder::Col, + pub anchor_pack: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for PuzzleWorkProfile { + type Cols = PuzzleWorkProfileCols; + fn cols(table_name: &'static str) -> Self::Cols { + PuzzleWorkProfileCols { + work_id: __sdk::__query_builder::Col::new(table_name, "work_id"), + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + source_session_id: __sdk::__query_builder::Col::new(table_name, "source_session_id"), + author_display_name: __sdk::__query_builder::Col::new( + table_name, + "author_display_name", + ), + work_title: __sdk::__query_builder::Col::new(table_name, "work_title"), + work_description: __sdk::__query_builder::Col::new(table_name, "work_description"), + level_name: __sdk::__query_builder::Col::new(table_name, "level_name"), + summary: __sdk::__query_builder::Col::new(table_name, "summary"), + theme_tags: __sdk::__query_builder::Col::new(table_name, "theme_tags"), + cover_image_src: __sdk::__query_builder::Col::new(table_name, "cover_image_src"), + cover_asset_id: __sdk::__query_builder::Col::new(table_name, "cover_asset_id"), + levels: __sdk::__query_builder::Col::new(table_name, "levels"), + publication_status: __sdk::__query_builder::Col::new(table_name, "publication_status"), + updated_at_micros: __sdk::__query_builder::Col::new(table_name, "updated_at_micros"), + published_at_micros: __sdk::__query_builder::Col::new( + table_name, + "published_at_micros", + ), + play_count: __sdk::__query_builder::Col::new(table_name, "play_count"), + remix_count: __sdk::__query_builder::Col::new(table_name, "remix_count"), + like_count: __sdk::__query_builder::Col::new(table_name, "like_count"), + recent_play_count_7_d: __sdk::__query_builder::Col::new( + table_name, + "recent_play_count_7_d", + ), + point_incentive_total_half_points: __sdk::__query_builder::Col::new( + table_name, + "point_incentive_total_half_points", + ), + point_incentive_claimed_points: __sdk::__query_builder::Col::new( + table_name, + "point_incentive_claimed_points", + ), + publish_ready: __sdk::__query_builder::Col::new(table_name, "publish_ready"), + anchor_pack: __sdk::__query_builder::Col::new(table_name, "anchor_pack"), + } + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_works_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_works_procedure_result_type.rs index 6a34c60f..53204197 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_works_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_works_procedure_result_type.rs @@ -4,11 +4,13 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::puzzle_work_profile_type::PuzzleWorkProfile; + #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct PuzzleWorksProcedureResult { pub ok: bool, - pub items_json: Option, + pub items: Vec, pub error_message: Option, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/square_hole_agent_message_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_agent_message_snapshot_type.rs new file mode 100644 index 00000000..8af09de8 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_agent_message_snapshot_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct SquareHoleAgentMessageSnapshot { + pub message_id: String, + pub session_id: String, + pub role: String, + pub kind: String, + pub text: String, + pub created_at_micros: i64, +} + +impl __sdk::InModule for SquareHoleAgentMessageSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/square_hole_agent_session_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_agent_session_procedure_result_type.rs index 5ea89d13..0b7384a7 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/square_hole_agent_session_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_agent_session_procedure_result_type.rs @@ -4,11 +4,13 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::square_hole_agent_session_snapshot_type::SquareHoleAgentSessionSnapshot; + #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct SquareHoleAgentSessionProcedureResult { pub ok: bool, - pub session_json: Option, + pub session: Option, pub error_message: Option, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/square_hole_agent_session_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_agent_session_snapshot_type.rs new file mode 100644 index 00000000..47130393 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_agent_session_snapshot_type.rs @@ -0,0 +1,31 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::square_hole_agent_message_snapshot_type::SquareHoleAgentMessageSnapshot; +use super::square_hole_creator_config_snapshot_type::SquareHoleCreatorConfigSnapshot; +use super::square_hole_draft_snapshot_type::SquareHoleDraftSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct SquareHoleAgentSessionSnapshot { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub current_turn: u32, + pub progress_percent: u32, + pub stage: String, + pub config: SquareHoleCreatorConfigSnapshot, + pub draft: Option, + pub messages: Vec, + pub last_assistant_reply: String, + pub published_profile_id: Option, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for SquareHoleAgentSessionSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/square_hole_creator_config_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_creator_config_snapshot_type.rs new file mode 100644 index 00000000..b10dd3e9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_creator_config_snapshot_type.rs @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::square_hole_hole_option_snapshot_type::SquareHoleHoleOptionSnapshot; +use super::square_hole_shape_option_snapshot_type::SquareHoleShapeOptionSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct SquareHoleCreatorConfigSnapshot { + 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: String, + pub background_image_src: String, +} + +impl __sdk::InModule for SquareHoleCreatorConfigSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/square_hole_draft_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_draft_snapshot_type.rs new file mode 100644 index 00000000..810103ea --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_draft_snapshot_type.rs @@ -0,0 +1,30 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::square_hole_hole_option_snapshot_type::SquareHoleHoleOptionSnapshot; +use super::square_hole_shape_option_snapshot_type::SquareHoleShapeOptionSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct SquareHoleDraftSnapshot { + pub profile_id: String, + pub game_name: String, + pub theme_text: String, + pub twist_rule: String, + pub summary_text: String, + pub tags: Vec, + pub cover_image_src: String, + pub background_prompt: String, + pub background_image_src: String, + pub shape_options: Vec, + pub hole_options: Vec, + pub shape_count: u32, + pub difficulty: u32, +} + +impl __sdk::InModule for SquareHoleDraftSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/square_hole_drop_feedback_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_drop_feedback_snapshot_type.rs new file mode 100644 index 00000000..3ff25600 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_drop_feedback_snapshot_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct SquareHoleDropFeedbackSnapshot { + pub accepted: bool, + pub reject_reason: Option, + pub message: String, +} + +impl __sdk::InModule for SquareHoleDropFeedbackSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/square_hole_drop_shape_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_drop_shape_procedure_result_type.rs index 06ba3616..0d2b6665 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/square_hole_drop_shape_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_drop_shape_procedure_result_type.rs @@ -4,13 +4,16 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::square_hole_drop_feedback_snapshot_type::SquareHoleDropFeedbackSnapshot; +use super::square_hole_run_snapshot_type::SquareHoleRunSnapshot; + #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct SquareHoleDropShapeProcedureResult { pub ok: bool, pub status: String, - pub run_json: Option, - pub feedback_json: Option, + pub run: Option, + pub feedback: Option, pub failure_reason: Option, pub error_message: Option, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/square_hole_gallery_view_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_gallery_view_row_type.rs new file mode 100644 index 00000000..997f82d8 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_gallery_view_row_type.rs @@ -0,0 +1,108 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::square_hole_hole_option_snapshot_type::SquareHoleHoleOptionSnapshot; +use super::square_hole_shape_option_snapshot_type::SquareHoleShapeOptionSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct SquareHoleGalleryViewRow { + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: String, + pub author_display_name: String, + pub game_name: String, + pub theme_text: String, + pub twist_rule: String, + pub summary_text: String, + pub tags: Vec, + pub cover_image_src: String, + pub background_prompt: String, + pub background_image_src: String, + pub shape_options: Vec, + pub hole_options: Vec, + pub shape_count: u32, + pub difficulty: u32, + pub publication_status: String, + pub publish_ready: bool, + pub play_count: u32, + pub updated_at_micros: i64, + pub published_at_micros: Option, +} + +impl __sdk::InModule for SquareHoleGalleryViewRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `SquareHoleGalleryViewRow`. +/// +/// Provides typed access to columns for query building. +pub struct SquareHoleGalleryViewRowCols { + pub work_id: __sdk::__query_builder::Col, + pub profile_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub source_session_id: __sdk::__query_builder::Col, + pub author_display_name: __sdk::__query_builder::Col, + pub game_name: __sdk::__query_builder::Col, + pub theme_text: __sdk::__query_builder::Col, + pub twist_rule: __sdk::__query_builder::Col, + pub summary_text: __sdk::__query_builder::Col, + pub tags: __sdk::__query_builder::Col>, + pub cover_image_src: __sdk::__query_builder::Col, + pub background_prompt: __sdk::__query_builder::Col, + pub background_image_src: __sdk::__query_builder::Col, + pub shape_options: + __sdk::__query_builder::Col>, + pub hole_options: + __sdk::__query_builder::Col>, + pub shape_count: __sdk::__query_builder::Col, + pub difficulty: __sdk::__query_builder::Col, + pub publication_status: __sdk::__query_builder::Col, + pub publish_ready: __sdk::__query_builder::Col, + pub play_count: __sdk::__query_builder::Col, + pub updated_at_micros: __sdk::__query_builder::Col, + pub published_at_micros: __sdk::__query_builder::Col>, +} + +impl __sdk::__query_builder::HasCols for SquareHoleGalleryViewRow { + type Cols = SquareHoleGalleryViewRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + SquareHoleGalleryViewRowCols { + work_id: __sdk::__query_builder::Col::new(table_name, "work_id"), + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + source_session_id: __sdk::__query_builder::Col::new(table_name, "source_session_id"), + author_display_name: __sdk::__query_builder::Col::new( + table_name, + "author_display_name", + ), + game_name: __sdk::__query_builder::Col::new(table_name, "game_name"), + theme_text: __sdk::__query_builder::Col::new(table_name, "theme_text"), + twist_rule: __sdk::__query_builder::Col::new(table_name, "twist_rule"), + summary_text: __sdk::__query_builder::Col::new(table_name, "summary_text"), + tags: __sdk::__query_builder::Col::new(table_name, "tags"), + cover_image_src: __sdk::__query_builder::Col::new(table_name, "cover_image_src"), + background_prompt: __sdk::__query_builder::Col::new(table_name, "background_prompt"), + background_image_src: __sdk::__query_builder::Col::new( + table_name, + "background_image_src", + ), + shape_options: __sdk::__query_builder::Col::new(table_name, "shape_options"), + hole_options: __sdk::__query_builder::Col::new(table_name, "hole_options"), + shape_count: __sdk::__query_builder::Col::new(table_name, "shape_count"), + difficulty: __sdk::__query_builder::Col::new(table_name, "difficulty"), + publication_status: __sdk::__query_builder::Col::new(table_name, "publication_status"), + publish_ready: __sdk::__query_builder::Col::new(table_name, "publish_ready"), + play_count: __sdk::__query_builder::Col::new(table_name, "play_count"), + updated_at_micros: __sdk::__query_builder::Col::new(table_name, "updated_at_micros"), + published_at_micros: __sdk::__query_builder::Col::new( + table_name, + "published_at_micros", + ), + } + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/square_hole_gallery_view_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_gallery_view_table.rs new file mode 100644 index 00000000..62f4b4b2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_gallery_view_table.rs @@ -0,0 +1,116 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::square_hole_gallery_view_row_type::SquareHoleGalleryViewRow; +use super::square_hole_hole_option_snapshot_type::SquareHoleHoleOptionSnapshot; +use super::square_hole_shape_option_snapshot_type::SquareHoleShapeOptionSnapshot; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `square_hole_gallery_view`. +/// +/// Obtain a handle from the [`SquareHoleGalleryViewTableAccess::square_hole_gallery_view`] method on [`super::RemoteTables`], +/// like `ctx.db.square_hole_gallery_view()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.square_hole_gallery_view().on_insert(...)`. +pub struct SquareHoleGalleryViewTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `square_hole_gallery_view`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait SquareHoleGalleryViewTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`SquareHoleGalleryViewTableHandle`], which mediates access to the table `square_hole_gallery_view`. + fn square_hole_gallery_view(&self) -> SquareHoleGalleryViewTableHandle<'_>; +} + +impl SquareHoleGalleryViewTableAccess for super::RemoteTables { + fn square_hole_gallery_view(&self) -> SquareHoleGalleryViewTableHandle<'_> { + SquareHoleGalleryViewTableHandle { + imp: self + .imp + .get_table::("square_hole_gallery_view"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct SquareHoleGalleryViewInsertCallbackId(__sdk::CallbackId); +pub struct SquareHoleGalleryViewDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for SquareHoleGalleryViewTableHandle<'ctx> { + type Row = SquareHoleGalleryViewRow; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = SquareHoleGalleryViewInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> SquareHoleGalleryViewInsertCallbackId { + SquareHoleGalleryViewInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: SquareHoleGalleryViewInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = SquareHoleGalleryViewDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> SquareHoleGalleryViewDeleteCallbackId { + SquareHoleGalleryViewDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: SquareHoleGalleryViewDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("square_hole_gallery_view"); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `SquareHoleGalleryViewRow`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait square_hole_gallery_viewQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `SquareHoleGalleryViewRow`. + fn square_hole_gallery_view(&self) -> __sdk::__query_builder::Table; +} + +impl square_hole_gallery_viewQueryTableAccess for __sdk::QueryTableAccessor { + fn square_hole_gallery_view(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("square_hole_gallery_view") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/square_hole_hole_option_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_hole_option_snapshot_type.rs new file mode 100644 index 00000000..e0251660 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_hole_option_snapshot_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct SquareHoleHoleOptionSnapshot { + pub hole_id: String, + pub hole_kind: String, + pub label: String, + pub image_prompt: String, + pub image_src: String, +} + +impl __sdk::InModule for SquareHoleHoleOptionSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/square_hole_hole_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_hole_snapshot_type.rs new file mode 100644 index 00000000..5663a23f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_hole_snapshot_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct SquareHoleHoleSnapshot { + pub hole_id: String, + pub hole_kind: String, + pub label: String, + pub x: f32, + pub y: f32, + pub image_src: String, +} + +impl __sdk::InModule for SquareHoleHoleSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/square_hole_run_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_run_procedure_result_type.rs index e4a5817d..ab11a2f4 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/square_hole_run_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_run_procedure_result_type.rs @@ -4,11 +4,13 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::square_hole_run_snapshot_type::SquareHoleRunSnapshot; + #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct SquareHoleRunProcedureResult { pub ok: bool, - pub run_json: Option, + pub run: Option, pub error_message: Option, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/square_hole_run_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_run_snapshot_type.rs new file mode 100644 index 00000000..a8d1e8b0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_run_snapshot_type.rs @@ -0,0 +1,39 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::square_hole_drop_feedback_snapshot_type::SquareHoleDropFeedbackSnapshot; +use super::square_hole_hole_snapshot_type::SquareHoleHoleSnapshot; +use super::square_hole_shape_option_snapshot_type::SquareHoleShapeOptionSnapshot; +use super::square_hole_shape_snapshot_type::SquareHoleShapeSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct SquareHoleRunSnapshot { + pub run_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub status: String, + pub snapshot_version: u64, + pub started_at_ms: i64, + pub duration_limit_ms: i64, + pub server_now_ms: i64, + pub remaining_ms: i64, + 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: String, + pub shape_options: Vec, + pub current_shape: Option, + pub holes: Vec, + pub last_feedback: Option, +} + +impl __sdk::InModule for SquareHoleRunSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/square_hole_shape_option_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_shape_option_snapshot_type.rs new file mode 100644 index 00000000..8a0d062e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_shape_option_snapshot_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct SquareHoleShapeOptionSnapshot { + pub option_id: String, + pub shape_kind: String, + pub label: String, + pub target_hole_id: String, + pub image_prompt: String, + pub image_src: String, +} + +impl __sdk::InModule for SquareHoleShapeOptionSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/square_hole_shape_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_shape_snapshot_type.rs new file mode 100644 index 00000000..2c16b1c9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_shape_snapshot_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct SquareHoleShapeSnapshot { + pub shape_id: String, + pub shape_kind: String, + pub label: String, + pub target_hole_id: String, + pub color: String, + pub image_src: String, +} + +impl __sdk::InModule for SquareHoleShapeSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/square_hole_work_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_work_procedure_result_type.rs index 0565f0e9..a3682071 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/square_hole_work_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_work_procedure_result_type.rs @@ -4,11 +4,13 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::square_hole_work_snapshot_type::SquareHoleWorkSnapshot; + #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct SquareHoleWorkProcedureResult { pub ok: bool, - pub work_json: Option, + pub work: Option, pub error_message: Option, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/square_hole_work_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_work_snapshot_type.rs new file mode 100644 index 00000000..54786576 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_work_snapshot_type.rs @@ -0,0 +1,41 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::square_hole_creator_config_snapshot_type::SquareHoleCreatorConfigSnapshot; +use super::square_hole_hole_option_snapshot_type::SquareHoleHoleOptionSnapshot; +use super::square_hole_shape_option_snapshot_type::SquareHoleShapeOptionSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct SquareHoleWorkSnapshot { + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: String, + pub author_display_name: String, + pub game_name: String, + pub theme_text: String, + pub twist_rule: String, + pub summary_text: String, + pub tags: Vec, + pub cover_image_src: String, + pub background_prompt: String, + pub background_image_src: String, + pub shape_options: Vec, + pub hole_options: Vec, + pub shape_count: u32, + pub difficulty: u32, + pub config: SquareHoleCreatorConfigSnapshot, + pub publication_status: String, + pub publish_ready: bool, + pub play_count: u32, + pub updated_at_micros: i64, + pub published_at_micros: Option, +} + +impl __sdk::InModule for SquareHoleWorkSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/square_hole_works_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_works_procedure_result_type.rs index 6f7ca3f3..09faad0f 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/square_hole_works_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/square_hole_works_procedure_result_type.rs @@ -4,11 +4,13 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::square_hole_work_snapshot_type::SquareHoleWorkSnapshot; + #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct SquareHoleWorksProcedureResult { pub ok: bool, - pub items_json: Option, + pub items: Vec, pub error_message: Option, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_message_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_message_snapshot_type.rs new file mode 100644 index 00000000..a337915a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_message_snapshot_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelAgentMessageSnapshot { + pub message_id: String, + pub session_id: String, + pub role: String, + pub kind: String, + pub text: String, + pub created_at_micros: i64, +} + +impl __sdk::InModule for VisualNovelAgentMessageSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_session_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_session_procedure_result_type.rs index f04d06eb..7ed44833 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_session_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_session_procedure_result_type.rs @@ -4,11 +4,13 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::visual_novel_agent_session_snapshot_type::VisualNovelAgentSessionSnapshot; + #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct VisualNovelAgentSessionProcedureResult { pub ok: bool, - pub session_json: Option, + pub session: Option, pub error_message: Option, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_session_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_session_snapshot_type.rs new file mode 100644 index 00000000..623a380e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_session_snapshot_type.rs @@ -0,0 +1,32 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::visual_novel_agent_message_snapshot_type::VisualNovelAgentMessageSnapshot; +use super::visual_novel_json_value_type::VisualNovelJsonValue; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelAgentSessionSnapshot { + 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_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for VisualNovelAgentSessionSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_gallery_view_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_gallery_view_row_type.rs new file mode 100644 index 00000000..e0199208 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_gallery_view_row_type.rs @@ -0,0 +1,82 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelGalleryViewRow { + 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 publication_status: String, + pub publish_ready: bool, + pub play_count: u32, + pub created_at_micros: i64, + pub updated_at_micros: i64, + pub published_at_micros: Option, +} + +impl __sdk::InModule for VisualNovelGalleryViewRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `VisualNovelGalleryViewRow`. +/// +/// Provides typed access to columns for query building. +pub struct VisualNovelGalleryViewRowCols { + pub work_id: __sdk::__query_builder::Col, + pub profile_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub source_session_id: __sdk::__query_builder::Col>, + pub author_display_name: __sdk::__query_builder::Col, + pub work_title: __sdk::__query_builder::Col, + pub work_description: __sdk::__query_builder::Col, + pub tags: __sdk::__query_builder::Col>, + pub cover_image_src: __sdk::__query_builder::Col>, + pub source_asset_ids: __sdk::__query_builder::Col>, + pub publication_status: __sdk::__query_builder::Col, + pub publish_ready: __sdk::__query_builder::Col, + pub play_count: __sdk::__query_builder::Col, + pub created_at_micros: __sdk::__query_builder::Col, + pub updated_at_micros: __sdk::__query_builder::Col, + pub published_at_micros: __sdk::__query_builder::Col>, +} + +impl __sdk::__query_builder::HasCols for VisualNovelGalleryViewRow { + type Cols = VisualNovelGalleryViewRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + VisualNovelGalleryViewRowCols { + work_id: __sdk::__query_builder::Col::new(table_name, "work_id"), + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + source_session_id: __sdk::__query_builder::Col::new(table_name, "source_session_id"), + author_display_name: __sdk::__query_builder::Col::new( + table_name, + "author_display_name", + ), + work_title: __sdk::__query_builder::Col::new(table_name, "work_title"), + work_description: __sdk::__query_builder::Col::new(table_name, "work_description"), + tags: __sdk::__query_builder::Col::new(table_name, "tags"), + cover_image_src: __sdk::__query_builder::Col::new(table_name, "cover_image_src"), + source_asset_ids: __sdk::__query_builder::Col::new(table_name, "source_asset_ids"), + publication_status: __sdk::__query_builder::Col::new(table_name, "publication_status"), + publish_ready: __sdk::__query_builder::Col::new(table_name, "publish_ready"), + play_count: __sdk::__query_builder::Col::new(table_name, "play_count"), + created_at_micros: __sdk::__query_builder::Col::new(table_name, "created_at_micros"), + updated_at_micros: __sdk::__query_builder::Col::new(table_name, "updated_at_micros"), + published_at_micros: __sdk::__query_builder::Col::new( + table_name, + "published_at_micros", + ), + } + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_gallery_view_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_gallery_view_table.rs new file mode 100644 index 00000000..a1f70563 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_gallery_view_table.rs @@ -0,0 +1,117 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::visual_novel_gallery_view_row_type::VisualNovelGalleryViewRow; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `visual_novel_gallery_view`. +/// +/// Obtain a handle from the [`VisualNovelGalleryViewTableAccess::visual_novel_gallery_view`] method on [`super::RemoteTables`], +/// like `ctx.db.visual_novel_gallery_view()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.visual_novel_gallery_view().on_insert(...)`. +pub struct VisualNovelGalleryViewTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `visual_novel_gallery_view`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait VisualNovelGalleryViewTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`VisualNovelGalleryViewTableHandle`], which mediates access to the table `visual_novel_gallery_view`. + fn visual_novel_gallery_view(&self) -> VisualNovelGalleryViewTableHandle<'_>; +} + +impl VisualNovelGalleryViewTableAccess for super::RemoteTables { + fn visual_novel_gallery_view(&self) -> VisualNovelGalleryViewTableHandle<'_> { + VisualNovelGalleryViewTableHandle { + imp: self + .imp + .get_table::("visual_novel_gallery_view"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct VisualNovelGalleryViewInsertCallbackId(__sdk::CallbackId); +pub struct VisualNovelGalleryViewDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for VisualNovelGalleryViewTableHandle<'ctx> { + type Row = VisualNovelGalleryViewRow; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = VisualNovelGalleryViewInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> VisualNovelGalleryViewInsertCallbackId { + VisualNovelGalleryViewInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: VisualNovelGalleryViewInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = VisualNovelGalleryViewDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> VisualNovelGalleryViewDeleteCallbackId { + VisualNovelGalleryViewDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: VisualNovelGalleryViewDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("visual_novel_gallery_view"); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `VisualNovelGalleryViewRow`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait visual_novel_gallery_viewQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `VisualNovelGalleryViewRow`. + fn visual_novel_gallery_view(&self) + -> __sdk::__query_builder::Table; +} + +impl visual_novel_gallery_viewQueryTableAccess for __sdk::QueryTableAccessor { + fn visual_novel_gallery_view( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("visual_novel_gallery_view") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_history_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_history_procedure_result_type.rs index c5c5935c..f0a3e7cd 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_history_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_history_procedure_result_type.rs @@ -4,11 +4,13 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::visual_novel_runtime_history_entry_snapshot_type::VisualNovelRuntimeHistoryEntrySnapshot; + #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct VisualNovelHistoryProcedureResult { pub ok: bool, - pub items_json: Option, + pub items: Vec, pub error_message: Option, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_json_field_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_json_field_type.rs new file mode 100644 index 00000000..d0789512 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_json_field_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::visual_novel_json_value_type::VisualNovelJsonValue; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelJsonField { + pub key: String, + pub value: VisualNovelJsonValue, +} + +impl __sdk::InModule for VisualNovelJsonField { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_json_value_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_json_value_type.rs new file mode 100644 index 00000000..31bb6ffb --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_json_value_type.rs @@ -0,0 +1,27 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::visual_novel_json_field_type::VisualNovelJsonField; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub enum VisualNovelJsonValue { + Null, + + Bool(bool), + + Number(f64), + + String(String), + + Array(Vec), + + Object(Vec), +} + +impl __sdk::InModule for VisualNovelJsonValue { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_run_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_run_procedure_result_type.rs index 66bbe483..b9bdde61 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_run_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_run_procedure_result_type.rs @@ -4,11 +4,13 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::visual_novel_run_snapshot_type::VisualNovelRunSnapshot; + #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct VisualNovelRunProcedureResult { pub ok: bool, - pub run_json: Option, + pub run: Option, pub error_message: Option, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_run_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_run_snapshot_type.rs new file mode 100644 index 00000000..a4ea47f6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_run_snapshot_type.rs @@ -0,0 +1,32 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::visual_novel_json_value_type::VisualNovelJsonValue; +use super::visual_novel_runtime_history_entry_snapshot_type::VisualNovelRuntimeHistoryEntrySnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelRunSnapshot { + 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: VisualNovelJsonValue, + pub metrics: VisualNovelJsonValue, + pub history: Vec, + pub available_choices: VisualNovelJsonValue, + pub text_mode_enabled: bool, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for VisualNovelRunSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_event_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_event_procedure_result_type.rs index 58bdfbfb..ccabc4be 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_event_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_event_procedure_result_type.rs @@ -4,11 +4,13 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::visual_novel_runtime_event_snapshot_type::VisualNovelRuntimeEventSnapshot; + #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct VisualNovelRuntimeEventProcedureResult { pub ok: bool, - pub event_json: Option, + pub event: Option, pub error_message: Option, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_event_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_event_snapshot_type.rs new file mode 100644 index 00000000..8749eb5c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_event_snapshot_type.rs @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::visual_novel_json_value_type::VisualNovelJsonValue; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelRuntimeEventSnapshot { + 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: VisualNovelJsonValue, + pub occurred_at_micros: i64, +} + +impl __sdk::InModule for VisualNovelRuntimeEventSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_history_entry_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_history_entry_snapshot_type.rs new file mode 100644 index 00000000..af62143e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_history_entry_snapshot_type.rs @@ -0,0 +1,27 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::visual_novel_json_value_type::VisualNovelJsonValue; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelRuntimeHistoryEntrySnapshot { + 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: VisualNovelJsonValue, + pub snapshot_before_hash: Option, + pub snapshot_after_hash: Option, + pub created_at_micros: i64, +} + +impl __sdk::InModule for VisualNovelRuntimeHistoryEntrySnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_procedure_result_type.rs index f7e63a1c..72535982 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_procedure_result_type.rs @@ -4,11 +4,13 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::visual_novel_work_snapshot_type::VisualNovelWorkSnapshot; + #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct VisualNovelWorkProcedureResult { pub ok: bool, - pub work_json: Option, + pub work: Option, pub error_message: Option, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_snapshot_type.rs new file mode 100644 index 00000000..46b8180f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_snapshot_type.rs @@ -0,0 +1,33 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::visual_novel_json_value_type::VisualNovelJsonValue; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelWorkSnapshot { + 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: VisualNovelJsonValue, + pub publication_status: String, + pub publish_ready: bool, + pub play_count: u32, + pub created_at_micros: i64, + pub updated_at_micros: i64, + pub published_at_micros: Option, +} + +impl __sdk::InModule for VisualNovelWorkSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_works_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_works_procedure_result_type.rs index 72558fcd..d6c89c1c 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_works_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_works_procedure_result_type.rs @@ -4,11 +4,13 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::visual_novel_work_snapshot_type::VisualNovelWorkSnapshot; + #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] pub struct VisualNovelWorksProcedureResult { pub ok: bool, - pub items_json: Option, + pub items: Vec, pub error_message: Option, } diff --git a/server-rs/crates/spacetime-client/src/npc.rs b/server-rs/crates/spacetime-client/src/npc.rs index 77635c7b..61d33d2b 100644 --- a/server-rs/crates/spacetime-client/src/npc.rs +++ b/server-rs/crates/spacetime-client/src/npc.rs @@ -9,19 +9,22 @@ impl SpacetimeClient { validate_npc_battle_interaction_input(&input)?; let procedure_input = input.into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .resolve_npc_battle_interaction_and_return_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_npc_battle_interaction_procedure_result); - send_once(&sender, mapped); - }, - ); - }) + self.call_after_connect( + "resolve_npc_battle_interaction_and_return", + move |connection, sender| { + connection + .procedures() + .resolve_npc_battle_interaction_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_npc_battle_interaction_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } } diff --git a/server-rs/crates/spacetime-client/src/puzzle.rs b/server-rs/crates/spacetime-client/src/puzzle.rs index 30f21887..f6ddd839 100644 --- a/server-rs/crates/spacetime-client/src/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/puzzle.rs @@ -20,7 +20,7 @@ impl SpacetimeClient { created_at_micros: input.created_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("create_puzzle_agent_session", move |connection, sender| { connection.procedures().create_puzzle_agent_session_then( procedure_input, move |_, result| { @@ -44,7 +44,7 @@ impl SpacetimeClient { owner_user_id, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("get_puzzle_agent_session", move |connection, sender| { connection.procedures().get_puzzle_agent_session_then( procedure_input, move |_, result| { @@ -69,7 +69,7 @@ impl SpacetimeClient { saved_at_micros: input.saved_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("save_puzzle_form_draft", move |connection, sender| { connection.procedures().save_puzzle_form_draft_then( procedure_input, move |_, result| { @@ -95,7 +95,7 @@ impl SpacetimeClient { submitted_at_micros: input.submitted_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("submit_puzzle_agent_message", move |connection, sender| { connection.procedures().submit_puzzle_agent_message_then( procedure_input, move |_, result| { @@ -125,16 +125,19 @@ impl SpacetimeClient { updated_at_micros: input.updated_at_micros, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .finalize_puzzle_agent_message_turn_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_puzzle_agent_session_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "finalize_puzzle_agent_message_turn", + move |connection, sender| { + connection + .procedures() + .finalize_puzzle_agent_message_turn_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_puzzle_agent_session_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -150,7 +153,7 @@ impl SpacetimeClient { compiled_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("compile_puzzle_agent_draft", move |connection, sender| { connection.procedures().compile_puzzle_agent_draft_then( procedure_input, move |_, result| { @@ -177,7 +180,7 @@ impl SpacetimeClient { saved_at_micros: input.saved_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("save_puzzle_generated_images", move |connection, sender| { connection.procedures().save_puzzle_generated_images_then( procedure_input, move |_, result| { @@ -206,7 +209,7 @@ impl SpacetimeClient { saved_at_micros: input.saved_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("save_puzzle_ui_background", move |connection, sender| { connection.procedures().save_puzzle_ui_background_then( procedure_input, move |_, result| { @@ -232,7 +235,7 @@ impl SpacetimeClient { selected_at_micros: input.selected_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("select_puzzle_cover_image", move |connection, sender| { connection.procedures().select_puzzle_cover_image_then( procedure_input, move |_, result| { @@ -265,7 +268,7 @@ impl SpacetimeClient { published_at_micros: input.published_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("publish_puzzle_work", move |connection, sender| { connection .procedures() .publish_puzzle_work_then(procedure_input, move |_, result| { @@ -284,7 +287,7 @@ impl SpacetimeClient { ) -> Result, SpacetimeClientError> { let procedure_input = PuzzleWorksListInput { owner_user_id }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("list_puzzle_works", move |connection, sender| { connection .procedures() .list_puzzle_works_then(procedure_input, move |_, result| { @@ -303,7 +306,7 @@ impl SpacetimeClient { ) -> Result { let procedure_input = PuzzleWorkGetInput { profile_id }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("get_puzzle_work_detail", move |connection, sender| { connection.procedures().get_puzzle_work_detail_then( procedure_input, move |_, result| { @@ -335,7 +338,7 @@ impl SpacetimeClient { updated_at_micros: input.updated_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("update_puzzle_work", move |connection, sender| { connection .procedures() .update_puzzle_work_then(procedure_input, move |_, result| { @@ -358,7 +361,7 @@ impl SpacetimeClient { owner_user_id, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("delete_puzzle_work", move |connection, sender| { connection .procedures() .delete_puzzle_work_then(procedure_input, move |_, result| { @@ -381,31 +384,48 @@ impl SpacetimeClient { claimed_at_micros: input.claimed_at_micros, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .claim_puzzle_work_point_incentive_then(procedure_input, move |_, result| { - let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) - .and_then(map_puzzle_work_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "claim_puzzle_work_point_incentive", + move |connection, sender| { + connection + .procedures() + .claim_puzzle_work_point_incentive_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_work_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } pub async fn list_puzzle_gallery( &self, - ) -> Result, SpacetimeClientError> { - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .list_puzzle_gallery_then(move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_puzzle_works_procedure_result); - send_once(&sender, mapped); - }); + ) -> Result, SpacetimeClientError> { + self.read_after_connect("list_puzzle_gallery", move |connection| { + let mut items = connection + .db() + .puzzle_gallery_card_view() + .iter() + .collect::>(); + items.sort_by(|left, right| { + right + .updated_at_micros + .cmp(&left.updated_at_micros) + .then_with(|| left.profile_id.cmp(&right.profile_id)) + }); + let recent_play_counts = public_work_recent_play_counts(connection, "puzzle"); + Ok(items + .into_iter() + .map(|item| { + let recent_play_count_7d = recent_play_counts + .get(&item.profile_id) + .copied() + .unwrap_or(0); + map_puzzle_gallery_card_view_row(item, recent_play_count_7d) + }) + .collect()) }) .await } @@ -416,7 +436,7 @@ impl SpacetimeClient { ) -> Result { let procedure_input = PuzzleWorkGetInput { profile_id }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("get_puzzle_gallery_detail", move |connection, sender| { connection.procedures().get_puzzle_gallery_detail_then( procedure_input, move |_, result| { @@ -440,7 +460,7 @@ impl SpacetimeClient { liked_at_micros: input.liked_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("record_puzzle_work_like", move |connection, sender| { connection.procedures().record_puzzle_work_like_then( procedure_input, move |_, result| { @@ -469,7 +489,7 @@ impl SpacetimeClient { remixed_at_micros: input.remixed_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("remix_puzzle_work", move |connection, sender| { connection .procedures() .remix_puzzle_work_then(procedure_input, move |_, result| { @@ -494,7 +514,7 @@ impl SpacetimeClient { started_at_micros: input.started_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("start_puzzle_run", move |connection, sender| { connection .procedures() .start_puzzle_run_then(procedure_input, move |_, result| { @@ -517,7 +537,7 @@ impl SpacetimeClient { owner_user_id, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("get_puzzle_run", move |connection, sender| { connection .procedures() .get_puzzle_run_then(procedure_input, move |_, result| { @@ -542,7 +562,7 @@ impl SpacetimeClient { swapped_at_micros: input.swapped_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("swap_puzzle_pieces", move |connection, sender| { connection .procedures() .swap_puzzle_pieces_then(procedure_input, move |_, result| { @@ -568,7 +588,7 @@ impl SpacetimeClient { dragged_at_micros: input.dragged_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("drag_puzzle_piece_or_group", move |connection, sender| { connection.procedures().drag_puzzle_piece_or_group_then( procedure_input, move |_, result| { @@ -593,7 +613,7 @@ impl SpacetimeClient { advanced_at_micros: input.advanced_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("advance_puzzle_next_level", move |connection, sender| { connection.procedures().advance_puzzle_next_level_then( procedure_input, move |_, result| { @@ -618,7 +638,7 @@ impl SpacetimeClient { updated_at_micros: input.updated_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("update_puzzle_run_pause", move |connection, sender| { connection.procedures().update_puzzle_run_pause_then( procedure_input, move |_, result| { @@ -644,7 +664,7 @@ impl SpacetimeClient { spent_points: input.spent_points, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("use_puzzle_runtime_prop", move |connection, sender| { connection.procedures().use_puzzle_runtime_prop_then( procedure_input, move |_, result| { @@ -672,16 +692,19 @@ impl SpacetimeClient { submitted_at_micros: input.submitted_at_micros, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .submit_puzzle_leaderboard_entry_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_puzzle_run_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "submit_puzzle_leaderboard_entry", + move |connection, sender| { + connection + .procedures() + .submit_puzzle_leaderboard_entry_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_puzzle_run_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } } diff --git a/server-rs/crates/spacetime-client/src/runtime.rs b/server-rs/crates/spacetime-client/src/runtime.rs index 3ecd0d1f..baac3495 100644 --- a/server-rs/crates/spacetime-client/src/runtime.rs +++ b/server-rs/crates/spacetime-client/src/runtime.rs @@ -4,7 +4,50 @@ impl SpacetimeClient { pub async fn get_creation_entry_config( &self, ) -> Result { - self.call_after_connect(move |connection, sender| { + match self + .read_after_connect("get_creation_entry_config", move |connection| { + let config_id = module_runtime::CREATION_ENTRY_CONFIG_GLOBAL_ID.to_string(); + let header = connection + .db() + .creation_entry_config() + .config_id() + .find(&config_id) + .ok_or_else(|| SpacetimeClientError::missing_snapshot("创作入口配置快照"))?; + let creation_types = connection + .db() + .creation_entry_type_config() + .iter() + .collect::>(); + Ok(build_creation_entry_config_record_from_rows( + header, + creation_types, + )) + }) + .await + { + Ok(config) => { + self.cache_creation_entry_config(config.clone()).await; + Ok(config) + } + Err(_) => { + if let Some(config) = self.read_cached_creation_entry_config().await { + return Ok(config); + } + match self.fetch_creation_entry_config_via_procedure().await { + Ok(config) => { + self.cache_creation_entry_config(config.clone()).await; + Ok(config) + } + Err(fallback_error) => Err(fallback_error), + } + } + } + } + + async fn fetch_creation_entry_config_via_procedure( + &self, + ) -> Result { + self.call_after_connect("get_creation_entry_config", move |connection, sender| { connection .procedures() .get_creation_entry_config_then(move |_, result| { @@ -22,17 +65,26 @@ impl SpacetimeClient { input: module_runtime::CreationEntryTypeAdminUpsertInput, ) -> Result { let procedure_input: CreationEntryTypeAdminUpsertInput = input.into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .upsert_creation_entry_type_config_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_creation_entry_config_procedure_result); - send_once(&sender, mapped); - }); - }) - .await + let config = self + .call_after_connect( + "upsert_creation_entry_type_config", + move |connection, sender| { + connection + .procedures() + .upsert_creation_entry_type_config_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_creation_entry_config_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await?; + self.cache_creation_entry_config(config.clone()).await; + Ok(config) } pub async fn get_runtime_settings( @@ -43,17 +95,20 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { - connection.procedures().get_runtime_setting_or_default_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_runtime_setting_procedure_result); - send_once(&sender, mapped); - }, - ); - }) + self.call_after_connect( + "get_runtime_setting_or_default", + move |connection, sender| { + connection.procedures().get_runtime_setting_or_default_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_setting_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -65,7 +120,7 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { + self.call_after_connect("list_platform_browse_history", move |connection, sender| { connection.procedures().list_platform_browse_history_then( procedure_input, move |_, result| { @@ -87,7 +142,7 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { + self.call_after_connect("get_profile_dashboard", move |connection, sender| { connection.procedures().get_profile_dashboard_then( procedure_input, move |_, result| { @@ -109,7 +164,7 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { + self.call_after_connect("list_profile_wallet_ledger", move |connection, sender| { connection.procedures().list_profile_wallet_ledger_then( procedure_input, move |_, result| { @@ -131,19 +186,22 @@ impl SpacetimeClient { .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))? .into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .grant_new_user_registration_wallet_reward_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) - .and_then(map_runtime_profile_wallet_adjustment_procedure_result); - send_once(&sender, mapped); - }, - ); - }) + self.call_after_connect( + "grant_new_user_registration_wallet_reward", + move |connection, sender| { + connection + .procedures() + .grant_new_user_registration_wallet_reward_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_profile_wallet_adjustment_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -163,19 +221,22 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .consume_profile_wallet_points_and_return_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_runtime_profile_wallet_adjustment_procedure_result); - send_once(&sender, mapped); - }, - ); - }) + self.call_after_connect( + "consume_profile_wallet_points_and_return", + move |connection, sender| { + connection + .procedures() + .consume_profile_wallet_points_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_profile_wallet_adjustment_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -195,16 +256,22 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .refund_profile_wallet_points_and_return_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_runtime_profile_wallet_adjustment_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "refund_profile_wallet_points_and_return", + move |connection, sender| { + connection + .procedures() + .refund_profile_wallet_points_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_profile_wallet_adjustment_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -216,7 +283,7 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { + self.call_after_connect("get_profile_recharge_center", move |connection, sender| { connection.procedures().get_profile_recharge_center_then( procedure_input, move |_, result| { @@ -252,19 +319,22 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .create_profile_recharge_order_and_return_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_runtime_profile_recharge_order_procedure_result); - send_once(&sender, mapped); - }, - ); - }) + self.call_after_connect( + "create_profile_recharge_order_and_return", + move |connection, sender| { + connection + .procedures() + .create_profile_recharge_order_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_profile_recharge_order_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -282,16 +352,22 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .get_profile_recharge_order_and_return_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_runtime_profile_recharge_order_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "get_profile_recharge_order_and_return", + move |connection, sender| { + connection + .procedures() + .get_profile_recharge_order_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_profile_recharge_order_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -315,19 +391,22 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .mark_profile_recharge_order_paid_and_return_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_runtime_profile_recharge_order_procedure_result); - send_once(&sender, mapped); - }, - ); - }) + self.call_after_connect( + "mark_profile_recharge_order_paid_and_return", + move |connection, sender| { + connection + .procedures() + .mark_profile_recharge_order_paid_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_profile_recharge_order_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -349,16 +428,19 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .submit_profile_feedback_and_return_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_runtime_profile_feedback_submission_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "submit_profile_feedback_and_return", + move |connection, sender| { + connection + .procedures() + .submit_profile_feedback_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_profile_feedback_submission_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -370,16 +452,19 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .get_profile_referral_invite_center_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_runtime_referral_invite_center_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "get_profile_referral_invite_center", + move |connection, sender| { + connection + .procedures() + .get_profile_referral_invite_center_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_referral_invite_center_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -394,16 +479,19 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .redeem_profile_referral_invite_code_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_runtime_referral_redeem_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "redeem_profile_referral_invite_code", + move |connection, sender| { + connection + .procedures() + .redeem_profile_referral_invite_code_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_referral_redeem_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -418,7 +506,7 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { + self.call_after_connect("redeem_profile_reward_code", move |connection, sender| { connection.procedures().redeem_profile_reward_code_then( procedure_input, move |_, result| { @@ -481,16 +569,19 @@ impl SpacetimeClient { occurred_at_micros, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .record_tracking_event_and_return_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_runtime_tracking_event_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "record_tracking_event_and_return", + move |connection, sender| { + connection + .procedures() + .record_tracking_event_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_tracking_event_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -502,7 +593,7 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { + self.call_after_connect("get_profile_task_center", move |connection, sender| { connection.procedures().get_profile_task_center_then( procedure_input, move |_, result| { @@ -525,16 +616,22 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .claim_profile_task_reward_and_return_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_runtime_profile_task_claim_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "claim_profile_task_reward_and_return", + move |connection, sender| { + connection + .procedures() + .claim_profile_task_reward_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_profile_task_claim_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -550,7 +647,7 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { + self.call_after_connect("query_analytics_metric", move |connection, sender| { connection.procedures().query_analytics_metric_then( procedure_input, move |_, result| { @@ -572,16 +669,19 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .admin_list_profile_task_configs_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_runtime_profile_task_config_admin_list_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "admin_list_profile_task_configs", + move |connection, sender| { + connection + .procedures() + .admin_list_profile_task_configs_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_profile_task_config_admin_list_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -617,16 +717,19 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .admin_upsert_profile_task_config_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_runtime_profile_task_config_admin_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "admin_upsert_profile_task_config", + move |connection, sender| { + connection + .procedures() + .admin_upsert_profile_task_config_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_profile_task_config_admin_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -644,16 +747,19 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .admin_disable_profile_task_config_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_runtime_profile_task_config_admin_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "admin_disable_profile_task_config", + move |connection, sender| { + connection + .procedures() + .admin_disable_profile_task_config_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_profile_task_config_admin_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -666,16 +772,24 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .admin_list_profile_recharge_products_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_runtime_profile_recharge_product_admin_list_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "admin_list_profile_recharge_products", + move |connection, sender| { + connection + .procedures() + .admin_list_profile_recharge_products_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then( + map_runtime_profile_recharge_product_admin_list_procedure_result, + ); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -716,16 +830,24 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .admin_upsert_profile_recharge_product_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_runtime_profile_recharge_product_admin_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "admin_upsert_profile_recharge_product", + move |connection, sender| { + connection + .procedures() + .admin_upsert_profile_recharge_product_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then( + map_runtime_profile_recharge_product_admin_procedure_result, + ); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -755,16 +877,19 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .admin_upsert_profile_redeem_code_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_runtime_profile_redeem_code_admin_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "admin_upsert_profile_redeem_code", + move |connection, sender| { + connection + .procedures() + .admin_upsert_profile_redeem_code_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_profile_redeem_code_admin_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -776,16 +901,19 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .admin_list_profile_redeem_codes_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_runtime_profile_redeem_code_admin_list_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "admin_list_profile_redeem_codes", + move |connection, sender| { + connection + .procedures() + .admin_list_profile_redeem_codes_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_profile_redeem_code_admin_list_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -803,16 +931,19 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .admin_disable_profile_redeem_code_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_runtime_profile_redeem_code_admin_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "admin_disable_profile_redeem_code", + move |connection, sender| { + connection + .procedures() + .admin_disable_profile_redeem_code_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_profile_redeem_code_admin_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -836,16 +967,19 @@ impl SpacetimeClient { .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))? .into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .admin_upsert_profile_invite_code_then(procedure_input, move |_, result| { - let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) - .and_then(map_runtime_profile_invite_code_admin_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "admin_upsert_profile_invite_code", + move |connection, sender| { + connection + .procedures() + .admin_upsert_profile_invite_code_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_runtime_profile_invite_code_admin_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -857,16 +991,19 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .admin_list_profile_invite_codes_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_runtime_profile_invite_code_admin_list_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "admin_list_profile_invite_codes", + move |connection, sender| { + connection + .procedures() + .admin_list_profile_invite_codes_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_profile_invite_code_admin_list_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -878,7 +1015,7 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { + self.call_after_connect("get_profile_play_stats", move |connection, sender| { connection.procedures().get_profile_play_stats_then( procedure_input, move |_, result| { @@ -900,7 +1037,7 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { + self.call_after_connect("get_runtime_snapshot", move |connection, sender| { connection .procedures() .get_runtime_snapshot_then(procedure_input, move |_, result| { @@ -933,16 +1070,19 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .upsert_runtime_snapshot_and_return_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_runtime_snapshot_required_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "upsert_runtime_snapshot_and_return", + move |connection, sender| { + connection + .procedures() + .upsert_runtime_snapshot_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_snapshot_required_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -954,16 +1094,19 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .delete_runtime_snapshot_and_return_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_runtime_snapshot_delete_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "delete_runtime_snapshot_and_return", + move |connection, sender| { + connection + .procedures() + .delete_runtime_snapshot_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_snapshot_delete_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -975,7 +1118,7 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { + self.call_after_connect("list_profile_save_archives", move |connection, sender| { connection.procedures().list_profile_save_archives_then( procedure_input, move |_, result| { @@ -999,16 +1142,22 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .resume_profile_save_archive_and_return_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_runtime_profile_save_archive_resume_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "resume_profile_save_archive_and_return", + move |connection, sender| { + connection + .procedures() + .resume_profile_save_archive_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_profile_save_archive_resume_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -1028,16 +1177,19 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .upsert_runtime_setting_and_return_then(procedure_input, move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_runtime_setting_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "upsert_runtime_setting_and_return", + move |connection, sender| { + connection + .procedures() + .upsert_runtime_setting_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_setting_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -1052,19 +1204,22 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .upsert_platform_browse_history_and_return_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_runtime_browse_history_procedure_result); - send_once(&sender, mapped); - }, - ); - }) + self.call_after_connect( + "upsert_platform_browse_history_and_return", + move |connection, sender| { + connection + .procedures() + .upsert_platform_browse_history_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_browse_history_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -1076,19 +1231,22 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .clear_platform_browse_history_and_return_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_runtime_browse_history_procedure_result); - send_once(&sender, mapped); - }, - ); - }) + self.call_after_connect( + "clear_platform_browse_history_and_return", + move |connection, sender| { + connection + .procedures() + .clear_platform_browse_history_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_browse_history_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } } diff --git a/server-rs/crates/spacetime-client/src/square_hole.rs b/server-rs/crates/spacetime-client/src/square_hole.rs index f0ade205..0b8e9e26 100644 --- a/server-rs/crates/spacetime-client/src/square_hole.rs +++ b/server-rs/crates/spacetime-client/src/square_hole.rs @@ -16,16 +16,19 @@ impl SpacetimeClient { created_at_micros: input.created_at_micros, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .create_square_hole_agent_session_then(procedure_input, move |_, result| { - let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) - .and_then(map_square_hole_agent_session_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "create_square_hole_agent_session", + move |connection, sender| { + connection + .procedures() + .create_square_hole_agent_session_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_square_hole_agent_session_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -39,17 +42,20 @@ impl SpacetimeClient { owner_user_id, }; - self.call_after_connect(move |connection, sender| { - connection.procedures().get_square_hole_agent_session_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) - .and_then(map_square_hole_agent_session_procedure_result); - send_once(&sender, mapped); - }, - ); - }) + self.call_after_connect( + "get_square_hole_agent_session", + move |connection, sender| { + connection.procedures().get_square_hole_agent_session_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_square_hole_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -65,16 +71,19 @@ impl SpacetimeClient { submitted_at_micros: input.submitted_at_micros, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .submit_square_hole_agent_message_then(procedure_input, move |_, result| { - let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) - .and_then(map_square_hole_agent_session_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "submit_square_hole_agent_message", + move |connection, sender| { + connection + .procedures() + .submit_square_hole_agent_message_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_square_hole_agent_session_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -94,16 +103,22 @@ impl SpacetimeClient { error_message: input.error_message, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .finalize_square_hole_agent_message_turn_then(procedure_input, move |_, result| { - let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) - .and_then(map_square_hole_agent_session_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "finalize_square_hole_agent_message_turn", + move |connection, sender| { + connection + .procedures() + .finalize_square_hole_agent_message_turn_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_square_hole_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -123,7 +138,7 @@ impl SpacetimeClient { compiled_at_micros: input.compiled_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("compile_square_hole_draft", move |connection, sender| { connection.procedures().compile_square_hole_draft_then( procedure_input, move |_, result| { @@ -159,7 +174,7 @@ impl SpacetimeClient { updated_at_micros: input.updated_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("update_square_hole_work", move |connection, sender| { connection.procedures().update_square_hole_work_then( procedure_input, move |_, result| { @@ -185,7 +200,7 @@ impl SpacetimeClient { published_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("publish_square_hole_work", move |connection, sender| { connection.procedures().publish_square_hole_work_then( procedure_input, move |_, result| { @@ -213,10 +228,22 @@ impl SpacetimeClient { pub async fn list_square_hole_gallery( &self, ) -> Result, SpacetimeClientError> { - self.list_square_hole_works_with_input(SquareHoleWorksListInput { - // 中文注释:公开广场只依赖 published_only,owner_user_id 用固定值通过输入校验。 - owner_user_id: "square-hole-public-gallery".to_string(), - published_only: true, + self.read_after_connect("list_square_hole_gallery", move |connection| { + let mut items = connection + .db() + .square_hole_gallery_view() + .iter() + .collect::>(); + items.sort_by(|left, right| { + right + .updated_at_micros + .cmp(&left.updated_at_micros) + .then_with(|| left.profile_id.cmp(&right.profile_id)) + }); + Ok(items + .into_iter() + .map(map_square_hole_gallery_view_row) + .collect()) }) .await } @@ -225,7 +252,7 @@ impl SpacetimeClient { &self, procedure_input: SquareHoleWorksListInput, ) -> Result, SpacetimeClientError> { - self.call_after_connect(move |connection, sender| { + self.call_after_connect("list_square_hole_works", move |connection, sender| { connection.procedures().list_square_hole_works_then( procedure_input, move |_, result| { @@ -249,7 +276,7 @@ impl SpacetimeClient { owner_user_id, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("get_square_hole_work_detail", move |connection, sender| { connection.procedures().get_square_hole_work_detail_then( procedure_input, move |_, result| { @@ -273,7 +300,7 @@ impl SpacetimeClient { owner_user_id, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("delete_square_hole_work", move |connection, sender| { connection.procedures().delete_square_hole_work_then( procedure_input, move |_, result| { @@ -298,7 +325,7 @@ impl SpacetimeClient { started_at_ms: input.started_at_ms, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("start_square_hole_run", move |connection, sender| { connection.procedures().start_square_hole_run_then( procedure_input, move |_, result| { @@ -322,7 +349,7 @@ impl SpacetimeClient { owner_user_id, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("get_square_hole_run", move |connection, sender| { connection .procedures() .get_square_hole_run_then(procedure_input, move |_, result| { @@ -349,7 +376,7 @@ impl SpacetimeClient { dropped_at_ms: input.dropped_at_ms, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("drop_square_hole_shape", move |connection, sender| { connection.procedures().drop_square_hole_shape_then( procedure_input, move |_, result| { @@ -379,7 +406,7 @@ impl SpacetimeClient { stopped_at_ms: input.stopped_at_ms, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("stop_square_hole_run", move |connection, sender| { connection .procedures() .stop_square_hole_run_then(procedure_input, move |_, result| { @@ -403,7 +430,7 @@ impl SpacetimeClient { restarted_at_ms: input.restarted_at_ms, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("restart_square_hole_run", move |connection, sender| { connection.procedures().restart_square_hole_run_then( procedure_input, move |_, result| { @@ -427,7 +454,7 @@ impl SpacetimeClient { finished_at_ms: input.finished_at_ms, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("finish_square_hole_time_up", move |connection, sender| { connection.procedures().finish_square_hole_time_up_then( procedure_input, move |_, result| { diff --git a/server-rs/crates/spacetime-client/src/story.rs b/server-rs/crates/spacetime-client/src/story.rs index c04d02d1..d341385f 100644 --- a/server-rs/crates/spacetime-client/src/story.rs +++ b/server-rs/crates/spacetime-client/src/story.rs @@ -23,17 +23,20 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { - connection.procedures().begin_story_session_and_return_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(SpacetimeClientError::from_sdk_error) - .and_then(map_story_session_procedure_result); - send_once(&sender, mapped); - }, - ); - }) + self.call_after_connect( + "begin_story_session_and_return", + move |connection, sender| { + connection.procedures().begin_story_session_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_story_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -55,7 +58,7 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { + self.call_after_connect("continue_story_and_return", move |connection, sender| { connection.procedures().continue_story_and_return_then( procedure_input, move |_, result| { @@ -77,7 +80,7 @@ impl SpacetimeClient { .map_err(SpacetimeClientError::validation_failed)? .into(); - self.call_after_connect(move |connection, sender| { + self.call_after_connect("get_story_session_state", move |connection, sender| { connection.procedures().get_story_session_state_then( procedure_input, move |_, result| { diff --git a/server-rs/crates/spacetime-client/src/telemetry.rs b/server-rs/crates/spacetime-client/src/telemetry.rs new file mode 100644 index 00000000..c89e0f19 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/telemetry.rs @@ -0,0 +1,118 @@ +use std::time::Duration; + +use opentelemetry::{KeyValue, global, metrics::Counter}; + +use crate::SpacetimeClientError; + +// SpacetimeDB procedure 指标只使用 procedure / status_class 等低基数字段,避免把用户或作品 ID 写进指标标签。 +pub(crate) struct ProcedureMetricsGuard { + procedure: &'static str, + started_at: std::time::Instant, +} + +pub(crate) struct ReadMetricsGuard { + read: &'static str, + started_at: std::time::Instant, +} + +pub(crate) fn begin_procedure(procedure: &'static str) -> ProcedureMetricsGuard { + ProcedureMetricsGuard { + procedure, + started_at: std::time::Instant::now(), + } +} + +pub(crate) fn begin_read(read: &'static str) -> ReadMetricsGuard { + ReadMetricsGuard { + read, + started_at: std::time::Instant::now(), + } +} + +impl ProcedureMetricsGuard { + pub(crate) fn finish(&self, result: &Result) { + let duration = self.started_at.elapsed(); + record_procedure(self.procedure, duration, result.is_err()); + } +} + +impl ReadMetricsGuard { + pub(crate) fn finish(&self, result: &Result) { + let duration = self.started_at.elapsed(); + record_read(self.read, duration, result.is_err()); + } +} + +struct SpacetimeMetrics { + calls: Counter, + errors: Counter, + duration_ms: opentelemetry::metrics::Histogram, + read_calls: Counter, + read_errors: Counter, + read_duration_ms: opentelemetry::metrics::Histogram, +} + +fn spacetime_metrics() -> &'static SpacetimeMetrics { + static METRICS: std::sync::OnceLock = std::sync::OnceLock::new(); + METRICS.get_or_init(|| { + let meter = global::meter("genarrative-spacetime-client"); + SpacetimeMetrics { + calls: meter + .u64_counter("genarrative.spacetime.procedure.calls") + .with_description("SpacetimeDB procedure call count") + .build(), + errors: meter + .u64_counter("genarrative.spacetime.procedure.errors") + .with_description("SpacetimeDB procedure error count") + .build(), + duration_ms: meter + .f64_histogram("genarrative.spacetime.procedure.duration_ms") + .with_unit("ms") + .with_description("SpacetimeDB procedure duration in milliseconds") + .build(), + read_calls: meter + .u64_counter("genarrative.spacetime.read.calls") + .with_description("SpacetimeDB local subscription cache read count") + .build(), + read_errors: meter + .u64_counter("genarrative.spacetime.read.errors") + .with_description("SpacetimeDB local subscription cache read error count") + .build(), + read_duration_ms: meter + .f64_histogram("genarrative.spacetime.read.duration_ms") + .with_unit("ms") + .with_description("SpacetimeDB local subscription cache read duration in milliseconds") + .build(), + } + }) +} + +fn record_procedure(procedure: &'static str, duration: Duration, failed: bool) { + let labels = vec![ + KeyValue::new("procedure", procedure), + KeyValue::new("status_class", if failed { "error" } else { "ok" }), + ]; + let metrics = spacetime_metrics(); + metrics.calls.add(1, &labels); + metrics + .duration_ms + .record(duration.as_secs_f64() * 1000.0, &labels); + if failed { + metrics.errors.add(1, &labels); + } +} + +fn record_read(read: &'static str, duration: Duration, failed: bool) { + let labels = vec![ + KeyValue::new("read", read), + KeyValue::new("status_class", if failed { "error" } else { "ok" }), + ]; + let metrics = spacetime_metrics(); + metrics.read_calls.add(1, &labels); + metrics + .read_duration_ms + .record(duration.as_secs_f64() * 1000.0, &labels); + if failed { + metrics.read_errors.add(1, &labels); + } +} diff --git a/server-rs/crates/spacetime-client/src/visual_novel.rs b/server-rs/crates/spacetime-client/src/visual_novel.rs index bbc8226a..3454298f 100644 --- a/server-rs/crates/spacetime-client/src/visual_novel.rs +++ b/server-rs/crates/spacetime-client/src/visual_novel.rs @@ -6,9 +6,9 @@ use crate::mapper::{ VisualNovelRunSnapshotRecordInput, VisualNovelRunStartRecordInput, VisualNovelRuntimeEventRecord, VisualNovelWorkCompileRecordInput, VisualNovelWorkProfileRecord, VisualNovelWorkUpdateRecordInput, map_visual_novel_agent_session_procedure_result, - 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, + 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, }; impl SpacetimeClient { @@ -28,16 +28,19 @@ impl SpacetimeClient { created_at_micros: input.created_at_micros, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .create_visual_novel_agent_session_then(procedure_input, move |_, result| { - let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) - .and_then(map_visual_novel_agent_session_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "create_visual_novel_agent_session", + move |connection, sender| { + connection + .procedures() + .create_visual_novel_agent_session_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_visual_novel_agent_session_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -51,17 +54,20 @@ impl SpacetimeClient { owner_user_id, }; - self.call_after_connect(move |connection, sender| { - connection.procedures().get_visual_novel_agent_session_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) - .and_then(map_visual_novel_agent_session_procedure_result); - send_once(&sender, mapped); - }, - ); - }) + self.call_after_connect( + "get_visual_novel_agent_session", + move |connection, sender| { + connection.procedures().get_visual_novel_agent_session_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_visual_novel_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -77,16 +83,19 @@ impl SpacetimeClient { submitted_at_micros: input.submitted_at_micros, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .submit_visual_novel_agent_message_then(procedure_input, move |_, result| { - let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) - .and_then(map_visual_novel_agent_session_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "submit_visual_novel_agent_message", + move |connection, sender| { + connection + .procedures() + .submit_visual_novel_agent_message_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_visual_novel_agent_session_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -107,19 +116,22 @@ impl SpacetimeClient { error_message: input.error_message, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .finalize_visual_novel_agent_message_turn_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) - .and_then(map_visual_novel_agent_session_procedure_result); - send_once(&sender, mapped); - }, - ); - }) + self.call_after_connect( + "finalize_visual_novel_agent_message_turn", + move |connection, sender| { + connection + .procedures() + .finalize_visual_novel_agent_message_turn_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_visual_novel_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -140,16 +152,19 @@ impl SpacetimeClient { compiled_at_micros: input.compiled_at_micros, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .compile_visual_novel_work_profile_then(procedure_input, move |_, result| { - let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) - .and_then(map_visual_novel_agent_session_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "compile_visual_novel_work_profile", + move |connection, sender| { + connection + .procedures() + .compile_visual_novel_work_profile_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_visual_novel_agent_session_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -170,7 +185,7 @@ impl SpacetimeClient { updated_at_micros: input.updated_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("update_visual_novel_work", move |connection, sender| { connection.procedures().update_visual_novel_work_then( procedure_input, move |_, result| { @@ -196,7 +211,7 @@ impl SpacetimeClient { published_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("publish_visual_novel_work", move |connection, sender| { connection.procedures().publish_visual_novel_work_then( procedure_input, move |_, result| { @@ -224,10 +239,22 @@ impl SpacetimeClient { pub async fn list_visual_novel_gallery( &self, ) -> Result, SpacetimeClientError> { - self.list_visual_novel_works_with_input(VisualNovelWorksListInput { - // 中文注释:公开列表只依赖 published_only,owner_user_id 用固定值满足 procedure 输入契约。 - owner_user_id: "visual-novel-public-gallery".to_string(), - published_only: true, + self.read_after_connect("list_visual_novel_gallery", move |connection| { + let mut items = connection + .db() + .visual_novel_gallery_view() + .iter() + .collect::>(); + items.sort_by(|left, right| { + right + .updated_at_micros + .cmp(&left.updated_at_micros) + .then_with(|| left.profile_id.cmp(&right.profile_id)) + }); + Ok(items + .into_iter() + .map(map_visual_novel_gallery_view_row) + .collect()) }) .await } @@ -236,7 +263,7 @@ impl SpacetimeClient { &self, procedure_input: VisualNovelWorksListInput, ) -> Result, SpacetimeClientError> { - self.call_after_connect(move |connection, sender| { + self.call_after_connect("list_visual_novel_works", move |connection, sender| { connection.procedures().list_visual_novel_works_then( procedure_input, move |_, result| { @@ -260,7 +287,7 @@ impl SpacetimeClient { owner_user_id, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("get_visual_novel_work_detail", move |connection, sender| { connection.procedures().get_visual_novel_work_detail_then( procedure_input, move |_, result| { @@ -284,7 +311,7 @@ impl SpacetimeClient { owner_user_id, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("delete_visual_novel_work", move |connection, sender| { connection.procedures().delete_visual_novel_work_then( procedure_input, move |_, result| { @@ -311,7 +338,7 @@ impl SpacetimeClient { started_at_micros: input.started_at_micros, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("start_visual_novel_run", move |connection, sender| { connection.procedures().start_visual_novel_run_then( procedure_input, move |_, result| { @@ -335,7 +362,7 @@ impl SpacetimeClient { owner_user_id, }; - self.call_after_connect(move |connection, sender| { + self.call_after_connect("get_visual_novel_run", move |connection, sender| { connection .procedures() .get_visual_novel_run_then(procedure_input, move |_, result| { @@ -367,16 +394,19 @@ impl SpacetimeClient { updated_at_micros: input.updated_at_micros, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .upsert_visual_novel_run_snapshot_then(procedure_input, move |_, result| { - let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) - .and_then(map_visual_novel_run_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "upsert_visual_novel_run_snapshot", + move |connection, sender| { + connection + .procedures() + .upsert_visual_novel_run_snapshot_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_visual_novel_run_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -397,19 +427,22 @@ impl SpacetimeClient { created_at_micros: input.created_at_micros, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .append_visual_novel_runtime_history_entry_then( - procedure_input, - move |_, result| { - let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) - .and_then(map_visual_novel_history_procedure_result); - send_once(&sender, mapped); - }, - ); - }) + self.call_after_connect( + "append_visual_novel_runtime_history_entry", + move |connection, sender| { + connection + .procedures() + .append_visual_novel_runtime_history_entry_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_visual_novel_history_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } @@ -423,16 +456,19 @@ impl SpacetimeClient { owner_user_id, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .list_visual_novel_runtime_history_then(procedure_input, move |_, result| { - let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) - .and_then(map_visual_novel_history_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "list_visual_novel_runtime_history", + move |connection, sender| { + connection + .procedures() + .list_visual_novel_runtime_history_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_visual_novel_history_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -452,16 +488,19 @@ impl SpacetimeClient { occurred_at_micros: input.occurred_at_micros, }; - self.call_after_connect(move |connection, sender| { - connection - .procedures() - .record_visual_novel_runtime_event_then(procedure_input, move |_, result| { - let mapped = result - .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) - .and_then(map_visual_novel_runtime_event_procedure_result); - send_once(&sender, mapped); - }); - }) + self.call_after_connect( + "record_visual_novel_runtime_event", + move |connection, sender| { + connection + .procedures() + .record_visual_novel_runtime_event_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_visual_novel_runtime_event_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } } 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/ai/snapshots.rs b/server-rs/crates/spacetime-module/src/ai/snapshots.rs index f6a9284c..8ee4c0be 100644 --- a/server-rs/crates/spacetime-module/src/ai/snapshots.rs +++ b/server-rs/crates/spacetime-module/src/ai/snapshots.rs @@ -33,8 +33,8 @@ pub(crate) fn build_ai_task_snapshot_from_row( let mut stages = ctx .db .ai_task_stage() - .iter() - .filter(|stage| stage.task_id == row.task_id) + .by_ai_task_stage_task_id() + .filter(&row.task_id) .map(|stage| build_ai_task_stage_snapshot_from_row(&stage)) .collect::>(); stages.sort_by_key(|stage| stage.order); @@ -42,8 +42,8 @@ pub(crate) fn build_ai_task_snapshot_from_row( let mut result_references = ctx .db .ai_result_reference() - .iter() - .filter(|reference| reference.task_id == row.task_id) + .by_ai_result_reference_task_id() + .filter(&row.task_id) .map(|reference| build_ai_result_reference_snapshot_from_row(&reference)) .collect::>(); result_references.sort_by_key(|reference| reference.created_at_micros); diff --git a/server-rs/crates/spacetime-module/src/ai/stages.rs b/server-rs/crates/spacetime-module/src/ai/stages.rs index ed908daf..8ffea6f2 100644 --- a/server-rs/crates/spacetime-module/src/ai/stages.rs +++ b/server-rs/crates/spacetime-module/src/ai/stages.rs @@ -318,8 +318,8 @@ pub(crate) fn replace_ai_task_stages( let stage_ids = ctx .db .ai_task_stage() - .iter() - .filter(|row| row.task_id == task_id) + .by_ai_task_stage_task_id() + .filter(task_id) .map(|row| row.task_stage_id.clone()) .collect::>(); for stage_id in stage_ids { @@ -341,7 +341,8 @@ pub(crate) fn collect_ai_stage_text_output( let mut chunks = ctx .db .ai_text_chunk() - .iter() + .by_ai_text_chunk_task_id() + .filter(task_id) .filter(|row| row.task_id == task_id && row.stage_kind == stage_kind) .map(|row| build_ai_text_chunk_snapshot_from_row(&row)) .collect::>(); 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/asset_metadata/bindings.rs b/server-rs/crates/spacetime-module/src/asset_metadata/bindings.rs index 50a6c649..98bd9b4d 100644 --- a/server-rs/crates/spacetime-module/src/asset_metadata/bindings.rs +++ b/server-rs/crates/spacetime-module/src/asset_metadata/bindings.rs @@ -66,12 +66,16 @@ fn upsert_asset_entity_binding( return Err("asset_entity_binding.asset_object_id 对应的 asset_object 不存在".to_string()); } - // 首版绑定按 entity_kind + entity_id + slot 幂等定位,后续访问量明确后再改为组合索引扫描。 - let current = ctx.db.asset_entity_binding().iter().find(|row| { - row.entity_kind == input.entity_kind - && row.entity_id == input.entity_id - && row.slot == input.slot - }); + let current = ctx + .db + .asset_entity_binding() + .by_entity_slot() + .filter(( + input.entity_kind.as_str(), + input.entity_id.as_str(), + input.slot.as_str(), + )) + .next(); let snapshot = match current { Some(existing) => { diff --git a/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs b/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs index e6650242..01cb9a38 100644 --- a/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs +++ b/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs @@ -128,12 +128,12 @@ pub(crate) fn upsert_asset_object( ) .map_err(|error| error.to_string())?; - // 这里先保持最小可发布实现:查重语义已经冻结,后续再把实现优化回组合索引扫描。 let current = ctx .db .asset_object() - .iter() - .find(|row| row.bucket == input.bucket && row.object_key == input.object_key); + .by_bucket_object_key() + .filter((input.bucket.as_str(), input.object_key.as_str())) + .next(); let snapshot = match current { Some(existing) => { @@ -196,8 +196,9 @@ pub(crate) fn upsert_asset_object( pub(crate) fn has_asset_object(ctx: &ReducerContext, asset_object_id: &str) -> bool { ctx.db .asset_object() - .iter() - .any(|row| row.asset_object_id == asset_object_id) + .asset_object_id() + .find(&asset_object_id.to_string()) + .is_some() } fn list_asset_history( @@ -224,8 +225,8 @@ fn list_asset_history( let mut entries = ctx .db .asset_object() - .iter() - .filter(|row| row.asset_kind == asset_kind) + .asset_kind() + .filter(&asset_kind.to_string()) .map(|row| AssetHistoryEntrySnapshot { asset_object_id: row.asset_object_id, asset_kind: row.asset_kind, 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 93% rename from server-rs/crates/spacetime-module/src/bark_battle/mod.rs rename to server-rs/crates/spacetime-module/src/bark_battle.rs index d4afb89b..2d104b7e 100644 --- a/server-rs/crates/spacetime-module/src/bark_battle/mod.rs +++ b/server-rs/crates/spacetime-module/src/bark_battle.rs @@ -1,6 +1,5 @@ use crate::*; -use serde::Serialize; -use serde::de::DeserializeOwned; +use serde::{Serialize, de::DeserializeOwned}; use sha2::{Digest, Sha256}; pub(crate) mod tables; @@ -15,7 +14,7 @@ pub fn create_bark_battle_draft( input: BarkBattleDraftCreateInput, ) -> BarkBattleProcedureResult { match ctx.try_with_tx(|tx| create_bark_battle_draft_tx(tx, input.clone())) { - Ok(snapshot) => bark_battle_json_result(&snapshot), + Ok(snapshot) => bark_battle_draft_config_result(snapshot), Err(error) => bark_battle_error_result(error), } } @@ -26,7 +25,7 @@ pub fn update_bark_battle_draft_config( input: BarkBattleDraftConfigUpsertInput, ) -> BarkBattleProcedureResult { match ctx.try_with_tx(|tx| update_bark_battle_draft_config_tx(tx, input.clone())) { - Ok(snapshot) => bark_battle_json_result(&snapshot), + Ok(snapshot) => bark_battle_draft_config_result(snapshot), Err(error) => bark_battle_error_result(error), } } @@ -37,7 +36,7 @@ pub fn publish_bark_battle_work( input: BarkBattleWorkPublishInput, ) -> BarkBattleProcedureResult { match ctx.try_with_tx(|tx| publish_bark_battle_work_tx(tx, input.clone())) { - Ok(snapshot) => bark_battle_json_result(&snapshot), + Ok(snapshot) => bark_battle_runtime_config_result(snapshot), Err(error) => bark_battle_error_result(error), } } @@ -48,7 +47,7 @@ pub fn get_bark_battle_runtime_config( input: BarkBattleRuntimeConfigGetInput, ) -> BarkBattleProcedureResult { match ctx.try_with_tx(|tx| get_bark_battle_runtime_config_tx(tx, input.clone())) { - Ok(snapshot) => bark_battle_json_result(&snapshot), + Ok(snapshot) => bark_battle_runtime_config_result(snapshot), Err(error) => bark_battle_error_result(error), } } @@ -59,7 +58,7 @@ pub fn start_bark_battle_run( input: BarkBattleRunStartInput, ) -> BarkBattleProcedureResult { match ctx.try_with_tx(|tx| start_bark_battle_run_tx(tx, input.clone())) { - Ok(snapshot) => bark_battle_json_result(&snapshot), + Ok(snapshot) => bark_battle_run_result(snapshot), Err(error) => bark_battle_error_result(error), } } @@ -70,7 +69,7 @@ pub fn finish_bark_battle_run( input: BarkBattleRunFinishInput, ) -> BarkBattleProcedureResult { match ctx.try_with_tx(|tx| finish_bark_battle_run_tx(tx, input.clone())) { - Ok(snapshot) => bark_battle_json_result(&snapshot), + Ok(snapshot) => bark_battle_run_result(snapshot), Err(error) => bark_battle_error_result(error), } } @@ -81,7 +80,7 @@ pub fn get_bark_battle_run( input: BarkBattleRunGetInput, ) -> BarkBattleProcedureResult { match ctx.try_with_tx(|tx| get_bark_battle_run_tx(tx, input.clone())) { - Ok(snapshot) => bark_battle_json_result(&snapshot), + Ok(snapshot) => bark_battle_run_result(snapshot), Err(error) => bark_battle_error_result(error), } } @@ -584,10 +583,36 @@ fn validate_json(value: &str, field_name: &str) -> Result<( .map_err(|error| format!("bark_battle {field_name} JSON 无效: {error}")) } -fn bark_battle_json_result(value: &T) -> BarkBattleProcedureResult { +fn bark_battle_draft_config_result( + draft_config: BarkBattleDraftConfigSnapshot, +) -> BarkBattleProcedureResult { BarkBattleProcedureResult { ok: true, - row_json: Some(to_json_string(value)), + draft_config: Some(draft_config), + runtime_config: None, + run: None, + error_message: None, + } +} + +fn bark_battle_runtime_config_result( + runtime_config: BarkBattleRuntimeConfigSnapshot, +) -> BarkBattleProcedureResult { + BarkBattleProcedureResult { + ok: true, + draft_config: None, + runtime_config: Some(runtime_config), + run: None, + error_message: None, + } +} + +fn bark_battle_run_result(run: BarkBattleRunSnapshot) -> BarkBattleProcedureResult { + BarkBattleProcedureResult { + ok: true, + draft_config: None, + runtime_config: None, + run: Some(run), error_message: None, } } @@ -595,7 +620,9 @@ fn bark_battle_json_result(value: &T) -> BarkBattleProcedureResult fn bark_battle_error_result(error: String) -> BarkBattleProcedureResult { BarkBattleProcedureResult { ok: false, - row_json: None, + draft_config: None, + runtime_config: None, + run: None, error_message: Some(error), } } @@ -850,7 +877,21 @@ mod tests { let result = BarkBattleProcedureResult { ok: true, - row_json: Some(input.config_json.clone()), + draft_config: Some(BarkBattleDraftConfigSnapshot { + draft_id: input.draft_id.clone(), + owner_user_id: input.owner_user_id.clone(), + work_id: input.work_id.clone(), + config_version: input.config_version, + ruleset_version: input.ruleset_version.clone(), + difficulty_preset: input.difficulty_preset.clone(), + leaderboard_enabled: input.leaderboard_enabled, + config_json: input.config_json.clone(), + editor_state_json: "{}".to_string(), + created_at_micros: 1_700_000, + updated_at_micros: input.updated_at_micros, + }), + runtime_config: None, + run: None, error_message: None, }; diff --git a/server-rs/crates/spacetime-module/src/bark_battle/types.rs b/server-rs/crates/spacetime-module/src/bark_battle/types.rs index e26a2747..a1652d78 100644 --- a/server-rs/crates/spacetime-module/src/bark_battle/types.rs +++ b/server-rs/crates/spacetime-module/src/bark_battle/types.rs @@ -102,14 +102,16 @@ pub struct BarkBattleRunGetInput { pub owner_user_id: String, } -#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +#[derive(Clone, Debug, PartialEq, SpacetimeType)] pub struct BarkBattleProcedureResult { pub ok: bool, - pub row_json: Option, + pub draft_config: Option, + pub runtime_config: Option, + pub run: Option, pub error_message: Option, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct BarkBattleEditorConfigSnapshot { pub title: String, @@ -121,7 +123,7 @@ pub struct BarkBattleEditorConfigSnapshot { pub leaderboard_enabled: bool, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct BarkBattleDraftConfigSnapshot { pub draft_id: String, @@ -137,7 +139,7 @@ pub struct BarkBattleDraftConfigSnapshot { pub updated_at_micros: i64, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct BarkBattleRuntimeConfigSnapshot { pub work_id: String, @@ -153,7 +155,7 @@ pub struct BarkBattleRuntimeConfigSnapshot { pub updated_at_micros: i64, } -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct BarkBattleRunSnapshot { pub run_id: String, 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/big_fish/assets.rs b/server-rs/crates/spacetime-module/src/big_fish/assets.rs index 2f0f1fa4..1da68d3c 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/assets.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/assets.rs @@ -222,8 +222,8 @@ pub(crate) fn list_big_fish_asset_slots( let mut slots = ctx .db .big_fish_asset_slot() - .iter() - .filter(|slot| slot.session_id == session_id) + .by_big_fish_asset_session_id() + .filter(&session_id.to_string()) .map(|slot| BigFishAssetSlotSnapshot { slot_id: slot.slot_id, session_id: slot.session_id, diff --git a/server-rs/crates/spacetime-module/src/big_fish/runtime.rs b/server-rs/crates/spacetime-module/src/big_fish/runtime.rs index fefdadd4..6e0f56f8 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/runtime.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/runtime.rs @@ -16,12 +16,12 @@ pub fn start_big_fish_run( match ctx.try_with_tx(|tx| start_big_fish_run_tx(tx, input.clone())) { Ok(run) => BigFishRunProcedureResult { ok: true, - run_json: Some(serialize_big_fish_run_json(&run)), + run: Some(run), error_message: None, }, Err(message) => BigFishRunProcedureResult { ok: false, - run_json: None, + run: None, error_message: Some(message), }, } @@ -35,12 +35,12 @@ pub fn get_big_fish_run( match ctx.try_with_tx(|tx| get_big_fish_run_tx(tx, input.clone())) { Ok(run) => BigFishRunProcedureResult { ok: true, - run_json: Some(serialize_big_fish_run_json(&run)), + run: Some(run), error_message: None, }, Err(message) => BigFishRunProcedureResult { ok: false, - run_json: None, + run: None, error_message: Some(message), }, } @@ -54,12 +54,12 @@ pub fn submit_big_fish_input( match ctx.try_with_tx(|tx| submit_big_fish_input_tx(tx, input.clone())) { Ok(run) => BigFishRunProcedureResult { ok: true, - run_json: Some(serialize_big_fish_run_json(&run)), + run: Some(run), error_message: None, }, Err(message) => BigFishRunProcedureResult { ok: false, - run_json: None, + run: None, error_message: Some(message), }, } @@ -225,7 +225,3 @@ fn replace_big_fish_runtime_run( }); Ok(()) } - -fn serialize_big_fish_run_json(run: &BigFishRuntimeSnapshot) -> String { - serialize_runtime_snapshot(run).unwrap_or_else(|_| "{}".to_string()) -} diff --git a/server-rs/crates/spacetime-module/src/big_fish/session.rs b/server-rs/crates/spacetime-module/src/big_fish/session.rs index b7815e0a..5ae78d2a 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/session.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/session.rs @@ -8,9 +8,42 @@ use crate::runtime::{ }; use crate::*; use module_big_fish::{EvaluateBigFishPublishReadinessCommand, evaluate_publish_readiness}; +use spacetimedb::AnonymousViewContext; const INITIAL_BIG_FISH_CREATION_PROGRESS_PERCENT: u32 = 0; +/// 大鱼吃小鱼公开广场列表投影。 +/// +/// 公开列表从已发布 creation session 生成卡片字段;7 日播放数由 +/// `api-server` 订阅 `public_work_play_daily_stat` 后在本地聚合。 +#[spacetimedb::view(accessor = big_fish_gallery_view, public)] +pub fn big_fish_gallery_view(ctx: &AnonymousViewContext) -> Vec { + let mut items = ctx + .db + .big_fish_creation_session() + .by_big_fish_session_stage() + .filter(BigFishCreationStage::Published) + .filter_map(|row| match build_big_fish_gallery_view_row(ctx, &row) { + Ok(snapshot) => Some(snapshot), + Err(error) => { + log::warn!( + "大鱼吃小鱼公开广场 view 跳过损坏的作品投影 session_id={}: {}", + row.session_id, + error + ); + None + } + }) + .collect::>(); + items.sort_by(|left, right| { + right + .updated_at_micros + .cmp(&left.updated_at_micros) + .then_with(|| left.source_session_id.cmp(&right.source_session_id)) + }); + items +} + #[spacetimedb::procedure] pub fn create_big_fish_session( ctx: &mut ProcedureContext, @@ -55,21 +88,14 @@ pub fn list_big_fish_works( input: BigFishWorksListInput, ) -> BigFishWorksProcedureResult { match ctx.try_with_tx(|tx| list_big_fish_works_tx(tx, input.clone())) { - Ok(items) => match serde_json::to_string(&items) { - Ok(items_json) => BigFishWorksProcedureResult { - ok: true, - items_json: Some(items_json), - error_message: None, - }, - Err(error) => BigFishWorksProcedureResult { - ok: false, - items_json: None, - error_message: Some(error.to_string()), - }, + Ok(items) => BigFishWorksProcedureResult { + ok: true, + items, + error_message: None, }, Err(message) => BigFishWorksProcedureResult { ok: false, - items_json: None, + items: Vec::new(), error_message: Some(message), }, } @@ -81,21 +107,14 @@ pub fn delete_big_fish_work( input: BigFishWorkDeleteInput, ) -> BigFishWorksProcedureResult { match ctx.try_with_tx(|tx| delete_big_fish_work_tx(tx, input.clone())) { - Ok(items) => match serde_json::to_string(&items) { - Ok(items_json) => BigFishWorksProcedureResult { - ok: true, - items_json: Some(items_json), - error_message: None, - }, - Err(error) => BigFishWorksProcedureResult { - ok: false, - items_json: None, - error_message: Some(error.to_string()), - }, + Ok(items) => BigFishWorksProcedureResult { + ok: true, + items, + error_message: None, }, Err(message) => BigFishWorksProcedureResult { ok: false, - items_json: None, + items: Vec::new(), error_message: Some(message), }, } @@ -107,21 +126,14 @@ pub fn record_big_fish_play( input: BigFishPlayRecordInput, ) -> BigFishWorksProcedureResult { match ctx.try_with_tx(|tx| record_big_fish_play_tx(tx, input.clone())) { - Ok(items) => match serde_json::to_string(&items) { - Ok(items_json) => BigFishWorksProcedureResult { - ok: true, - items_json: Some(items_json), - error_message: None, - }, - Err(error) => BigFishWorksProcedureResult { - ok: false, - items_json: None, - error_message: Some(error.to_string()), - }, + Ok(items) => BigFishWorksProcedureResult { + ok: true, + items, + error_message: None, }, Err(message) => BigFishWorksProcedureResult { ok: false, - items_json: None, + items: Vec::new(), error_message: Some(message), }, } @@ -133,21 +145,14 @@ pub fn record_big_fish_like( input: BigFishWorkLikeRecordInput, ) -> BigFishWorksProcedureResult { match ctx.try_with_tx(|tx| record_big_fish_like_tx(tx, input.clone())) { - Ok(items) => match serde_json::to_string(&items) { - Ok(items_json) => BigFishWorksProcedureResult { - ok: true, - items_json: Some(items_json), - error_message: None, - }, - Err(error) => BigFishWorksProcedureResult { - ok: false, - items_json: None, - error_message: Some(error.to_string()), - }, + Ok(items) => BigFishWorksProcedureResult { + ok: true, + items, + error_message: None, }, Err(message) => BigFishWorksProcedureResult { ok: false, - items_json: None, + items: Vec::new(), error_message: Some(message), }, } @@ -321,16 +326,20 @@ pub(crate) fn list_big_fish_works_tx( validate_works_list_input(&input).map_err(|error| error.to_string())?; let now_micros = ctx.timestamp.to_micros_since_unix_epoch(); - let mut items = ctx + let rows = ctx .db .big_fish_creation_session() + .by_big_fish_session_owner_user_id() + .filter(&input.owner_user_id) + .collect::>(); + let mut items = rows .iter() .filter(|row| { if input.published_only { return row.stage == BigFishCreationStage::Published; } - row.owner_user_id == input.owner_user_id && should_include_big_fish_work(ctx, row) + should_include_big_fish_work(ctx, row) }) .map(|row| build_big_fish_work_summary(ctx, &row, now_micros)) .collect::, _>>()?; @@ -349,10 +358,11 @@ fn should_include_big_fish_work(ctx: &ReducerContext, row: &BigFishCreationSessi return true; } - ctx.db.big_fish_agent_message().iter().any(|message| { - message.session_id == row.session_id - && matches!(message.role, BigFishAgentMessageRole::User) - }) + ctx.db + .big_fish_agent_message() + .by_big_fish_message_session_id() + .filter(&row.session_id) + .any(|message| matches!(message.role, BigFishAgentMessageRole::User)) } fn big_fish_session_has_direct_work_content(row: &BigFishCreationSession) -> bool { @@ -387,8 +397,8 @@ pub(crate) fn delete_big_fish_work_tx( for message in ctx .db .big_fish_agent_message() - .iter() - .filter(|row| row.session_id == input.session_id) + .by_big_fish_message_session_id() + .filter(&input.session_id) .collect::>() { ctx.db @@ -399,8 +409,8 @@ pub(crate) fn delete_big_fish_work_tx( for slot in ctx .db .big_fish_asset_slot() - .iter() - .filter(|row| row.session_id == input.session_id) + .by_big_fish_asset_session_id() + .filter(&input.session_id) .collect::>() { ctx.db.big_fish_asset_slot().slot_id().delete(&slot.slot_id); @@ -408,8 +418,8 @@ pub(crate) fn delete_big_fish_work_tx( for run in ctx .db .big_fish_runtime_run() - .iter() - .filter(|row| row.session_id == input.session_id) + .by_big_fish_run_session_id() + .filter(&input.session_id) .collect::>() { ctx.db.big_fish_runtime_run().run_id().delete(&run.run_id); @@ -952,8 +962,8 @@ pub(crate) fn build_big_fish_session_snapshot( let mut messages = ctx .db .big_fish_agent_message() - .iter() - .filter(|message| message.session_id == row.session_id) + .by_big_fish_message_session_id() + .filter(&row.session_id) .map(|message| BigFishAgentMessageSnapshot { message_id: message.message_id, session_id: message.session_id, @@ -988,6 +998,16 @@ pub(crate) fn build_big_fish_work_summary( ctx: &ReducerContext, row: &BigFishCreationSession, now_micros: i64, +) -> Result { + let mut summary = build_big_fish_work_summary_without_recent_count(ctx, row)?; + summary.recent_play_count_7d = + count_recent_public_work_plays(ctx, "big-fish", &row.session_id, now_micros); + Ok(summary) +} + +fn build_big_fish_work_summary_without_recent_count( + ctx: &ReducerContext, + row: &BigFishCreationSession, ) -> Result { let draft = row .draft_json @@ -1052,12 +1072,7 @@ pub(crate) fn build_big_fish_work_summary( play_count: row.play_count, remix_count: row.remix_count, like_count: row.like_count, - recent_play_count_7d: count_recent_public_work_plays( - ctx, - "big-fish", - &row.session_id, - now_micros, - ), + recent_play_count_7d: 0, published_at_micros: row .published_at .or_else(|| (row.stage == BigFishCreationStage::Published).then_some(row.updated_at)) @@ -1065,6 +1080,113 @@ pub(crate) fn build_big_fish_work_summary( }) } +fn build_big_fish_gallery_view_row( + ctx: &AnonymousViewContext, + row: &BigFishCreationSession, +) -> Result { + let draft = row + .draft_json + .as_deref() + .map(deserialize_draft) + .transpose() + .map_err(|error| format!("big_fish.draft_json 非法: {error}"))?; + let asset_slots = list_big_fish_asset_slots_for_view(ctx, &row.session_id); + let coverage = build_asset_coverage(draft.as_ref(), &asset_slots); + let cover_image_src = asset_slots + .iter() + .find(|slot| slot.asset_kind == BigFishAssetKind::StageBackground) + .and_then(|slot| slot.asset_url.clone()) + .or_else(|| { + asset_slots + .iter() + .find(|slot| slot.asset_kind == BigFishAssetKind::LevelMainImage) + .and_then(|slot| slot.asset_url.clone()) + }); + let title = draft + .as_ref() + .map(|value| value.title.clone()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "未命名大鱼草稿".to_string()); + let subtitle = draft + .as_ref() + .map(|value| value.subtitle.clone()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "等待整理玩法草稿".to_string()); + let summary = draft + .as_ref() + .map(|value| value.core_fun.clone()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| { + row.last_assistant_reply + .clone() + .unwrap_or_else(|| "继续补齐锚点后即可生成玩法草稿。".to_string()) + }); + + Ok(BigFishWorkSummarySnapshot { + work_id: format!("big-fish-work-{}", row.session_id), + source_session_id: row.session_id.clone(), + owner_user_id: row.owner_user_id.clone(), + title, + subtitle, + summary, + cover_image_src, + status: if row.stage == BigFishCreationStage::Published { + "published".to_string() + } else { + "draft".to_string() + }, + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + publish_ready: coverage.publish_ready, + level_count: draft + .as_ref() + .map(|value| value.runtime_params.level_count) + .unwrap_or(BIG_FISH_DEFAULT_LEVEL_COUNT), + level_main_image_ready_count: coverage.level_main_image_ready_count, + level_motion_ready_count: coverage.level_motion_ready_count, + background_ready: coverage.background_ready, + play_count: row.play_count, + remix_count: row.remix_count, + like_count: row.like_count, + recent_play_count_7d: 0, + published_at_micros: row + .published_at + .or_else(|| (row.stage == BigFishCreationStage::Published).then_some(row.updated_at)) + .map(|value| value.to_micros_since_unix_epoch()), + }) +} + +fn list_big_fish_asset_slots_for_view( + ctx: &AnonymousViewContext, + session_id: &str, +) -> Vec { + let mut slots = ctx + .db + .big_fish_asset_slot() + .by_big_fish_asset_session_id() + .filter(session_id) + .map(|slot| BigFishAssetSlotSnapshot { + slot_id: slot.slot_id, + session_id: slot.session_id, + asset_kind: slot.asset_kind, + level: slot.level, + motion_key: slot.motion_key, + status: slot.status, + asset_url: slot.asset_url, + prompt_snapshot: slot.prompt_snapshot, + updated_at_micros: slot.updated_at.to_micros_since_unix_epoch(), + }) + .collect::>(); + slots.sort_by_key(|slot| { + ( + slot.level.unwrap_or(0), + slot.asset_kind.as_str().to_string(), + slot.motion_key.clone().unwrap_or_default(), + slot.slot_id.clone(), + ) + }); + slots +} + fn build_public_big_fish_gallery_list_input() -> BigFishWorksListInput { BigFishWorksListInput { // 中文注释:published_only 分支不会按 owner 过滤;非空占位用于兼容旧部署模块的前置校验。 diff --git a/server-rs/crates/spacetime-module/src/big_fish/tables.rs b/server-rs/crates/spacetime-module/src/big_fish/tables.rs index 997371e8..fa480120 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/tables.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/tables.rs @@ -2,7 +2,8 @@ use crate::*; #[spacetimedb::table( accessor = big_fish_creation_session, - index(accessor = by_big_fish_session_owner_user_id, btree(columns = [owner_user_id])) + index(accessor = by_big_fish_session_owner_user_id, btree(columns = [owner_user_id])), + index(accessor = by_big_fish_session_stage, btree(columns = [stage])) )] pub struct BigFishCreationSession { #[primary_key] 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 98% rename from server-rs/crates/spacetime-module/src/custom_world/mod.rs rename to server-rs/crates/spacetime-module/src/custom_world.rs index da42008c..36228bfe 100644 --- a/server-rs/crates/spacetime-module/src/custom_world/mod.rs +++ b/server-rs/crates/spacetime-module/src/custom_world.rs @@ -436,7 +436,8 @@ fn delete_custom_world_agent_session_tx( let published_profile = ctx .db .custom_world_profile() - .iter() + .by_custom_world_profile_owner_user_id() + .filter(&input.owner_user_id) .find(|row| { row.owner_user_id == input.owner_user_id && row.source_agent_session_id.as_deref() == Some(input.session_id.as_str()) @@ -471,8 +472,8 @@ fn delete_custom_world_agent_session_tx( for message in ctx .db .custom_world_agent_message() - .iter() - .filter(|row| row.session_id == input.session_id) + .by_custom_world_agent_message_session_id() + .filter(&input.session_id) .collect::>() { ctx.db @@ -483,8 +484,8 @@ fn delete_custom_world_agent_session_tx( for operation in ctx .db .custom_world_agent_operation() - .iter() - .filter(|row| row.session_id == input.session_id) + .by_custom_world_agent_operation_session_id() + .filter(&input.session_id) .collect::>() { ctx.db @@ -495,8 +496,8 @@ fn delete_custom_world_agent_session_tx( for card in ctx .db .custom_world_draft_card() - .iter() - .filter(|row| row.session_id == input.session_id) + .by_custom_world_draft_card_session_id() + .filter(&input.session_id) .collect::>() { ctx.db @@ -1184,9 +1185,17 @@ fn upsert_custom_world_profile_record( .source_agent_session_id .as_ref() .and_then(|session_id| { - ctx.db.custom_world_profile().iter().find(|row| { - is_same_agent_draft_profile_candidate(row, &input.owner_user_id, session_id) - }) + ctx.db + .custom_world_profile() + .by_custom_world_profile_owner_user_id() + .filter(&input.owner_user_id) + .find(|row| { + is_same_agent_draft_profile_candidate( + row, + &input.owner_user_id, + session_id, + ) + }) }) }); @@ -1534,8 +1543,9 @@ fn list_custom_world_profile_snapshots( let mut entries = ctx .db .custom_world_profile() - .iter() - .filter(|row| row.owner_user_id == input.owner_user_id && row.deleted_at.is_none()) + .by_custom_world_profile_owner_user_id() + .filter(&input.owner_user_id) + .filter(|row| row.deleted_at.is_none()) .map(|row| build_custom_world_profile_snapshot(&row)) .collect::>(); @@ -1676,8 +1686,9 @@ fn get_custom_world_gallery_detail_record_by_code( let gallery_entry = ctx .db .custom_world_gallery_entry() - .iter() - .find(|row| row.public_work_code == normalized_public_work_code); + .by_custom_world_gallery_public_work_code() + .filter(&normalized_public_work_code) + .next(); let profile = gallery_entry.as_ref().and_then(|row| { ctx.db @@ -1974,9 +1985,14 @@ fn list_custom_world_work_snapshots( let mut items = Vec::new(); let mut active_agent_session_ids = HashSet::new(); - for session in ctx.db.custom_world_agent_session().iter().filter(|row| { - row.owner_user_id == input.owner_user_id - && row.stage != RpgAgentStage::Published + let sessions = ctx + .db + .custom_world_agent_session() + .by_custom_world_agent_session_owner_user_id() + .filter(&input.owner_user_id) + .collect::>(); + for session in sessions.iter().filter(|row| { + row.stage != RpgAgentStage::Published && should_include_custom_world_agent_session_work(ctx, row) }) { active_agent_session_ids.insert(session.session_id.clone()); @@ -2021,8 +2037,9 @@ fn list_custom_world_work_snapshots( for profile in ctx .db .custom_world_profile() - .iter() - .filter(|row| row.owner_user_id == input.owner_user_id && row.deleted_at.is_none()) + .by_custom_world_profile_owner_user_id() + .filter(&input.owner_user_id) + .filter(|row| row.deleted_at.is_none()) .filter(|row| should_include_custom_world_profile_work(row, &active_agent_session_ids)) { items.push(CustomWorldWorkSummarySnapshot { @@ -2086,16 +2103,20 @@ fn should_include_custom_world_agent_session_work( return true; } - if ctx.db.custom_world_agent_message().iter().any(|message| { - message.session_id == session.session_id - && matches!(message.role, RpgAgentMessageRole::User) - }) { + if ctx + .db + .custom_world_agent_message() + .by_custom_world_agent_message_session_id() + .filter(&session.session_id) + .any(|message| matches!(message.role, RpgAgentMessageRole::User)) + { return true; } ctx.db .custom_world_draft_card() - .iter() + .by_custom_world_draft_card_session_id() + .filter(&session.session_id) .any(|card| card.session_id == session.session_id) } @@ -3446,10 +3467,12 @@ fn update_role_asset_cards( label: &str, updated_at_micros: i64, ) { - for card in - ctx.db.custom_world_draft_card().iter().filter(|row| { - row.session_id == session_id && row.kind == RpgAgentDraftCardKind::Character - }) + for card in ctx + .db + .custom_world_draft_card() + .by_custom_world_draft_card_session_id() + .filter(&session_id.to_string()) + .filter(|row| row.kind == RpgAgentDraftCardKind::Character) { replace_custom_world_draft_card( ctx, @@ -4590,8 +4613,8 @@ fn resolve_session_work_counts( for card in ctx .db .custom_world_draft_card() - .iter() - .filter(|row| row.session_id == session.session_id) + .by_custom_world_draft_card_session_id() + .filter(&session.session_id) { match card.kind { RpgAgentDraftCardKind::Character => { @@ -4827,11 +4850,9 @@ fn sync_missing_custom_world_gallery_entries(ctx: &ReducerContext) -> Result<(), let published_profiles = ctx .db .custom_world_profile() - .iter() - .filter(|profile| { - profile.publication_status == CustomWorldPublicationStatus::Published - && profile.deleted_at.is_none() - }) + .by_custom_world_profile_publication_status() + .filter(CustomWorldPublicationStatus::Published) + .filter(|profile| profile.deleted_at.is_none()) .collect::>(); for profile in published_profiles { @@ -4973,8 +4994,8 @@ fn build_custom_world_agent_session_snapshot( let mut messages = ctx .db .custom_world_agent_message() - .iter() - .filter(|message| message.session_id == row.session_id) + .by_custom_world_agent_message_session_id() + .filter(&row.session_id) .map(|message| build_custom_world_agent_message_snapshot(&message)) .collect::>(); messages.sort_by_key(|message| (message.created_at_micros, message.message_id.clone())); @@ -4982,8 +5003,8 @@ fn build_custom_world_agent_session_snapshot( let mut draft_cards = ctx .db .custom_world_draft_card() - .iter() - .filter(|card| card.session_id == row.session_id) + .by_custom_world_draft_card_session_id() + .filter(&row.session_id) .map(|card| build_custom_world_draft_card_snapshot(&card)) .collect::>(); draft_cards.sort_by_key(|card| (card.created_at_micros, card.card_id.clone())); @@ -4991,8 +5012,8 @@ fn build_custom_world_agent_session_snapshot( let mut operations = ctx .db .custom_world_agent_operation() - .iter() - .filter(|operation| operation.session_id == row.session_id) + .by_custom_world_agent_operation_session_id() + .filter(&row.session_id) .map(|operation| build_custom_world_agent_operation_snapshot(&operation)) .collect::>(); operations diff --git a/server-rs/crates/spacetime-module/src/gameplay/mod.rs b/server-rs/crates/spacetime-module/src/gameplay.rs similarity index 99% rename from server-rs/crates/spacetime-module/src/gameplay/mod.rs rename to server-rs/crates/spacetime-module/src/gameplay.rs index db62e53a..5202342f 100644 --- a/server-rs/crates/spacetime-module/src/gameplay/mod.rs +++ b/server-rs/crates/spacetime-module/src/gameplay.rs @@ -415,11 +415,9 @@ fn apply_inventory_mutation_tx( let current_slots = ctx .db .inventory_slot() - .iter() - .filter(|slot| { - slot.runtime_session_id == input.runtime_session_id - && slot.actor_user_id == input.actor_user_id - }) + .by_inventory_runtime_session_id() + .filter(&input.runtime_session_id) + .filter(|slot| slot.actor_user_id == input.actor_user_id) .map(|row| build_inventory_slot_snapshot_from_row(&row)) .collect::>(); @@ -587,11 +585,9 @@ fn get_runtime_inventory_state_tx( let slots = ctx .db .inventory_slot() - .iter() - .filter(|row| { - row.runtime_session_id == validated_input.runtime_session_id - && row.actor_user_id == validated_input.actor_user_id - }) + .by_inventory_runtime_session_id() + .filter(&validated_input.runtime_session_id) + .filter(|row| row.actor_user_id == validated_input.actor_user_id) .map(|row| build_inventory_slot_snapshot_from_row(&row)) .collect::>(); @@ -926,8 +922,8 @@ fn get_story_session_state_tx( let mut events = ctx .db .story_event() - .iter() - .filter(|row| row.story_session_id == input.story_session_id) + .by_story_session_id() + .filter(&input.story_session_id) .map(|row| build_story_event_snapshot_from_row(&row)) .collect::>(); events.sort_by_key(|event| (event.created_at_micros, event.event_id.clone())); @@ -1439,11 +1435,9 @@ fn inventory_reward_source_already_granted( ctx.db .inventory_slot() - .iter() - .filter(|row| { - row.runtime_session_id == first_mutation.runtime_session_id - && row.actor_user_id == first_mutation.actor_user_id - }) + .by_inventory_runtime_session_id() + .filter(&first_mutation.runtime_session_id) + .filter(|row| row.actor_user_id == first_mutation.actor_user_id) .any(|row| row.source_reference_id.as_deref() == Some(source_reference_id)) } diff --git a/server-rs/crates/spacetime-module/src/match3d/mod.rs b/server-rs/crates/spacetime-module/src/match3d.rs similarity index 93% rename from server-rs/crates/spacetime-module/src/match3d/mod.rs rename to server-rs/crates/spacetime-module/src/match3d.rs index a4ed030e..b4154fc2 100644 --- a/server-rs/crates/spacetime-module/src/match3d/mod.rs +++ b/server-rs/crates/spacetime-module/src/match3d.rs @@ -19,6 +19,62 @@ use module_match3d::{ use serde::Serialize; use serde::de::DeserializeOwned; use serde_json::Value; +use spacetimedb::AnonymousViewContext; + +/// 抓大鹅公开广场列表投影。 +/// +/// `match3d_work_profile` 是玩法源表,HTTP gallery 只订阅这个轻量 view, +/// 避免每个公开列表请求重新调用 procedure 扫描和组装全量列表。 +#[spacetimedb::view(accessor = match3d_gallery_view, public)] +pub fn match3d_gallery_view(ctx: &AnonymousViewContext) -> Vec { + let mut items = ctx + .db + .match3d_work_profile() + .by_match3d_work_publication_status() + .filter(MATCH3D_PUBLICATION_PUBLISHED) + .filter_map(|row| match build_gallery_view_row(&row) { + Ok(item) => Some(item), + Err(error) => { + log::warn!( + "抓大鹅公开广场 view 跳过损坏的作品投影 profile_id={}: {}", + row.profile_id, + error + ); + None + } + }) + .collect::>(); + items.sort_by(|left, right| { + right + .updated_at_micros + .cmp(&left.updated_at_micros) + .then_with(|| left.profile_id.cmp(&right.profile_id)) + }); + items +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DGalleryViewRow { + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: String, + pub author_display_name: String, + pub game_name: String, + pub theme_text: String, + pub summary_text: String, + pub tags: Vec, + pub cover_image_src: String, + pub cover_asset_id: String, + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, + pub publication_status: String, + pub publish_ready: bool, + pub play_count: u32, + pub updated_at_micros: i64, + pub published_at_micros: Option, + pub generated_item_assets_json: Option, +} #[spacetimedb::procedure] pub fn create_match3d_agent_session( @@ -105,12 +161,12 @@ pub fn list_match3d_works( match ctx.try_with_tx(|tx| list_match3d_works_tx(tx, input.clone())) { Ok(items) => Match3DWorksProcedureResult { ok: true, - items_json: Some(to_json_string(&items)), + items, error_message: None, }, Err(message) => Match3DWorksProcedureResult { ok: false, - items_json: None, + items: Vec::new(), error_message: Some(message), }, } @@ -135,12 +191,12 @@ pub fn delete_match3d_work( match ctx.try_with_tx(|tx| delete_match3d_work_tx(tx, input.clone())) { Ok(items) => Match3DWorksProcedureResult { ok: true, - items_json: Some(to_json_string(&items)), + items, error_message: None, }, Err(message) => Match3DWorksProcedureResult { ok: false, - items_json: None, + items: Vec::new(), error_message: Some(message), }, } @@ -178,7 +234,7 @@ pub fn click_match3d_item( Err(message) => Match3DClickItemProcedureResult { ok: false, status: MATCH3D_CLICK_REJECTED_NOT_CLICKABLE.to_string(), - run_json: None, + run: None, accepted_item_instance_id: None, cleared_item_instance_ids: Vec::new(), failure_reason: None, @@ -459,6 +515,11 @@ fn compile_match3d_draft_tx( config.theme_text.as_str(), ); let summary_text = resolve_compile_summary_text(&input.summary_text, existing_work.as_ref()); + let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros); + let generated_item_assets_json = resolve_generated_item_assets_json_for_compile( + input.generated_item_assets_json.as_deref(), + existing_work.as_ref(), + )?; let draft = Match3DDraftSnapshot { profile_id: input.profile_id.clone(), game_name: game_name.clone(), @@ -467,12 +528,9 @@ fn compile_match3d_draft_tx( tags: tags.clone(), clear_count: config.clear_count, difficulty: config.difficulty, + // 中文注释:草稿响应本身也携带生成素材快照,避免 HTTP facade 回读 work 详情失败时丢失背景/容器图。 + generated_item_assets_json: generated_item_assets_json.clone(), }; - let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros); - let generated_item_assets_json = resolve_generated_item_assets_json_for_compile( - input.generated_item_assets_json.as_deref(), - existing_work.as_ref(), - )?; let previous_publication_status = existing_work .as_ref() .map(|work| work.publication_status.clone()) @@ -632,17 +690,22 @@ fn list_match3d_works_tx( ctx: &ReducerContext, input: Match3DWorksListInput, ) -> Result, String> { - let mut items = ctx - .db - .match3d_work_profile() + let rows = if input.published_only { + ctx.db + .match3d_work_profile() + .by_match3d_work_publication_status() + .filter(&MATCH3D_PUBLICATION_PUBLISHED.to_string()) + .collect::>() + } else { + require_non_empty(&input.owner_user_id, "match3d owner_user_id")?; + ctx.db + .match3d_work_profile() + .by_match3d_work_owner_user_id() + .filter(&input.owner_user_id) + .collect::>() + }; + let mut items = rows .iter() - .filter(|row| { - if input.published_only { - row.publication_status == MATCH3D_PUBLICATION_PUBLISHED - } else { - row.owner_user_id == input.owner_user_id - } - }) .map(|row| build_work_snapshot(&row)) .collect::, _>>()?; items.sort_by(|left, right| { @@ -683,10 +746,9 @@ fn delete_match3d_work_tx( for run in ctx .db .match3d_runtime_run() - .iter() - .filter(|row| { - row.profile_id == input.profile_id && row.owner_user_id == input.owner_user_id - }) + .by_match3d_run_profile_id() + .filter(&input.profile_id) + .filter(|row| row.owner_user_id == input.owner_user_id) .collect::>() { ctx.db.match3d_runtime_run().run_id().delete(&run.run_id); @@ -929,8 +991,8 @@ fn build_session_snapshot( let mut messages = ctx .db .match3d_agent_message() - .iter() - .filter(|message| message.session_id == row.session_id) + .by_match3d_agent_message_session_id() + .filter(&row.session_id) .map(|message| Match3DAgentMessageSnapshot { message_id: message.message_id, session_id: message.session_id, @@ -1002,6 +1064,35 @@ fn build_work_snapshot(row: &Match3DWorkProfileRow) -> Result Result { + let config = parse_config(&row.config_json)?; + Ok(Match3DGalleryViewRow { + profile_id: row.profile_id.clone(), + owner_user_id: row.owner_user_id.clone(), + source_session_id: row.source_session_id.clone(), + author_display_name: row.author_display_name.clone(), + game_name: row.game_name.clone(), + theme_text: row.theme_text.clone(), + summary_text: row.summary_text.clone(), + tags: parse_tags(&row.tags_json)?, + cover_image_src: row.cover_image_src.clone(), + cover_asset_id: row.cover_asset_id.clone(), + reference_image_src: config.reference_image_src, + clear_count: row.clear_count, + difficulty: row.difficulty, + publication_status: row.publication_status.clone(), + publish_ready: is_work_publish_ready(row), + play_count: row.play_count, + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + published_at_micros: row + .published_at + .map(|value| value.to_micros_since_unix_epoch()), + generated_item_assets_json: normalize_generated_item_assets_json( + row.generated_item_assets_json.as_deref(), + )?, + }) +} + fn build_initial_run_snapshot( run_id: &str, work: &Match3DWorkProfileRow, @@ -1154,10 +1245,10 @@ fn click_result( Match3DClickItemProcedureResult { ok: true, status: status.to_string(), - run_json: Some(to_json_string(&snapshot)), + failure_reason: snapshot.failure_reason.clone(), + run: Some(snapshot), accepted_item_instance_id, cleared_item_instance_ids, - failure_reason: snapshot.failure_reason, error_message: None, } } @@ -1715,7 +1806,7 @@ fn to_json_string(value: &T) -> String { fn session_result(session: Match3DAgentSessionSnapshot) -> Match3DAgentSessionProcedureResult { Match3DAgentSessionProcedureResult { ok: true, - session_json: Some(to_json_string(&session)), + session: Some(session), error_message: None, } } @@ -1723,7 +1814,7 @@ fn session_result(session: Match3DAgentSessionSnapshot) -> Match3DAgentSessionPr fn session_error(message: String) -> Match3DAgentSessionProcedureResult { Match3DAgentSessionProcedureResult { ok: false, - session_json: None, + session: None, error_message: Some(message), } } @@ -1731,7 +1822,7 @@ fn session_error(message: String) -> Match3DAgentSessionProcedureResult { fn work_result(work: Match3DWorkSnapshot) -> Match3DWorkProcedureResult { Match3DWorkProcedureResult { ok: true, - work_json: Some(to_json_string(&work)), + work: Some(work), error_message: None, } } @@ -1739,7 +1830,7 @@ fn work_result(work: Match3DWorkSnapshot) -> Match3DWorkProcedureResult { fn work_error(message: String) -> Match3DWorkProcedureResult { Match3DWorkProcedureResult { ok: false, - work_json: None, + work: None, error_message: Some(message), } } @@ -1747,7 +1838,7 @@ fn work_error(message: String) -> Match3DWorkProcedureResult { fn run_result(run: Match3DRunSnapshot) -> Match3DRunProcedureResult { Match3DRunProcedureResult { ok: true, - run_json: Some(to_json_string(&run)), + run: Some(run), error_message: None, } } @@ -1755,7 +1846,7 @@ fn run_result(run: Match3DRunSnapshot) -> Match3DRunProcedureResult { fn run_error(message: String) -> Match3DRunProcedureResult { Match3DRunProcedureResult { ok: false, - run_json: None, + run: None, error_message: Some(message), } } @@ -1889,6 +1980,31 @@ mod tests { ); } + #[test] + fn match3d_draft_snapshot_keeps_generated_item_assets_json() { + let draft = Match3DDraftSnapshot { + profile_id: "profile-1".to_string(), + game_name: "水果抓大鹅".to_string(), + theme_text: "水果".to_string(), + summary_text: "水果主题".to_string(), + tags: vec!["水果".to_string()], + clear_count: 3, + difficulty: 3, + generated_item_assets_json: Some( + r#"[{"itemId":"match3d-item-1","itemName":"草莓","backgroundAsset":{"prompt":"果园背景","imageSrc":"/generated-match3d-assets/session/profile/background/background.png","containerImageSrc":"/generated-match3d-assets/session/profile/ui-container/container.png","status":"image_ready"},"status":"image_ready"}]"# + .to_string(), + ), + }; + + let row_json = to_json_string(&draft); + let restored = parse_json::(&row_json, "match3d draft_json").unwrap(); + + assert_eq!( + restored.generated_item_assets_json.as_deref(), + draft.generated_item_assets_json.as_deref() + ); + } + #[test] fn match3d_work_update_preserves_assets_and_allows_empty_summary() { let existing = Match3DWorkProfileRow { diff --git a/server-rs/crates/spacetime-module/src/match3d/types.rs b/server-rs/crates/spacetime-module/src/match3d/types.rs index 292cc877..4d17b024 100644 --- a/server-rs/crates/spacetime-module/src/match3d/types.rs +++ b/server-rs/crates/spacetime-module/src/match3d/types.rs @@ -182,43 +182,43 @@ pub struct Match3DRunTimeUpInput { #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct Match3DAgentSessionProcedureResult { pub ok: bool, - pub session_json: Option, + pub session: Option, pub error_message: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct Match3DWorkProcedureResult { pub ok: bool, - pub work_json: Option, + pub work: Option, pub error_message: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct Match3DWorksProcedureResult { pub ok: bool, - pub items_json: Option, + pub items: Vec, pub error_message: Option, } -#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +#[derive(Clone, Debug, PartialEq, SpacetimeType)] pub struct Match3DRunProcedureResult { pub ok: bool, - pub run_json: Option, + pub run: Option, pub error_message: Option, } -#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +#[derive(Clone, Debug, PartialEq, SpacetimeType)] pub struct Match3DClickItemProcedureResult { pub ok: bool, pub status: String, - pub run_json: Option, + pub run: Option, pub accepted_item_instance_id: Option, pub cleared_item_instance_ids: Vec, pub failure_reason: Option, pub error_message: Option, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct Match3DCreatorConfigSnapshot { pub theme_text: String, @@ -235,7 +235,7 @@ pub struct Match3DCreatorConfigSnapshot { pub generate_click_sound: bool, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct Match3DAgentMessageSnapshot { pub message_id: String, @@ -246,7 +246,7 @@ pub struct Match3DAgentMessageSnapshot { pub created_at_micros: i64, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct Match3DDraftSnapshot { pub profile_id: String, @@ -256,9 +256,11 @@ pub struct Match3DDraftSnapshot { pub tags: Vec, pub clear_count: u32, pub difficulty: u32, + #[serde(default)] + pub generated_item_assets_json: Option, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct Match3DAgentSessionSnapshot { pub session_id: String, @@ -276,7 +278,7 @@ pub struct Match3DAgentSessionSnapshot { pub updated_at_micros: i64, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct Match3DWorkSnapshot { pub profile_id: String, @@ -300,7 +302,7 @@ pub struct Match3DWorkSnapshot { pub generated_item_assets_json: Option, } -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct Match3DItemSnapshot { pub item_instance_id: String, @@ -314,7 +316,7 @@ pub struct Match3DItemSnapshot { pub clickable: bool, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct Match3DTraySlotSnapshot { pub slot_index: u32, @@ -323,7 +325,7 @@ pub struct Match3DTraySlotSnapshot { pub visual_key: Option, } -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct Match3DRunSnapshot { pub run_id: String, diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index 703e880e..7b027bd4 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -31,7 +31,9 @@ use module_runtime::visible_runtime_profile_user_tags; use serde_json::from_str as json_from_str; use serde_json::json; use serde_json::to_string as json_to_string; -use spacetimedb::{ProcedureContext, SpacetimeType, Table, Timestamp, TxContext}; +use spacetimedb::{ + AnonymousViewContext, ProcedureContext, SpacetimeType, Table, Timestamp, TxContext, +}; use crate::auth::user_account; @@ -112,6 +114,93 @@ pub struct PuzzleWorkProfileRow { point_incentive_claimed_points: u64, } +/// 拼图广场公开详情兼容投影。 +/// +/// 该 view 返回完整 `PuzzleWorkProfile`,包含 levels / anchor_pack 等详情级字段。 +/// 公开列表主路径应订阅更轻量的 `puzzle_gallery_card_view`。 +#[spacetimedb::view(accessor = puzzle_gallery_view, public)] +pub fn puzzle_gallery_view(ctx: &AnonymousViewContext) -> Vec { + let mut items = ctx + .db + .puzzle_work_profile() + .by_puzzle_work_publication_status() + .filter(PuzzlePublicationStatus::Published) + .filter_map( + |row| match build_puzzle_work_profile_from_row_without_recent_count(&row) { + Ok(profile) => Some(profile), + Err(error) => { + log::warn!( + "拼图广场 view 跳过损坏的作品投影 profile_id={}: {}", + row.profile_id, + error + ); + None + } + }, + ) + .collect::>(); + items.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros)); + items +} + +/// 拼图广场公开列表卡片投影。 +/// +/// 该 view 只暴露前端列表首屏需要的公开卡片字段,不携带 levels / anchor_pack +/// 等详情级载荷,供 api-server 热点缓存订阅和组装列表窗口。 +#[spacetimedb::view(accessor = puzzle_gallery_card_view, public)] +pub fn puzzle_gallery_card_view(ctx: &AnonymousViewContext) -> Vec { + let mut items = ctx + .db + .puzzle_work_profile() + .by_puzzle_work_publication_status() + .filter(PuzzlePublicationStatus::Published) + .filter_map(|row| match build_puzzle_gallery_card_view_row(&row) { + Ok(item) => Some(item), + Err(error) => { + log::warn!( + "拼图广场卡片 view 跳过损坏的作品投影 profile_id={}: {}", + row.profile_id, + error + ); + None + } + }) + .collect::>(); + items.sort_by(|left, right| { + right + .updated_at_micros + .cmp(&left.updated_at_micros) + .then_with(|| left.profile_id.cmp(&right.profile_id)) + }); + items +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct PuzzleGalleryCardViewRow { + 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: PuzzlePublicationStatus, + pub updated_at_micros: i64, + pub published_at_micros: Option, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, + pub point_incentive_total_half_points: u64, + pub point_incentive_claimed_points: u64, + pub publish_ready: bool, + pub generation_status: Option, +} + /// 拼图创作事件类型。 /// /// 事件表只广播跨层订阅需要的轻量事实,作品真相仍以 @@ -187,12 +276,12 @@ pub fn create_puzzle_agent_session( match ctx.try_with_tx(|tx| create_puzzle_agent_session_tx(tx, input.clone())) { Ok(session) => PuzzleAgentSessionProcedureResult { ok: true, - session_json: Some(serialize_json(&session)), + session: Some(session), error_message: None, }, Err(message) => PuzzleAgentSessionProcedureResult { ok: false, - session_json: None, + session: None, error_message: Some(message), }, } @@ -206,12 +295,12 @@ pub fn get_puzzle_agent_session( match ctx.try_with_tx(|tx| get_puzzle_agent_session_tx(tx, input.clone())) { Ok(session) => PuzzleAgentSessionProcedureResult { ok: true, - session_json: Some(serialize_json(&session)), + session: Some(session), error_message: None, }, Err(message) => PuzzleAgentSessionProcedureResult { ok: false, - session_json: None, + session: None, error_message: Some(message), }, } @@ -225,12 +314,12 @@ pub fn submit_puzzle_agent_message( match ctx.try_with_tx(|tx| submit_puzzle_agent_message_tx(tx, input.clone())) { Ok(session) => PuzzleAgentSessionProcedureResult { ok: true, - session_json: Some(serialize_json(&session)), + session: Some(session), error_message: None, }, Err(message) => PuzzleAgentSessionProcedureResult { ok: false, - session_json: None, + session: None, error_message: Some(message), }, } @@ -244,12 +333,12 @@ pub fn finalize_puzzle_agent_message_turn( match ctx.try_with_tx(|tx| finalize_puzzle_agent_message_turn_tx(tx, input.clone())) { Ok(session) => PuzzleAgentSessionProcedureResult { ok: true, - session_json: Some(serialize_json(&session)), + session: Some(session), error_message: None, }, Err(message) => PuzzleAgentSessionProcedureResult { ok: false, - session_json: None, + session: None, error_message: Some(message), }, } @@ -263,12 +352,12 @@ pub fn compile_puzzle_agent_draft( match ctx.try_with_tx(|tx| compile_puzzle_agent_draft_tx(tx, input.clone())) { Ok(session) => PuzzleAgentSessionProcedureResult { ok: true, - session_json: Some(serialize_json(&session)), + session: Some(session), error_message: None, }, Err(message) => PuzzleAgentSessionProcedureResult { ok: false, - session_json: None, + session: None, error_message: Some(message), }, } @@ -284,12 +373,12 @@ pub fn save_puzzle_form_draft( match ctx.try_with_tx(|tx| save_puzzle_form_draft_tx(tx, input.clone())) { Ok(session) => PuzzleAgentSessionProcedureResult { ok: true, - session_json: Some(serialize_json(&session)), + session: Some(session), error_message: None, }, Err(message) => PuzzleAgentSessionProcedureResult { ok: false, - session_json: None, + session: None, error_message: Some(message), }, } @@ -303,12 +392,12 @@ pub fn save_puzzle_generated_images( match ctx.try_with_tx(|tx| save_puzzle_generated_images_tx(tx, input.clone())) { Ok(session) => PuzzleAgentSessionProcedureResult { ok: true, - session_json: Some(serialize_json(&session)), + session: Some(session), error_message: None, }, Err(message) => PuzzleAgentSessionProcedureResult { ok: false, - session_json: None, + session: None, error_message: Some(message), }, } @@ -322,12 +411,12 @@ pub fn save_puzzle_ui_background( match ctx.try_with_tx(|tx| save_puzzle_ui_background_tx(tx, input.clone())) { Ok(session) => PuzzleAgentSessionProcedureResult { ok: true, - session_json: Some(serialize_json(&session)), + session: Some(session), error_message: None, }, Err(message) => PuzzleAgentSessionProcedureResult { ok: false, - session_json: None, + session: None, error_message: Some(message), }, } @@ -341,12 +430,12 @@ pub fn select_puzzle_cover_image( match ctx.try_with_tx(|tx| select_puzzle_cover_image_tx(tx, input.clone())) { Ok(session) => PuzzleAgentSessionProcedureResult { ok: true, - session_json: Some(serialize_json(&session)), + session: Some(session), error_message: None, }, Err(message) => PuzzleAgentSessionProcedureResult { ok: false, - session_json: None, + session: None, error_message: Some(message), }, } @@ -360,12 +449,12 @@ pub fn publish_puzzle_work( match ctx.try_with_tx(|tx| publish_puzzle_work_tx(tx, input.clone())) { Ok(item) => PuzzleWorkProcedureResult { ok: true, - item_json: Some(serialize_json(&item)), + item: Some(item), error_message: None, }, Err(message) => PuzzleWorkProcedureResult { ok: false, - item_json: None, + item: None, error_message: Some(message), }, } @@ -379,12 +468,12 @@ pub fn list_puzzle_works( match ctx.try_with_tx(|tx| list_puzzle_works_tx(tx, input.clone())) { Ok(items) => PuzzleWorksProcedureResult { ok: true, - items_json: Some(serialize_json(&items)), + items, error_message: None, }, Err(message) => PuzzleWorksProcedureResult { ok: false, - items_json: None, + items: Vec::new(), error_message: Some(message), }, } @@ -398,12 +487,12 @@ pub fn get_puzzle_work_detail( match ctx.try_with_tx(|tx| get_puzzle_work_detail_tx(tx, input.clone())) { Ok(item) => PuzzleWorkProcedureResult { ok: true, - item_json: Some(serialize_json(&item)), + item: Some(item), error_message: None, }, Err(message) => PuzzleWorkProcedureResult { ok: false, - item_json: None, + item: None, error_message: Some(message), }, } @@ -417,12 +506,12 @@ pub fn update_puzzle_work( match ctx.try_with_tx(|tx| update_puzzle_work_tx(tx, input.clone())) { Ok(item) => PuzzleWorkProcedureResult { ok: true, - item_json: Some(serialize_json(&item)), + item: Some(item), error_message: None, }, Err(message) => PuzzleWorkProcedureResult { ok: false, - item_json: None, + item: None, error_message: Some(message), }, } @@ -436,12 +525,12 @@ pub fn delete_puzzle_work( match ctx.try_with_tx(|tx| delete_puzzle_work_tx(tx, input.clone())) { Ok(items) => PuzzleWorksProcedureResult { ok: true, - items_json: Some(serialize_json(&items)), + items, error_message: None, }, Err(message) => PuzzleWorksProcedureResult { ok: false, - items_json: None, + items: Vec::new(), error_message: Some(message), }, } @@ -452,12 +541,12 @@ pub fn list_puzzle_gallery(ctx: &mut ProcedureContext) -> PuzzleWorksProcedureRe match ctx.try_with_tx(|tx| list_puzzle_gallery_tx(tx)) { Ok(items) => PuzzleWorksProcedureResult { ok: true, - items_json: Some(serialize_json(&items)), + items, error_message: None, }, Err(message) => PuzzleWorksProcedureResult { ok: false, - items_json: None, + items: Vec::new(), error_message: Some(message), }, } @@ -471,12 +560,12 @@ pub fn get_puzzle_gallery_detail( match ctx.try_with_tx(|tx| get_puzzle_gallery_detail_tx(tx, input.clone())) { Ok(item) => PuzzleWorkProcedureResult { ok: true, - item_json: Some(serialize_json(&item)), + item: Some(item), error_message: None, }, Err(message) => PuzzleWorkProcedureResult { ok: false, - item_json: None, + item: None, error_message: Some(message), }, } @@ -490,12 +579,12 @@ pub fn record_puzzle_work_like( match ctx.try_with_tx(|tx| record_puzzle_work_like_tx(tx, input.clone())) { Ok(item) => PuzzleWorkProcedureResult { ok: true, - item_json: Some(serialize_json(&item)), + item: Some(item), error_message: None, }, Err(message) => PuzzleWorkProcedureResult { ok: false, - item_json: None, + item: None, error_message: Some(message), }, } @@ -509,12 +598,12 @@ pub fn remix_puzzle_work( match ctx.try_with_tx(|tx| remix_puzzle_work_tx(tx, input.clone())) { Ok(session) => PuzzleAgentSessionProcedureResult { ok: true, - session_json: Some(serialize_json(&session)), + session: Some(session), error_message: None, }, Err(message) => PuzzleAgentSessionProcedureResult { ok: false, - session_json: None, + session: None, error_message: Some(message), }, } @@ -528,12 +617,12 @@ pub fn start_puzzle_run( match ctx.try_with_tx(|tx| start_puzzle_run_tx(tx, input.clone())) { Ok(run) => PuzzleRunProcedureResult { ok: true, - run_json: Some(serialize_json(&run)), + run: Some(run), error_message: None, }, Err(message) => PuzzleRunProcedureResult { ok: false, - run_json: None, + run: None, error_message: Some(message), }, } @@ -547,12 +636,12 @@ pub fn get_puzzle_run( match ctx.try_with_tx(|tx| get_puzzle_run_tx(tx, input.clone())) { Ok(run) => PuzzleRunProcedureResult { ok: true, - run_json: Some(serialize_json(&run)), + run: Some(run), error_message: None, }, Err(message) => PuzzleRunProcedureResult { ok: false, - run_json: None, + run: None, error_message: Some(message), }, } @@ -566,12 +655,12 @@ pub fn swap_puzzle_pieces( match ctx.try_with_tx(|tx| swap_puzzle_pieces_tx(tx, input.clone())) { Ok(run) => PuzzleRunProcedureResult { ok: true, - run_json: Some(serialize_json(&run)), + run: Some(run), error_message: None, }, Err(message) => PuzzleRunProcedureResult { ok: false, - run_json: None, + run: None, error_message: Some(message), }, } @@ -585,12 +674,12 @@ pub fn drag_puzzle_piece_or_group( match ctx.try_with_tx(|tx| drag_puzzle_piece_or_group_tx(tx, input.clone())) { Ok(run) => PuzzleRunProcedureResult { ok: true, - run_json: Some(serialize_json(&run)), + run: Some(run), error_message: None, }, Err(message) => PuzzleRunProcedureResult { ok: false, - run_json: None, + run: None, error_message: Some(message), }, } @@ -604,12 +693,12 @@ pub fn advance_puzzle_next_level( match ctx.try_with_tx(|tx| advance_puzzle_next_level_tx(tx, input.clone())) { Ok(run) => PuzzleRunProcedureResult { ok: true, - run_json: Some(serialize_json(&run)), + run: Some(run), error_message: None, }, Err(message) => PuzzleRunProcedureResult { ok: false, - run_json: None, + run: None, error_message: Some(message), }, } @@ -623,12 +712,12 @@ pub fn update_puzzle_run_pause( match ctx.try_with_tx(|tx| update_puzzle_run_pause_tx(tx, input.clone())) { Ok(run) => PuzzleRunProcedureResult { ok: true, - run_json: Some(serialize_json(&run)), + run: Some(run), error_message: None, }, Err(message) => PuzzleRunProcedureResult { ok: false, - run_json: None, + run: None, error_message: Some(message), }, } @@ -642,12 +731,12 @@ pub fn use_puzzle_runtime_prop( match ctx.try_with_tx(|tx| use_puzzle_runtime_prop_tx(tx, input.clone())) { Ok(run) => PuzzleRunProcedureResult { ok: true, - run_json: Some(serialize_json(&run)), + run: Some(run), error_message: None, }, Err(message) => PuzzleRunProcedureResult { ok: false, - run_json: None, + run: None, error_message: Some(message), }, } @@ -661,12 +750,12 @@ pub fn claim_puzzle_work_point_incentive( match ctx.try_with_tx(|tx| claim_puzzle_work_point_incentive_tx(tx, input.clone())) { Ok(item) => PuzzleWorkProcedureResult { ok: true, - item_json: Some(serialize_json(&item)), + item: Some(item), error_message: None, }, Err(message) => PuzzleWorkProcedureResult { ok: false, - item_json: None, + item: None, error_message: Some(message), }, } @@ -680,12 +769,12 @@ pub fn submit_puzzle_leaderboard_entry( match ctx.try_with_tx(|tx| submit_puzzle_leaderboard_entry_tx(tx, input.clone())) { Ok(run) => PuzzleRunProcedureResult { ok: true, - run_json: Some(serialize_json(&run)), + run: Some(run), error_message: None, }, Err(message) => PuzzleRunProcedureResult { ok: false, - run_json: None, + run: None, error_message: Some(message), }, } @@ -1264,8 +1353,8 @@ fn list_puzzle_works_tx( let mut items = ctx .db .puzzle_work_profile() - .iter() - .filter(|row| row.owner_user_id == input.owner_user_id) + .by_puzzle_work_owner_user_id() + .filter(&input.owner_user_id) .map(|row| build_puzzle_work_profile_from_row(&row)) .collect::, _>>()?; items.sort_by(|left, right| right.updated_at_micros.cmp(&left.updated_at_micros)); @@ -1446,8 +1535,8 @@ fn delete_puzzle_work_tx( for message in ctx .db .puzzle_agent_message() - .iter() - .filter(|message| message.session_id == *session_id) + .by_puzzle_agent_message_session_id() + .filter(session_id) .collect::>() { ctx.db @@ -1459,10 +1548,9 @@ fn delete_puzzle_work_tx( for run in ctx .db .puzzle_runtime_run() - .iter() - .filter(|run| { - run.owner_user_id == input.owner_user_id && run.entry_profile_id == input.profile_id - }) + .by_puzzle_runtime_run_owner_user_id() + .filter(&input.owner_user_id) + .filter(|run| run.entry_profile_id == input.profile_id) .collect::>() { ctx.db.puzzle_runtime_run().run_id().delete(&run.run_id); @@ -1481,8 +1569,8 @@ fn list_puzzle_gallery_tx(ctx: &TxContext) -> Result, Str let rows = ctx .db .puzzle_work_profile() - .iter() - .filter(|row| row.publication_status == PuzzlePublicationStatus::Published) + .by_puzzle_work_publication_status() + .filter(PuzzlePublicationStatus::Published) .collect::>(); let profile_ids = rows .iter() @@ -2416,6 +2504,68 @@ fn build_puzzle_work_profile_from_row_without_recent_count( }) } +fn build_puzzle_gallery_card_view_row( + row: &PuzzleWorkProfileRow, +) -> Result { + let levels = build_profile_levels_from_row(row)?; + Ok(PuzzleGalleryCardViewRow { + work_id: row.work_id.clone(), + profile_id: row.profile_id.clone(), + owner_user_id: row.owner_user_id.clone(), + source_session_id: row.source_session_id.clone(), + author_display_name: row.author_display_name.clone(), + work_title: if row.work_title.trim().is_empty() { + row.level_name.clone() + } else { + row.work_title.clone() + }, + work_description: if row.work_description.trim().is_empty() { + row.summary.clone() + } else { + row.work_description.clone() + }, + level_name: row.level_name.clone(), + summary: row.summary.clone(), + theme_tags: deserialize_theme_tags(&row.theme_tags_json)?, + cover_image_src: row.cover_image_src.clone(), + cover_asset_id: row.cover_asset_id.clone(), + publication_status: row.publication_status, + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + published_at_micros: row + .published_at + .map(|value| value.to_micros_since_unix_epoch()), + play_count: row.play_count, + remix_count: row.remix_count, + like_count: row.like_count, + point_incentive_total_half_points: row.point_incentive_total_half_points, + point_incentive_claimed_points: row.point_incentive_claimed_points, + publish_ready: row.publish_ready, + generation_status: resolve_puzzle_gallery_generation_status(&levels), + }) +} + +fn resolve_puzzle_gallery_generation_status( + levels: &[module_puzzle::PuzzleDraftLevel], +) -> Option { + levels + .iter() + .map(|level| level.generation_status.trim()) + .find(|status| *status == "generating") + .or_else(|| { + levels + .iter() + .map(|level| level.generation_status.trim()) + .find(|status| *status == "ready") + }) + .or_else(|| { + levels + .iter() + .map(|level| level.generation_status.trim()) + .find(|status| !status.is_empty()) + }) + .map(str::to_string) +} + fn build_profile_levels_from_row( row: &PuzzleWorkProfileRow, ) -> Result, String> { @@ -2542,8 +2692,8 @@ fn list_session_messages(ctx: &TxContext, session_id: &str) -> Vec Result, String> { ctx.db .puzzle_work_profile() - .iter() - .filter(|row| row.publication_status == PuzzlePublicationStatus::Published) + .by_puzzle_work_publication_status() + .filter(PuzzlePublicationStatus::Published) .map(|row| build_puzzle_work_profile_from_row(&row)) .collect() } @@ -3319,8 +3469,8 @@ fn list_puzzle_leaderboard_entries( let mut rows = ctx .db .puzzle_leaderboard_entry() - .iter() - .filter(|row| row.profile_id == profile_id && row.grid_size == grid_size) + .by_puzzle_leaderboard_profile_grid() + .filter((profile_id, grid_size)) .collect::>(); rows.sort_by(|left, right| { left.best_elapsed_ms 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/runtime/browse_history.rs b/server-rs/crates/spacetime-module/src/runtime/browse_history.rs index a4886067..7183fa2b 100644 --- a/server-rs/crates/spacetime-module/src/runtime/browse_history.rs +++ b/server-rs/crates/spacetime-module/src/runtime/browse_history.rs @@ -95,8 +95,8 @@ fn list_platform_browse_history_rows( let mut entries = ctx .db .user_browse_history() - .iter() - .filter(|row| row.user_id == validated_input.user_id) + .by_browse_history_user_id() + .filter(&validated_input.user_id) .map(|row| build_runtime_browse_history_snapshot_from_row(&row)) .collect::>(); @@ -165,8 +165,8 @@ fn clear_platform_browse_history_rows( let row_ids = ctx .db .user_browse_history() - .iter() - .filter(|row| row.user_id == validated_input.user_id) + .by_browse_history_user_id() + .filter(&validated_input.user_id) .map(|row| row.browse_history_id.clone()) .collect::>(); diff --git a/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs b/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs index 683c4b2f..05c9db50 100644 --- a/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs +++ b/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs @@ -215,8 +215,7 @@ fn migrate_visual_novel_entry_from_old_visible_default(ctx: &ReducerContext, now && row.subtitle == "分支叙事体验" && row.image_src == "/creation-type-references/visual-novel.webp" && row.visible - && ((row.badge == "可创建" && row.open) - || (row.badge == "敬请期待" && !row.open)) + && ((row.badge == "可创建" && row.open) || (row.badge == "敬请期待" && !row.open)) && row.sort_order == 60; if !still_old_visible_default { return; diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index c4ba135f..10f3c59e 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -1079,8 +1079,8 @@ pub(crate) fn list_profile_save_archive_rows( let mut entries = ctx .db .profile_save_archive() - .iter() - .filter(|row| row.user_id == validated_input.user_id) + .by_profile_save_archive_user_id() + .filter(&validated_input.user_id) .map(|row| build_profile_save_archive_snapshot_from_row(&row)) .collect::>(); @@ -1104,10 +1104,12 @@ pub(crate) fn resume_profile_save_archive_record( let archive = ctx .db .profile_save_archive() - .iter() - .find(|row| { - row.user_id == validated_input.user_id && row.world_key == validated_input.world_key - }) + .by_profile_save_archive_user_world_key() + .filter(( + validated_input.user_id.as_str(), + validated_input.world_key.as_str(), + )) + .next() .ok_or_else(|| "profile_save_archive 对应 world_key 不存在".to_string())?; let existing_snapshot = ctx @@ -2052,8 +2054,8 @@ fn get_profile_dashboard_snapshot( let played_world_count = ctx .db .profile_played_world() - .iter() - .filter(|row| row.user_id == validated_input.user_id) + .by_profile_played_world_user_id() + .filter(&validated_input.user_id) .count() as u32; Ok(match state { @@ -2084,8 +2086,8 @@ fn list_profile_wallet_ledger_entries( let mut entries = ctx .db .profile_wallet_ledger() - .iter() - .filter(|row| row.user_id == validated_input.user_id) + .by_profile_wallet_ledger_user_id() + .filter(&validated_input.user_id) .map(|row| build_profile_wallet_ledger_snapshot_from_row(&row)) .collect::>(); @@ -2114,8 +2116,8 @@ fn get_profile_play_stats_snapshot( let mut played_works = ctx .db .profile_played_world() - .iter() - .filter(|row| row.user_id == validated_input.user_id) + .by_profile_played_world_user_id() + .filter(&validated_input.user_id) .map(|row| build_profile_played_world_snapshot_from_row(&row)) .collect::>(); @@ -2727,17 +2729,16 @@ fn build_profile_referral_invite_center_snapshot( let code = ensure_profile_invite_code(ctx, user_id); let today_inviter_reward_count = count_today_profile_referral_inviter_rewards(ctx, user_id, ctx.timestamp); - let invited_count = ctx + let invited_relations = ctx .db .profile_referral_relation() + .by_profile_referral_inviter_user_id() + .filter(user_id) + .collect::>(); + let invited_count = invited_relations.len() as u32; + let rewarded_invite_count = invited_relations .iter() - .filter(|row| row.inviter_user_id == user_id) - .count() as u32; - let rewarded_invite_count = ctx - .db - .profile_referral_relation() - .iter() - .filter(|row| row.inviter_user_id == user_id && row.inviter_reward_granted) + .filter(|row| row.inviter_reward_granted) .count() as u32; let bound_relation = ctx .db @@ -2918,7 +2919,8 @@ fn count_today_profile_referral_inviter_rewards( let day_start_micros = runtime_profile_day_start_micros(now.to_micros_since_unix_epoch()); ctx.db .profile_wallet_ledger() - .iter() + .by_profile_wallet_ledger_user_id() + .filter(user_id) .filter(|row| { row.user_id == user_id && row.source_type == RuntimeProfileWalletLedgerSourceType::InviteInviterReward @@ -3422,7 +3424,11 @@ fn query_analytics_metric_buckets( let stats = ctx .db .tracking_daily_stat() - .iter() + .by_tracking_daily_stat_scope_day() + .filter(( + validated_input.scope_kind, + validated_input.scope_id.as_str(), + )) .filter(|row| { row.event_key.trim() == validated_input.event_key && row.scope_kind == validated_input.scope_kind @@ -4023,27 +4029,39 @@ fn apply_profile_wallet_signed_delta( } fn has_profile_points_recharged(ctx: &ReducerContext, user_id: &str) -> bool { - ctx.db.profile_recharge_order().iter().any(|row| { - row.user_id == user_id - && row.kind == RuntimeProfileRechargeProductKind::Points - && row.status == RuntimeProfileRechargeOrderStatus::Paid - }) + ctx.db + .profile_recharge_order() + .by_profile_recharge_order_user_id() + .filter(user_id) + .any(|row| { + row.user_id == user_id + && row.kind == RuntimeProfileRechargeProductKind::Points + && row.status == RuntimeProfileRechargeOrderStatus::Paid + }) } fn has_profile_product_recharged(ctx: &ReducerContext, user_id: &str, product_id: &str) -> bool { - ctx.db.profile_recharge_order().iter().any(|row| { - row.user_id == user_id - && row.product_id == product_id - && row.kind == RuntimeProfileRechargeProductKind::Points - && row.status == RuntimeProfileRechargeOrderStatus::Paid - }) + ctx.db + .profile_recharge_order() + .by_profile_recharge_order_user_id() + .filter(user_id) + .any(|row| { + row.user_id == user_id + && row.product_id == product_id + && row.kind == RuntimeProfileRechargeProductKind::Points + && row.status == RuntimeProfileRechargeOrderStatus::Paid + }) } fn has_profile_business_wallet_ledger(ctx: &ReducerContext, user_id: &str) -> bool { - ctx.db.profile_wallet_ledger().iter().any(|row| { - row.user_id == user_id - && row.source_type != RuntimeProfileWalletLedgerSourceType::SnapshotSync - }) + ctx.db + .profile_wallet_ledger() + .by_profile_wallet_ledger_user_id() + .filter(user_id) + .any(|row| { + row.user_id == user_id + && row.source_type != RuntimeProfileWalletLedgerSourceType::SnapshotSync + }) } fn latest_profile_recharge_order( @@ -4053,8 +4071,8 @@ fn latest_profile_recharge_order( let mut orders = ctx .db .profile_recharge_order() - .iter() - .filter(|row| row.user_id == user_id) + .by_profile_recharge_order_user_id() + .filter(user_id) .collect::>(); orders.sort_by(|left, right| { right 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 93% rename from server-rs/crates/spacetime-module/src/square_hole/mod.rs rename to server-rs/crates/spacetime-module/src/square_hole.rs index 0d371ec0..4358722a 100644 --- a/server-rs/crates/spacetime-module/src/square_hole/mod.rs +++ b/server-rs/crates/spacetime-module/src/square_hole.rs @@ -26,6 +26,65 @@ use module_square_hole::{ }; use serde::Serialize; use serde::de::DeserializeOwned; +use spacetimedb::AnonymousViewContext; + +/// 方洞挑战公开广场列表投影。 +/// +/// HTTP gallery 通过 `spacetime-client` 订阅该 view 后读本地 cache, +/// 不再在每个公开列表请求里调用 `list_square_hole_works` procedure。 +#[spacetimedb::view(accessor = square_hole_gallery_view, public)] +pub fn square_hole_gallery_view(ctx: &AnonymousViewContext) -> Vec { + let mut items = ctx + .db + .square_hole_work_profile() + .by_square_hole_work_publication_status() + .filter(SQUARE_HOLE_PUBLICATION_PUBLISHED) + .filter_map(|row| match build_gallery_view_row(&row) { + Ok(item) => Some(item), + Err(error) => { + log::warn!( + "方洞挑战公开广场 view 跳过损坏的作品投影 profile_id={}: {}", + row.profile_id, + error + ); + None + } + }) + .collect::>(); + items.sort_by(|left, right| { + right + .updated_at_micros + .cmp(&left.updated_at_micros) + .then_with(|| left.profile_id.cmp(&right.profile_id)) + }); + items +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct SquareHoleGalleryViewRow { + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: String, + pub author_display_name: String, + pub game_name: String, + pub theme_text: String, + pub twist_rule: String, + pub summary_text: String, + pub tags: Vec, + pub cover_image_src: String, + pub background_prompt: String, + pub background_image_src: String, + pub shape_options: Vec, + pub hole_options: Vec, + pub shape_count: u32, + pub difficulty: u32, + pub publication_status: String, + pub publish_ready: bool, + pub play_count: u32, + pub updated_at_micros: i64, + pub published_at_micros: Option, +} #[spacetimedb::procedure] pub fn create_square_hole_agent_session( @@ -112,12 +171,12 @@ pub fn list_square_hole_works( match ctx.try_with_tx(|tx| list_square_hole_works_tx(tx, input.clone())) { Ok(items) => SquareHoleWorksProcedureResult { ok: true, - items_json: Some(to_json_string(&items)), + items, error_message: None, }, Err(message) => SquareHoleWorksProcedureResult { ok: false, - items_json: None, + items: Vec::new(), error_message: Some(message), }, } @@ -142,12 +201,12 @@ pub fn delete_square_hole_work( match ctx.try_with_tx(|tx| delete_square_hole_work_tx(tx, input.clone())) { Ok(items) => SquareHoleWorksProcedureResult { ok: true, - items_json: Some(to_json_string(&items)), + items, error_message: None, }, Err(message) => SquareHoleWorksProcedureResult { ok: false, - items_json: None, + items: Vec::new(), error_message: Some(message), }, } @@ -185,8 +244,8 @@ pub fn drop_square_hole_shape( Err(message) => SquareHoleDropShapeProcedureResult { ok: false, status: SQUARE_HOLE_DROP_REJECTED.to_string(), - run_json: None, - feedback_json: None, + run: None, + feedback: None, failure_reason: None, error_message: Some(message), }, @@ -743,10 +802,8 @@ fn drop_square_hole_shape_tx( Ok(SquareHoleDropShapeProcedureResult { ok: true, status: status.to_string(), - run_json: Some(to_json_string(&next)), - feedback_json: Some(to_json_string(&feedback_from_domain( - &confirmation.feedback, - ))), + run: Some(next), + feedback: Some(feedback_from_domain(&confirmation.feedback)), failure_reason: confirmation .feedback .reject_reason @@ -880,6 +937,38 @@ fn build_work_snapshot(row: &SquareHoleWorkProfileRow) -> Result Result { + let config = parse_config(&row.config_json)?; + Ok(SquareHoleGalleryViewRow { + work_id: row.work_id.clone(), + profile_id: row.profile_id.clone(), + owner_user_id: row.owner_user_id.clone(), + source_session_id: row.source_session_id.clone(), + author_display_name: row.author_display_name.clone(), + game_name: row.game_name.clone(), + theme_text: row.theme_text.clone(), + twist_rule: row.twist_rule.clone(), + summary_text: row.summary_text.clone(), + tags: parse_tags(&row.tags_json)?, + cover_image_src: row.cover_image_src.clone(), + background_prompt: config.background_prompt, + background_image_src: config.background_image_src, + shape_options: config.shape_options, + hole_options: config.hole_options, + shape_count: row.shape_count, + difficulty: row.difficulty, + publication_status: row.publication_status.clone(), + publish_ready: is_work_publish_ready(row), + play_count: row.play_count, + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + published_at_micros: row + .published_at + .map(|value| value.to_micros_since_unix_epoch()), + }) +} + fn refresh_run_row( ctx: &ReducerContext, row: SquareHoleRuntimeRunRow, @@ -1502,7 +1591,7 @@ fn session_result( ) -> SquareHoleAgentSessionProcedureResult { SquareHoleAgentSessionProcedureResult { ok: true, - session_json: Some(to_json_string(&session)), + session: Some(session), error_message: None, } } @@ -1510,7 +1599,7 @@ fn session_result( fn session_error(message: String) -> SquareHoleAgentSessionProcedureResult { SquareHoleAgentSessionProcedureResult { ok: false, - session_json: None, + session: None, error_message: Some(message), } } @@ -1518,7 +1607,7 @@ fn session_error(message: String) -> SquareHoleAgentSessionProcedureResult { fn work_result(work: SquareHoleWorkSnapshot) -> SquareHoleWorkProcedureResult { SquareHoleWorkProcedureResult { ok: true, - work_json: Some(to_json_string(&work)), + work: Some(work), error_message: None, } } @@ -1526,7 +1615,7 @@ fn work_result(work: SquareHoleWorkSnapshot) -> SquareHoleWorkProcedureResult { fn work_error(message: String) -> SquareHoleWorkProcedureResult { SquareHoleWorkProcedureResult { ok: false, - work_json: None, + work: None, error_message: Some(message), } } @@ -1534,7 +1623,7 @@ fn work_error(message: String) -> SquareHoleWorkProcedureResult { fn run_result(run: SquareHoleRunSnapshot) -> SquareHoleRunProcedureResult { SquareHoleRunProcedureResult { ok: true, - run_json: Some(to_json_string(&run)), + run: Some(run), error_message: None, } } @@ -1542,7 +1631,7 @@ fn run_result(run: SquareHoleRunSnapshot) -> SquareHoleRunProcedureResult { fn run_error(message: String) -> SquareHoleRunProcedureResult { SquareHoleRunProcedureResult { ok: false, - run_json: None, + run: None, error_message: Some(message), } } diff --git a/server-rs/crates/spacetime-module/src/square_hole/types.rs b/server-rs/crates/spacetime-module/src/square_hole/types.rs index 232002e1..70a86c66 100644 --- a/server-rs/crates/spacetime-module/src/square_hole/types.rs +++ b/server-rs/crates/spacetime-module/src/square_hole/types.rs @@ -168,42 +168,42 @@ pub struct SquareHoleRunTimeUpInput { #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct SquareHoleAgentSessionProcedureResult { pub ok: bool, - pub session_json: Option, + pub session: Option, pub error_message: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct SquareHoleWorkProcedureResult { pub ok: bool, - pub work_json: Option, + pub work: Option, pub error_message: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct SquareHoleWorksProcedureResult { pub ok: bool, - pub items_json: Option, + pub items: Vec, pub error_message: Option, } -#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +#[derive(Clone, Debug, PartialEq, SpacetimeType)] pub struct SquareHoleRunProcedureResult { pub ok: bool, - pub run_json: Option, + pub run: Option, pub error_message: Option, } -#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +#[derive(Clone, Debug, PartialEq, SpacetimeType)] pub struct SquareHoleDropShapeProcedureResult { pub ok: bool, pub status: String, - pub run_json: Option, - pub feedback_json: Option, + pub run: Option, + pub feedback: Option, pub failure_reason: Option, pub error_message: Option, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct SquareHoleCreatorConfigSnapshot { pub theme_text: String, @@ -222,7 +222,7 @@ pub struct SquareHoleCreatorConfigSnapshot { pub background_image_src: String, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct SquareHoleShapeOptionSnapshot { pub option_id: String, @@ -235,7 +235,7 @@ pub struct SquareHoleShapeOptionSnapshot { pub image_src: String, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct SquareHoleHoleOptionSnapshot { pub hole_id: String, @@ -247,7 +247,7 @@ pub struct SquareHoleHoleOptionSnapshot { pub image_src: String, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct SquareHoleAgentMessageSnapshot { pub message_id: String, @@ -258,7 +258,7 @@ pub struct SquareHoleAgentMessageSnapshot { pub created_at_micros: i64, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct SquareHoleDraftSnapshot { pub profile_id: String, @@ -281,7 +281,7 @@ pub struct SquareHoleDraftSnapshot { pub difficulty: u32, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct SquareHoleAgentSessionSnapshot { pub session_id: String, @@ -299,7 +299,7 @@ pub struct SquareHoleAgentSessionSnapshot { pub updated_at_micros: i64, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct SquareHoleWorkSnapshot { pub work_id: String, @@ -331,7 +331,7 @@ pub struct SquareHoleWorkSnapshot { pub published_at_micros: Option, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct SquareHoleShapeSnapshot { pub shape_id: String, @@ -344,7 +344,7 @@ pub struct SquareHoleShapeSnapshot { pub image_src: String, } -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct SquareHoleHoleSnapshot { pub hole_id: String, @@ -356,7 +356,7 @@ pub struct SquareHoleHoleSnapshot { pub image_src: String, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct SquareHoleDropFeedbackSnapshot { pub accepted: bool, @@ -364,7 +364,7 @@ pub struct SquareHoleDropFeedbackSnapshot { pub message: String, } -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct SquareHoleRunSnapshot { pub run_id: String, diff --git a/server-rs/crates/spacetime-module/src/visual_novel.rs b/server-rs/crates/spacetime-module/src/visual_novel.rs index 1e64046c..f377e312 100644 --- a/server-rs/crates/spacetime-module/src/visual_novel.rs +++ b/server-rs/crates/spacetime-module/src/visual_novel.rs @@ -1,6 +1,7 @@ use crate::*; use serde::Serialize; use serde::de::DeserializeOwned; +use spacetimedb::AnonymousViewContext; pub const VISUAL_NOVEL_SOURCE_IDEA: &str = "idea"; pub const VISUAL_NOVEL_SOURCE_DOCUMENT: &str = "document"; @@ -166,6 +167,58 @@ pub struct VisualNovelRuntimeEvent { pub(crate) occurred_at: Timestamp, } +/// 视觉小说公开广场列表投影。 +/// +/// 该 view 只暴露已发布作品卡片需要的公开字段,HTTP gallery 订阅后 +/// 从本地 cache 读取,避免每个列表请求调用 `list_visual_novel_works` procedure。 +#[spacetimedb::view(accessor = visual_novel_gallery_view, public)] +pub fn visual_novel_gallery_view(ctx: &AnonymousViewContext) -> Vec { + let mut items = ctx + .db + .visual_novel_work_profile() + .by_visual_novel_work_publication_status() + .filter(VISUAL_NOVEL_PUBLICATION_PUBLISHED) + .filter_map(|row| match build_gallery_view_row(&row) { + Ok(item) => Some(item), + Err(error) => { + log::warn!( + "视觉小说公开广场 view 跳过损坏的作品投影 profile_id={}: {}", + row.profile_id, + error + ); + None + } + }) + .collect::>(); + items.sort_by(|left, right| { + right + .updated_at_micros + .cmp(&left.updated_at_micros) + .then_with(|| left.profile_id.cmp(&right.profile_id)) + }); + items +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct VisualNovelGalleryViewRow { + 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 publication_status: String, + pub publish_ready: bool, + pub play_count: u32, + pub created_at_micros: i64, + pub updated_at_micros: i64, + pub published_at_micros: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct VisualNovelAgentSessionCreateInput { pub session_id: String, @@ -326,49 +379,65 @@ pub struct VisualNovelRuntimeEventRecordInput { pub occurred_at_micros: i64, } -#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +#[derive(Clone, Debug, PartialEq, SpacetimeType)] pub struct VisualNovelAgentSessionProcedureResult { pub ok: bool, - pub session_json: Option, + pub session: Option, pub error_message: Option, } -#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +#[derive(Clone, Debug, PartialEq, SpacetimeType)] pub struct VisualNovelWorkProcedureResult { pub ok: bool, - pub work_json: Option, + pub work: Option, pub error_message: Option, } -#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +#[derive(Clone, Debug, PartialEq, SpacetimeType)] pub struct VisualNovelWorksProcedureResult { pub ok: bool, - pub items_json: Option, + pub items: Vec, pub error_message: Option, } -#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +#[derive(Clone, Debug, PartialEq, SpacetimeType)] pub struct VisualNovelRunProcedureResult { pub ok: bool, - pub run_json: Option, + pub run: Option, pub error_message: Option, } -#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +#[derive(Clone, Debug, PartialEq, SpacetimeType)] pub struct VisualNovelHistoryProcedureResult { pub ok: bool, - pub items_json: Option, + pub items: Vec, pub error_message: Option, } -#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +#[derive(Clone, Debug, PartialEq, SpacetimeType)] pub struct VisualNovelRuntimeEventProcedureResult { pub ok: bool, - pub event_json: Option, + pub event: Option, pub error_message: Option, } -#[derive(Clone, Debug, PartialEq, Serialize)] +#[derive(Clone, Debug, PartialEq, Serialize, SpacetimeType)] +pub struct VisualNovelJsonField { + pub key: String, + pub value: VisualNovelJsonValue, +} + +#[derive(Clone, Debug, PartialEq, Serialize, SpacetimeType)] +pub enum VisualNovelJsonValue { + Null, + Bool(bool), + Number(f64), + String(String), + Array(Vec), + Object(Vec), +} + +#[derive(Clone, Debug, PartialEq, Serialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct VisualNovelAgentMessageSnapshot { pub message_id: String, @@ -379,7 +448,7 @@ pub struct VisualNovelAgentMessageSnapshot { pub created_at_micros: i64, } -#[derive(Clone, Debug, PartialEq, Serialize)] +#[derive(Clone, Debug, PartialEq, Serialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct VisualNovelAgentSessionSnapshot { pub session_id: String, @@ -391,15 +460,15 @@ pub struct VisualNovelAgentSessionSnapshot { pub current_turn: u32, pub progress_percent: u32, pub messages: Vec, - pub draft: Option, - pub pending_action: Option, + pub draft: Option, + pub pending_action: Option, pub last_assistant_reply: Option, pub published_profile_id: Option, pub created_at_micros: i64, pub updated_at_micros: i64, } -#[derive(Clone, Debug, PartialEq, Serialize)] +#[derive(Clone, Debug, PartialEq, Serialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct VisualNovelWorkSnapshot { pub work_id: String, @@ -412,7 +481,7 @@ pub struct VisualNovelWorkSnapshot { pub tags: Vec, pub cover_image_src: Option, pub source_asset_ids: Vec, - pub draft: JsonValue, + pub draft: VisualNovelJsonValue, pub publication_status: String, pub publish_ready: bool, pub play_count: u32, @@ -421,7 +490,7 @@ pub struct VisualNovelWorkSnapshot { pub published_at_micros: Option, } -#[derive(Clone, Debug, PartialEq, Serialize)] +#[derive(Clone, Debug, PartialEq, Serialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct VisualNovelRuntimeHistoryEntrySnapshot { pub entry_id: String, @@ -431,13 +500,13 @@ pub struct VisualNovelRuntimeHistoryEntrySnapshot { pub turn_index: u32, pub source: String, pub action_text: Option, - pub steps: JsonValue, + pub steps: VisualNovelJsonValue, pub snapshot_before_hash: Option, pub snapshot_after_hash: Option, pub created_at_micros: i64, } -#[derive(Clone, Debug, PartialEq, Serialize)] +#[derive(Clone, Debug, PartialEq, Serialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct VisualNovelRunSnapshot { pub run_id: String, @@ -448,16 +517,16 @@ pub struct VisualNovelRunSnapshot { pub current_scene_id: Option, pub current_phase_id: Option, pub visible_character_ids: Vec, - pub flags: JsonValue, - pub metrics: JsonValue, + pub flags: VisualNovelJsonValue, + pub metrics: VisualNovelJsonValue, pub history: Vec, - pub available_choices: JsonValue, + pub available_choices: VisualNovelJsonValue, pub text_mode_enabled: bool, pub created_at_micros: i64, pub updated_at_micros: i64, } -#[derive(Clone, Debug, PartialEq, Serialize)] +#[derive(Clone, Debug, PartialEq, Serialize, SpacetimeType)] #[serde(rename_all = "camelCase")] pub struct VisualNovelRuntimeEventSnapshot { pub event_id: String, @@ -467,7 +536,7 @@ pub struct VisualNovelRuntimeEventSnapshot { pub event_kind: String, pub client_event_id: Option, pub history_entry_id: Option, - pub payload: JsonValue, + pub payload: VisualNovelJsonValue, pub occurred_at_micros: i64, } @@ -556,12 +625,12 @@ pub fn list_visual_novel_works( match ctx.try_with_tx(|tx| list_visual_novel_works_tx(tx, input.clone())) { Ok(items) => VisualNovelWorksProcedureResult { ok: true, - items_json: Some(to_json_string(&items)), + items, error_message: None, }, Err(message) => VisualNovelWorksProcedureResult { ok: false, - items_json: None, + items: Vec::new(), error_message: Some(message), }, } @@ -586,12 +655,12 @@ pub fn delete_visual_novel_work( match ctx.try_with_tx(|tx| delete_visual_novel_work_tx(tx, input.clone())) { Ok(items) => VisualNovelWorksProcedureResult { ok: true, - items_json: Some(to_json_string(&items)), + items, error_message: None, }, Err(message) => VisualNovelWorksProcedureResult { ok: false, - items_json: None, + items: Vec::new(), error_message: Some(message), }, } @@ -638,12 +707,12 @@ pub fn append_visual_novel_runtime_history_entry( match ctx.try_with_tx(|tx| append_visual_novel_runtime_history_entry_tx(tx, input.clone())) { Ok(items) => VisualNovelHistoryProcedureResult { ok: true, - items_json: Some(to_json_string(&items)), + items, error_message: None, }, Err(message) => VisualNovelHistoryProcedureResult { ok: false, - items_json: None, + items: Vec::new(), error_message: Some(message), }, } @@ -657,12 +726,12 @@ pub fn list_visual_novel_runtime_history( match ctx.try_with_tx(|tx| list_visual_novel_runtime_history_tx(tx, input.clone())) { Ok(items) => VisualNovelHistoryProcedureResult { ok: true, - items_json: Some(to_json_string(&items)), + items, error_message: None, }, Err(message) => VisualNovelHistoryProcedureResult { ok: false, - items_json: None, + items: Vec::new(), error_message: Some(message), }, } @@ -676,12 +745,12 @@ pub fn record_visual_novel_runtime_event( match ctx.try_with_tx(|tx| record_visual_novel_runtime_event_tx(tx, input.clone())) { Ok(event) => VisualNovelRuntimeEventProcedureResult { ok: true, - event_json: Some(to_json_string(&event)), + event: Some(event), error_message: None, }, Err(message) => VisualNovelRuntimeEventProcedureResult { ok: false, - event_json: None, + event: None, error_message: Some(message), }, } @@ -1052,17 +1121,22 @@ fn list_visual_novel_works_tx( ctx: &ReducerContext, input: VisualNovelWorksListInput, ) -> Result, String> { - let mut items = ctx - .db - .visual_novel_work_profile() + let rows = if input.published_only { + ctx.db + .visual_novel_work_profile() + .by_visual_novel_work_publication_status() + .filter(&VISUAL_NOVEL_PUBLICATION_PUBLISHED.to_string()) + .collect::>() + } else { + require_non_empty(&input.owner_user_id, "visual_novel owner_user_id")?; + ctx.db + .visual_novel_work_profile() + .by_visual_novel_work_owner_user_id() + .filter(&input.owner_user_id) + .collect::>() + }; + let mut items = rows .iter() - .filter(|row| { - if input.published_only { - row.publication_status == VISUAL_NOVEL_PUBLICATION_PUBLISHED - } else { - row.owner_user_id == input.owner_user_id - } - }) .map(|row| build_work_snapshot(&row)) .collect::, _>>()?; items.sort_by(|left, right| { @@ -1103,10 +1177,9 @@ fn delete_visual_novel_work_tx( for run in ctx .db .visual_novel_runtime_run() - .iter() - .filter(|row| { - row.profile_id == input.profile_id && row.owner_user_id == input.owner_user_id - }) + .by_visual_novel_run_profile_id() + .filter(&input.profile_id) + .filter(|row| row.owner_user_id == input.owner_user_id) .collect::>() { delete_run_children(ctx, &run.run_id, &input.owner_user_id); @@ -1385,8 +1458,8 @@ fn build_session_snapshot( let mut messages = ctx .db .visual_novel_agent_message() - .iter() - .filter(|message| message.session_id == row.session_id) + .by_visual_novel_agent_message_session_id() + .filter(&row.session_id) .map(|message| VisualNovelAgentMessageSnapshot { message_id: message.message_id, session_id: message.session_id, @@ -1412,8 +1485,9 @@ fn build_session_snapshot( current_turn: row.current_turn, progress_percent: row.progress_percent, messages, - draft: parse_optional_json_value(&row.draft_json)?, - pending_action: parse_optional_json_value(&row.pending_action_json)?, + draft: parse_optional_json_value(&row.draft_json)?.map(visual_novel_json_from_serde), + pending_action: parse_optional_json_value(&row.pending_action_json)? + .map(visual_novel_json_from_serde), last_assistant_reply: empty_to_none(&row.last_assistant_reply), published_profile_id: empty_to_none(&row.published_profile_id), created_at_micros: row.created_at.to_micros_since_unix_epoch(), @@ -1433,7 +1507,32 @@ fn build_work_snapshot(row: &VisualNovelWorkProfileRow) -> Result Result { + Ok(VisualNovelGalleryViewRow { + work_id: row.work_id.clone(), + profile_id: row.profile_id.clone(), + owner_user_id: row.owner_user_id.clone(), + source_session_id: empty_to_none(&row.source_session_id), + author_display_name: row.author_display_name.clone(), + work_title: row.work_title.clone(), + work_description: row.work_description.clone(), + tags: parse_string_vec_or_empty(&row.tags_json)?, + cover_image_src: empty_to_none(&row.cover_image_src), + source_asset_ids: parse_string_vec_or_empty(&row.source_asset_ids_json)?, publication_status: row.publication_status.clone(), publish_ready: row.publish_ready, play_count: row.play_count, @@ -1458,10 +1557,12 @@ fn build_run_snapshot( current_scene_id: empty_to_none(&row.current_scene_id), current_phase_id: empty_to_none(&row.current_phase_id), visible_character_ids: parse_string_vec_or_empty(&row.visible_character_ids_json)?, - flags: parse_json_value_or_object(&row.flags_json)?, - metrics: parse_json_value_or_object(&row.metrics_json)?, + flags: visual_novel_json_from_serde(parse_json_value_or_object(&row.flags_json)?), + metrics: visual_novel_json_from_serde(parse_json_value_or_object(&row.metrics_json)?), history: build_history_snapshots(ctx, &row.run_id, &row.owner_user_id)?, - available_choices: parse_json_value_or_array(&row.available_choices_json)?, + available_choices: visual_novel_json_from_serde(parse_json_value_or_array( + &row.available_choices_json, + )?), text_mode_enabled: row.text_mode_enabled, created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), @@ -1476,8 +1577,9 @@ fn build_history_snapshots( let mut items = ctx .db .visual_novel_runtime_history_entry() - .iter() - .filter(|row| row.run_id == run_id && row.owner_user_id == owner_user_id) + .by_visual_novel_history_run_id() + .filter(&run_id.to_string()) + .filter(|row| row.owner_user_id == owner_user_id) .map(|row| build_history_snapshot(&row)) .collect::, _>>()?; items.sort_by(|left, right| { @@ -1500,7 +1602,7 @@ fn build_history_snapshot( turn_index: row.turn_index, source: row.source.clone(), action_text: empty_to_none(&row.action_text), - steps: parse_json_value_or_array(&row.steps_json)?, + steps: visual_novel_json_from_serde(parse_json_value_or_array(&row.steps_json)?), snapshot_before_hash: empty_to_none(&row.snapshot_before_hash), snapshot_after_hash: empty_to_none(&row.snapshot_after_hash), created_at_micros: row.created_at.to_micros_since_unix_epoch(), @@ -1518,7 +1620,7 @@ fn build_event_snapshot( event_kind: row.event_kind.clone(), client_event_id: empty_to_none(&row.client_event_id), history_entry_id: empty_to_none(&row.history_entry_id), - payload: parse_json_value_or_object(&row.payload_json)?, + payload: visual_novel_json_from_serde(parse_json_value_or_object(&row.payload_json)?), occurred_at_micros: row.occurred_at.to_micros_since_unix_epoch(), }) } @@ -1579,8 +1681,9 @@ fn delete_run_children(ctx: &ReducerContext, run_id: &str, owner_user_id: &str) for history in ctx .db .visual_novel_runtime_history_entry() - .iter() - .filter(|row| row.run_id == run_id && row.owner_user_id == owner_user_id) + .by_visual_novel_history_run_id() + .filter(&run_id.to_string()) + .filter(|row| row.owner_user_id == owner_user_id) .collect::>() { ctx.db @@ -1758,6 +1861,30 @@ fn parse_json_value_or_array(value: &str) -> Result { parse_json_value(value) } +fn visual_novel_json_from_serde(value: JsonValue) -> VisualNovelJsonValue { + match value { + JsonValue::Null => VisualNovelJsonValue::Null, + JsonValue::Bool(value) => VisualNovelJsonValue::Bool(value), + JsonValue::Number(value) => VisualNovelJsonValue::Number(value.as_f64().unwrap_or(0.0)), + JsonValue::String(value) => VisualNovelJsonValue::String(value), + JsonValue::Array(items) => VisualNovelJsonValue::Array( + items + .into_iter() + .map(visual_novel_json_from_serde) + .collect(), + ), + JsonValue::Object(object) => VisualNovelJsonValue::Object( + object + .into_iter() + .map(|(key, value)| VisualNovelJsonField { + key, + value: visual_novel_json_from_serde(value), + }) + .collect(), + ), + } +} + fn draft_string_field(draft: &JsonValue, key: &str) -> Option { draft .get(key) @@ -1853,7 +1980,7 @@ fn session_result( ) -> VisualNovelAgentSessionProcedureResult { VisualNovelAgentSessionProcedureResult { ok: true, - session_json: Some(to_json_string(&session)), + session: Some(session), error_message: None, } } @@ -1861,7 +1988,7 @@ fn session_result( fn session_error(message: String) -> VisualNovelAgentSessionProcedureResult { VisualNovelAgentSessionProcedureResult { ok: false, - session_json: None, + session: None, error_message: Some(message), } } @@ -1869,7 +1996,7 @@ fn session_error(message: String) -> VisualNovelAgentSessionProcedureResult { fn work_result(work: VisualNovelWorkSnapshot) -> VisualNovelWorkProcedureResult { VisualNovelWorkProcedureResult { ok: true, - work_json: Some(to_json_string(&work)), + work: Some(work), error_message: None, } } @@ -1877,7 +2004,7 @@ fn work_result(work: VisualNovelWorkSnapshot) -> VisualNovelWorkProcedureResult fn work_error(message: String) -> VisualNovelWorkProcedureResult { VisualNovelWorkProcedureResult { ok: false, - work_json: None, + work: None, error_message: Some(message), } } @@ -1885,7 +2012,7 @@ fn work_error(message: String) -> VisualNovelWorkProcedureResult { fn run_result(run: VisualNovelRunSnapshot) -> VisualNovelRunProcedureResult { VisualNovelRunProcedureResult { ok: true, - run_json: Some(to_json_string(&run)), + run: Some(run), error_message: None, } } @@ -1893,7 +2020,7 @@ fn run_result(run: VisualNovelRunSnapshot) -> VisualNovelRunProcedureResult { fn run_error(message: String) -> VisualNovelRunProcedureResult { VisualNovelRunProcedureResult { ok: false, - run_json: None, + run: None, error_message: Some(message), } } diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index 21db5870..3120f30d 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -316,7 +316,7 @@ test('auth gate does not auto-create a guest account when dev guest switch is no expect(await screen.findByText('应用内容')).toBeTruthy(); }); -test('auth gate keeps password entry available when login options are empty', async () => { +test('auth gate keeps sms and password entries available when login options are empty', async () => { const user = userEvent.setup(); authMocks.getCurrentAuthUser.mockResolvedValue({ @@ -336,12 +336,19 @@ test('auth gate keeps password entry available when login options are empty', as await user.click(await screen.findByRole('button', { name: '进入作品' })); const dialog = screen.getByRole('dialog', { name: '账号入口' }); + expect(within(dialog).getByRole('tab', { name: '短信登录' })).toBeTruthy(); + expect(within(dialog).getByRole('tab', { name: '密码登录' })).toBeTruthy(); + expect(within(dialog).getByLabelText('验证码')).toBeTruthy(); + expect( + within(dialog).getByRole('button', { name: '获取验证码' }), + ).toBeTruthy(); + await user.click(within(dialog).getByRole('tab', { name: '密码登录' })); expect(within(dialog).getByLabelText('密码')).toBeTruthy(); expect(within(dialog).queryByText('当前登录入口暂不可用。')).toBeNull(); expect(within(dialog).queryByText('读取登录方式失败')).toBeNull(); }); -test('auth gate falls back to password entry when login options request fails', async () => { +test('auth gate keeps sms and password entries available when login options request fails', async () => { const user = userEvent.setup(); authMocks.getAuthLoginOptions.mockRejectedValue( @@ -357,6 +364,13 @@ test('auth gate falls back to password entry when login options request fails', await user.click(await screen.findByRole('button', { name: '进入作品' })); const dialog = screen.getByRole('dialog', { name: '账号入口' }); + expect(within(dialog).getByRole('tab', { name: '短信登录' })).toBeTruthy(); + expect(within(dialog).getByRole('tab', { name: '密码登录' })).toBeTruthy(); + expect(within(dialog).getByLabelText('验证码')).toBeTruthy(); + expect( + within(dialog).getByRole('button', { name: '获取验证码' }), + ).toBeTruthy(); + await user.click(within(dialog).getByRole('tab', { name: '密码登录' })); expect(within(dialog).getByLabelText('密码')).toBeTruthy(); expect(within(dialog).queryByText('当前登录入口暂不可用。')).toBeNull(); }); diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index e4e12a61..e2dc89a6 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -61,7 +61,7 @@ type AuthStatus = | 'ready' | 'error'; -const FALLBACK_LOGIN_METHODS: AuthLoginMethod[] = ['password']; +const REQUIRED_LOGIN_METHODS: AuthLoginMethod[] = ['phone', 'password']; function readInviteCodeFromLocation(): string { const params = new URLSearchParams(window.location.search || ''); @@ -76,11 +76,13 @@ function normalizeAvailableLoginMethods( ): AuthLoginMethod[] { const normalizedMethods = Array.from(new Set(methods ?? [])); - // 密码登录由 Rust auth entry 固定承载,不依赖短信或微信环境开关。 - // 当 login-options 联调失败或配置返回空数组时,仍要保留账号入口,避免登录弹窗失去可操作方式。 - return normalizedMethods.length > 0 - ? normalizedMethods - : FALLBACK_LOGIN_METHODS; + // 登录面板的核心入口必须稳定展示,login-options 只补充微信等环境相关入口。 + return Array.from( + new Set([ + ...REQUIRED_LOGIN_METHODS, + ...normalizedMethods, + ]), + ); } type AuthHydrateSessionResult = @@ -367,9 +369,9 @@ export function AuthGate({ children }: AuthGateProps) { return; } - setAvailableLoginMethods(FALLBACK_LOGIN_METHODS); + setAvailableLoginMethods(REQUIRED_LOGIN_METHODS); setUser(null); - // 中文注释:登录方式接口失败时按产品约定保留密码登录入口; + // 中文注释:登录方式接口失败时按产品约定保留验证码和密码登录入口; // 这里不展示接口读取错误,避免用户误以为登录本身不可用。 setError(callbackResult?.error ?? ''); setStatus('unauthenticated'); diff --git a/src/components/auth/LoginScreen.tsx b/src/components/auth/LoginScreen.tsx index 1e19ce9f..fc41169e 100644 --- a/src/components/auth/LoginScreen.tsx +++ b/src/components/auth/LoginScreen.tsx @@ -80,8 +80,8 @@ export function LoginScreen({ const [legalConsentChecked, setLegalConsentChecked] = useState(false); const [activeLegalDocumentId, setActiveLegalDocumentId] = useState(null); - const passwordLoginEnabled = availableLoginMethods.includes('password'); - const phoneLoginEnabled = availableLoginMethods.includes('phone'); + const passwordLoginEnabled = true; + const phoneLoginEnabled = true; const wechatLoginEnabled = availableLoginMethods.includes('wechat'); const [activeLoginTab, setActiveLoginTab] = useState('phone'); diff --git a/src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx b/src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx index f545f117..110b67e3 100644 --- a/src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx +++ b/src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx @@ -48,6 +48,8 @@ const mocapMock = vi.hoisted(() => ({ rightShoulder?: { x: number; y: number } | null; leftElbow?: { x: number; y: number } | null; rightElbow?: { x: number; y: number } | null; + leftWrist?: { x: number; y: number } | null; + rightWrist?: { x: number; y: number } | null; }; }, receivedAtMs: 1, @@ -242,16 +244,18 @@ test('renders the warmup stage and starts with the center ring step', () => { render(); expect(screen.getByTestId('child-motion-demo')).toBeTruthy(); - expect(screen.getByText('来到圆圈这里')).toBeTruthy(); + expect(screen.queryByRole('heading', { name: '来到圆圈这里' })).toBeNull(); + expect(screen.getByText('欢迎你,小朋友,见到你真开心')).toBeTruthy(); expect(screen.queryByLabelText('绿色圆环')).toBeNull(); expect(screen.getByText('请横屏体验')).toBeTruthy(); }); -test('shows narration first before revealing the step cue', async () => { +test('shows the first subtitle before revealing the step cue', async () => { vi.useFakeTimers(); render(); - expect(screen.getByText('来到圆圈这里')).toBeTruthy(); + expect(screen.getByText('欢迎你,小朋友,见到你真开心')).toBeTruthy(); + expect(screen.queryByText('来圆圈这里和我打个招呼吧')).toBeNull(); expect(screen.queryByLabelText('绿色圆环')).toBeNull(); expect(screen.getByTestId('child-motion-stage').dataset.stepPhase).toBe('intro'); @@ -261,6 +265,25 @@ test('shows narration first before revealing the step cue', async () => { expect(screen.getByTestId('child-motion-stage').dataset.stepPhase).toBe('active'); }); +test('switches the center step subtitle to the second line after a two second pause', async () => { + vi.useFakeTimers(); + render(); + + expect(screen.queryByRole('heading', { name: '来到圆圈这里' })).toBeNull(); + expect(screen.getByText('欢迎你,小朋友,见到你真开心')).toBeTruthy(); + expect(screen.queryByText('来圆圈这里和我打个招呼吧')).toBeNull(); + + await advanceWarmupTime(1999); + + expect(screen.getByText('欢迎你,小朋友,见到你真开心')).toBeTruthy(); + expect(screen.queryByText('来圆圈这里和我打个招呼吧')).toBeNull(); + + await advanceWarmupTime(1); + + expect(screen.queryByText('欢迎你,小朋友,见到你真开心')).toBeNull(); + expect(screen.getByText('来圆圈这里和我打个招呼吧')).toBeTruthy(); +}); + test('re-entering within the same runtime session opens the start button', () => { markChildMotionWarmupCompletedInRuntime(); @@ -299,6 +322,50 @@ test('developer keyboard input moves the avatar and triggers jump state', () => expect(avatar.className).toContain('child-motion-avatar--jumping'); }); +test('developer pointer input renders baby object hand indicators in warmup', async () => { + vi.useFakeTimers(); + render(); + + const stage = screen.getByTestId('child-motion-stage'); + vi.spyOn(stage, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + width: 1000, + height: 500, + top: 0, + right: 1000, + bottom: 500, + left: 0, + toJSON: () => ({}), + }); + + await revealCurrentStepCue(); + const pointerDownEvent = new Event('pointerdown', { + bubbles: true, + cancelable: true, + }); + Object.defineProperties(pointerDownEvent, { + button: { value: 0 }, + buttons: { value: 1 }, + clientX: { value: 250 }, + clientY: { value: 150 }, + pointerId: { value: 1 }, + }); + await act(async () => { + stage.dispatchEvent(pointerDownEvent); + }); + + const leftHand = screen.getByTestId('child-motion-left-hand-indicator'); + expect(leftHand.className).toContain('baby-object-runtime__hand--left'); + expect(leftHand.getAttribute('style')).toContain( + '--baby-object-hand-x: 25%', + ); + expect(leftHand.getAttribute('style')).toContain( + '--baby-object-hand-y: 30%', + ); + vi.useRealTimers(); +}); + test('mocap body center dampens small jitter before moving the avatar', async () => { setMocapBodyCenter(0.5); const { rerender } = render(); @@ -325,6 +392,68 @@ test('mocap body center dampens small jitter before moving the avatar', async () expect(style).not.toContain('left: 34%'); }); +test('mocap hand positions render with baby object hand indicators in body-side mapping', async () => { + setMocapCameraHandTrackPoint({ cameraSide: 'right', x: 0.24, y: 0.36 }); + const { rerender } = render(); + + await act(async () => { + rerender(); + }); + + const leftHand = await screen.findByTestId( + 'child-motion-left-hand-indicator', + ); + expect(leftHand.className).toContain('baby-object-runtime__hand--left'); + expect(leftHand.getAttribute('style')).toContain( + '--baby-object-hand-x: 24%', + ); + expect(leftHand.getAttribute('style')).toContain( + '--baby-object-hand-y: 36%', + ); + expect(screen.queryByTestId('child-motion-right-hand-indicator')).toBeNull(); +}); + +test('mocap hand indicators prefer skeleton wrist nodes in warmup', async () => { + const cameraRightHand = { + x: 0.24, + y: 0.36, + state: 'unknown', + side: 'right', + wrist: { x: 0.27, y: 0.39 }, + }; + mocapMock.command = { + actions: [], + bodyCenter: { x: 0.5, y: 0.7 }, + bodyJoints: { + leftShoulder: { x: 0.62, y: 0.48 }, + leftElbow: { x: 0.7, y: 0.5 }, + rightShoulder: { x: 0.38, y: 0.48 }, + rightElbow: { x: 0.3, y: 0.5 }, + rightWrist: { x: 0.64, y: 0.25 }, + }, + hands: [cameraRightHand], + primaryHand: cameraRightHand, + leftHand: null, + rightHand: cameraRightHand, + }; + mocapMock.receivedAtMs += 1; + const { rerender } = render(); + + await act(async () => { + rerender(); + }); + + const leftHand = await screen.findByTestId( + 'child-motion-left-hand-indicator', + ); + expect(leftHand.getAttribute('style')).toContain( + '--baby-object-hand-x: 64%', + ); + expect(leftHand.getAttribute('style')).toContain( + '--baby-object-hand-y: 25%', + ); +}); + test('mocap body center keeps the warmup flow on the motion data source', async () => { vi.useFakeTimers(); setMocapBodyCenter(0.5); @@ -440,6 +569,33 @@ test('mocap greeting requires a real horizontal wave track', async () => { vi.useRealTimers(); }); +test('greeting completion goes to warmup intro without praise float text', async () => { + vi.useFakeTimers(); + const { rerender, unmount } = render(); + + await revealCurrentStepCue(); + await completeCurrentPositionStepByHold(); + await vi.waitFor(() => { + expect(screen.getByText('打个招呼')).toBeTruthy(); + }); + + await revealCurrentStepCue(); + await completeGreetingByWaveTrack(rerender); + + expect(screen.queryByText('真棒')).toBeNull(); + + await advanceWarmupTime(900); + await vi.waitFor(() => { + expect(screen.getByText('准备热身')).toBeTruthy(); + }); + expect(screen.queryByText('真棒')).toBeNull(); + + await act(async () => { + unmount(); + }); + vi.useRealTimers(); +}); + test('mocap arm swing steps require body-side mapping and vertical open arm motion', async () => { vi.useFakeTimers(); const { rerender, unmount } = render(); @@ -477,6 +633,14 @@ test('mocap arm swing steps require body-side mapping and vertical open arm moti }); await revealCurrentStepCue(); + expect(screen.getByTestId('child-motion-arm-swing-guide-left')).toBeTruthy(); + expect(screen.queryByTestId('child-motion-arm-swing-guide-right')).toBeNull(); + expect( + screen + .getByTestId('child-motion-arm-swing-guide-left') + .querySelector('.child-motion-gesture-guide__arm-swing-paw-asset'), + ).toBeTruthy(); + await sendMocapCameraHandTrack(rerender, 'left', [ { x: 0.78, y: 0.5 }, { x: 0.86, y: 0.5 }, @@ -505,6 +669,14 @@ test('mocap arm swing steps require body-side mapping and vertical open arm moti }); await revealCurrentStepCue(); + expect(screen.getByTestId('child-motion-arm-swing-guide-right')).toBeTruthy(); + expect(screen.queryByTestId('child-motion-arm-swing-guide-left')).toBeNull(); + expect( + screen + .getByTestId('child-motion-arm-swing-guide-right') + .querySelector('.child-motion-gesture-guide__arm-swing-paw-asset'), + ).toBeTruthy(); + await sendMocapCameraHandTrack(rerender, 'right', [ { x: 0.2, y: 0.5 }, { x: 0.16, y: 0.42 }, @@ -519,8 +691,9 @@ test('mocap arm swing steps require body-side mapping and vertical open arm moti await advanceWarmupTime(900); await vi.waitFor(() => { - expect(screen.getByRole('heading', { name: '原地跳一下' })).toBeTruthy(); + expect(screen.getByRole('heading', { name: '热身完成' })).toBeTruthy(); }); + expect(screen.queryByRole('heading', { name: '原地跳一下' })).toBeNull(); await advanceWarmupTime(720); await act(async () => { unmount(); diff --git a/src/components/child-motion-demo/ChildMotionWarmupDemo.tsx b/src/components/child-motion-demo/ChildMotionWarmupDemo.tsx index 719f805b..a493d4fd 100644 --- a/src/components/child-motion-demo/ChildMotionWarmupDemo.tsx +++ b/src/components/child-motion-demo/ChildMotionWarmupDemo.tsx @@ -1,5 +1,5 @@ import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import { @@ -40,9 +40,9 @@ type WarmupStepPhase = 'intro' | 'active' | 'complete'; type WarmupMocapGestureIntent = | 'greeting' | 'left-hand' - | 'right-hand' - | 'jump'; + | 'right-hand'; type WarmupBodyHandSide = 'left' | 'right'; +type WarmupHandIndicators = Record; const CHILD_MOTION_BABY_OBJECT_DEMO_DRAFT: BabyObjectMatchDraft = { draftId: 'child-motion-demo-baby-object-draft', @@ -94,7 +94,9 @@ const WARMUP_GREETING_WAVE_DIRECTION_EPSILON = 0.008; const WARMUP_GREETING_WRIST_ABOVE_ELBOW_MARGIN = 0.04; const WARMUP_GREETING_WRIST_ABOVE_SHOULDER_MARGIN = 0.08; const WARMUP_STEP_INTRO_DELAY_MS = 1000; +const WARMUP_SUBTITLE_LINE_DELAY_MS = 2000; const WARMUP_STEP_COMPLETE_PAUSE_MS = 820; +const WARMUP_TOTAL_STEPS = 11; const AVATAR_MOCAP_DEAD_ZONE = 0.012; const AVATAR_MOCAP_SMOOTHING = 0.28; const AVATAR_MOCAP_MAX_STEP = 0.035; @@ -128,6 +130,13 @@ function formatAvatarLeftPercent(value: number) { return `${Math.round(clampMotionUnit(value) * 1000) / 10}%`; } +function createEmptyWarmupHandIndicators(): WarmupHandIndicators { + return { + left: null, + right: null, + }; +} + function resolveMocapHandWithBodySide( command: MocapInputCommand, side: WarmupBodyHandSide, @@ -136,6 +145,29 @@ function resolveMocapHandWithBodySide( return side === 'left' ? command.rightHand : command.leftHand; } +function resolveMocapSkeletonWristWithBodySide( + command: MocapInputCommand, + side: WarmupBodyHandSide, +) { + const joints = command.bodyJoints; + return side === 'left' ? joints?.rightWrist : joints?.leftWrist; +} + +function resolveWarmupHandIndicatorsFromMocap( + command: MocapInputCommand, +): WarmupHandIndicators { + return { + left: mocapHandToWarmupIndicatorPoint( + resolveMocapHandWithBodySide(command, 'left'), + resolveMocapSkeletonWristWithBodySide(command, 'left'), + ), + right: mocapHandToWarmupIndicatorPoint( + resolveMocapHandWithBodySide(command, 'right'), + resolveMocapSkeletonWristWithBodySide(command, 'right'), + ), + }; +} + function resolveMocapJointWithBodySide( command: MocapInputCommand, side: WarmupBodyHandSide, @@ -175,6 +207,22 @@ function mocapHandToChildMotionPoint( }; } +function mocapHandToWarmupIndicatorPoint( + hand: MocapHandInput | null | undefined, + skeletonWrist: MocapPointInput | null | undefined, +): ChildMotionPoint | null { + // 骨架手腕节点比手掌识别结果更稳定;热身指示器优先跟随骨架手腕。 + const point = skeletonWrist ?? hand?.wrist ?? hand; + if (!point) { + return null; + } + + return { + x: clampMotionUnit(point.x), + y: clampMotionUnit(point.y), + }; +} + function appendWarmupMocapPoint( points: ChildMotionPoint[], point: ChildMotionPoint, @@ -218,13 +266,6 @@ function getMotionSourceText(state: MotionSourceState) { return '正在连接动作数据'; } -function hasWarmupMocapAction( - command: MocapInputCommand, - expectedActions: string[], -) { - return command.actions.some((action) => expectedActions.includes(action)); -} - function countWarmupVerticalDirectionChanges(points: ChildMotionPoint[]) { let previousDirection = 0; let directionChanges = 0; @@ -403,7 +444,6 @@ function resolveDampedAvatarX(current: number, target: number) { function resolveWarmupMocapGestureIntent( stepId: ChildMotionWarmupStepId, - command: MocapInputCommand, paths: { leftHandPath: ChildMotionPoint[]; rightHandPath: ChildMotionPoint[]; @@ -434,19 +474,6 @@ function resolveWarmupMocapGestureIntent( return 'right-hand'; } - if ( - stepId === 'jump_once' && - hasWarmupMocapAction(command, [ - 'jump', - 'jump_once', - 'hop', - '跳跃', - '原地跳', - ]) - ) { - return 'jump'; - } - return null; } @@ -475,7 +502,6 @@ function getStepIndex(stepId: ChildMotionWarmupStepId) { 'return_center_2', 'wave_left_hand', 'wave_right_hand', - 'jump_once', 'warmup_finish', 'level_select', ]; @@ -546,11 +572,15 @@ function ChildMotionGestureGuide({ const isLeft = stepId === 'wave_left_hand'; const isRight = stepId === 'wave_right_hand'; const isGreeting = stepId === 'wave_greeting'; - const isJump = stepId === 'jump_once'; const activePath = isLeft ? leftHandPath : isRight ? rightHandPath : []; return ( - ); } @@ -630,11 +693,15 @@ export function ChildMotionWarmupDemo() { const [nowMs, setNowMs] = useState(() => Date.now()); const [leftHandPath, setLeftHandPath] = useState([]); const [rightHandPath, setRightHandPath] = useState([]); + const [handIndicators, setHandIndicators] = useState( + createEmptyWarmupHandIndicators, + ); const [activeHand, setActiveHand] = useState(null); const [isJumping, setIsJumping] = useState(false); const [justCompletedText, setJustCompletedText] = useState( null, ); + const [subtitleLineIndex, setSubtitleLineIndex] = useState(0); const [cameraAccessState, setCameraAccessState] = useState( () => typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia @@ -657,7 +724,9 @@ export function ChildMotionWarmupDemo() { step.kind === 'finish', }); const stepIndex = getStepIndex(stepId); - const progressPercent = Math.round((stepIndex / 12) * 100); + const progressPercent = Math.round( + (stepIndex / (WARMUP_TOTAL_STEPS - 1)) * 100, + ); const holdProgress = getHoldProgress(stepId, avatarX, holdStartedAt, nowMs); const isStepActive = stepPhase === 'active'; const shouldShowStepCues = stepPhase !== 'intro'; @@ -681,12 +750,14 @@ export function ChildMotionWarmupDemo() { ); const nextStep = resolveNextChildMotionWarmupStep(stepId); - if (stepId === 'jump_once') { + if (stepId === 'warmup_finish') { markChildMotionWarmupCompletedInRuntime(); } const completionText = - stepId === 'warmup_finish' || stepId === 'jump_once' ? null : '真棒'; + stepId === 'wave_greeting' || stepId === 'warmup_finish' + ? null + : '真棒'; setJustCompletedText(completionText); setStepPhase('complete'); setHoldStartedAt(null); @@ -803,6 +874,7 @@ export function ChildMotionWarmupDemo() { setHoldStartedAt(null); setLeftHandPath([]); setRightHandPath([]); + setSubtitleLineIndex(0); handledMocapPacketKeyRef.current = null; if (step.kind === 'levelSelect') { @@ -819,6 +891,20 @@ export function ChildMotionWarmupDemo() { return () => window.clearTimeout(timeout); }, [step.kind, stepId]); + useEffect(() => { + if (step.spokenLines.length <= 1) { + return; + } + + setSubtitleLineIndex(0); + const timeout = window.setTimeout(() => { + setSubtitleLineIndex((current) => + Math.min(current + 1, step.spokenLines.length - 1), + ); + }, WARMUP_SUBTITLE_LINE_DELAY_MS); + return () => window.clearTimeout(timeout); + }, [step.spokenLines, stepId]); + useEffect(() => { if (step.kind !== 'position' || !isStepActive) { return; @@ -925,7 +1011,7 @@ export function ChildMotionWarmupDemo() { setRightHandPath(nextRightHandPath); } - const intent = resolveWarmupMocapGestureIntent(stepId, command, { + const intent = resolveWarmupMocapGestureIntent(stepId, { leftHandPath: nextLeftHandPath, rightHandPath: nextRightHandPath, primaryHandPath: nextPrimaryHandPath, @@ -934,13 +1020,6 @@ export function ChildMotionWarmupDemo() { return; } - if (intent === 'jump') { - setIsJumping(true); - window.setTimeout(() => setIsJumping(false), 360); - completeStep({ type: 'jump', jumpSpace: 0.14 }); - return; - } - if (intent === 'right-hand') { const path = [...nextRightHandPath, rightPoint].filter( (point): point is ChildMotionPoint => Boolean(point), @@ -965,6 +1044,21 @@ export function ChildMotionWarmupDemo() { stepId, ]); + useEffect(() => { + if (stepPhase === 'complete' || !mocapInput.latestCommand) { + return; + } + + setHandIndicators( + resolveWarmupHandIndicatorsFromMocap(mocapInput.latestCommand), + ); + }, [ + mocapInput.latestCommand, + mocapInput.rawPacketPreview?.receivedAtMs, + mocapInput.rawPacketPreview?.text, + stepPhase, + ]); + useEffect(() => { if (stepPhase === 'complete' || !mocapInput.latestCommand) { return; @@ -1008,15 +1102,12 @@ export function ChildMotionWarmupDemo() { event.preventDefault(); setIsJumping(true); window.setTimeout(() => setIsJumping(false), 360); - if (stepId === 'jump_once' && isStepActive) { - completeStep({ type: 'jump', jumpSpace: 0.14 }); - } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [completeStep, isStepActive, stepId, stepPhase]); + }, [stepPhase]); useEffect(() => { const handleKeyUp = (event: KeyboardEvent) => { @@ -1040,20 +1131,29 @@ export function ChildMotionWarmupDemo() { return; } - if (event.button !== 0 && event.button !== 2) { + if ( + event.button !== 0 && + event.button !== 2 && + event.buttons !== 1 && + event.buttons !== 2 + ) { return; } event.preventDefault(); - const nextHand: DragHand = event.button === 2 ? 'right' : 'left'; + const nextHand: DragHand = + event.button === 2 || event.buttons === 2 ? 'right' : 'left'; setActiveHand(nextHand); const point = normalizePointerPoint(event, event.currentTarget); + setHandIndicators((current) => ({ ...current, [nextHand]: point })); if (nextHand === 'left') { setLeftHandPath([point]); } else { setRightHandPath([point]); } - event.currentTarget.setPointerCapture(event.pointerId); + if (typeof event.currentTarget.setPointerCapture === 'function') { + event.currentTarget.setPointerCapture(event.pointerId); + } }; const handleStagePointerMove = (event: ReactPointerEvent) => { @@ -1062,6 +1162,7 @@ export function ChildMotionWarmupDemo() { } const point = normalizePointerPoint(event, event.currentTarget); + setHandIndicators((current) => ({ ...current, [activeHand]: point })); const appendPoint = (points: ChildMotionPoint[]) => [...points, point].slice(-16); if (activeHand === 'left') { @@ -1076,7 +1177,10 @@ export function ChildMotionWarmupDemo() { return; } - if (event.currentTarget.hasPointerCapture(event.pointerId)) { + if ( + typeof event.currentTarget.hasPointerCapture === 'function' && + event.currentTarget.hasPointerCapture(event.pointerId) + ) { event.currentTarget.releasePointerCapture(event.pointerId); } const hand = activeHand; @@ -1110,10 +1214,8 @@ export function ChildMotionWarmupDemo() { setIsBabyObjectRuntimeOpen(true); }; - const lineText = useMemo( - () => step.spokenLines.join(','), - [step.spokenLines], - ); + const shouldHideStepTitle = stepId === 'center_arrive'; + const subtitleText = step.spokenLines[subtitleLineIndex] ?? step.spokenLines[0]; if (isBabyObjectRuntimeOpen) { return ( @@ -1170,6 +1272,7 @@ export function ChildMotionWarmupDemo() { rightHandPath={rightHandPath} /> ) : null} + {justCompletedText ? (
@@ -1178,10 +1281,16 @@ export function ChildMotionWarmupDemo() { ) : null}
- {`${Math.min(stepIndex + 1, 12)}/12`} -
-

{step.title}

-

{lineText}

+ {`${Math.min(stepIndex + 1, WARMUP_TOTAL_STEPS)}/${WARMUP_TOTAL_STEPS}`} +
+ {shouldHideStepTitle ? null :

{step.title}

} +

{subtitleText}

{progressPercent}%
diff --git a/src/components/child-motion-demo/childMotionWarmupModel.test.ts b/src/components/child-motion-demo/childMotionWarmupModel.test.ts index e5193de6..eb52ca20 100644 --- a/src/components/child-motion-demo/childMotionWarmupModel.test.ts +++ b/src/components/child-motion-demo/childMotionWarmupModel.test.ts @@ -22,7 +22,6 @@ describe('childMotionWarmupModel', () => { 'return_center_2', 'wave_left_hand', 'wave_right_hand', - 'jump_once', 'warmup_finish', 'level_select', ]); @@ -76,19 +75,11 @@ describe('childMotionWarmupModel', () => { ], }, ); - const completed = applyChildMotionWarmupCompletion( - 'jump_once', - withRightHand, - { - type: 'jump', - jumpSpace: 0.14, - }, - ); - expect(completed.leftBoundary).toBeCloseTo(0.16); - expect(completed.rightBoundary).toBeCloseTo(0.16); - expect(completed.leftHandPath).toHaveLength(2); - expect(completed.leftHandSpace).toEqual({ + expect(withRightHand.leftBoundary).toBeCloseTo(0.16); + expect(withRightHand.rightBoundary).toBeCloseTo(0.16); + expect(withRightHand.leftHandPath).toHaveLength(2); + expect(withRightHand.leftHandSpace).toEqual({ minX: 0.3, maxX: 0.34, minY: 0.32, @@ -97,7 +88,6 @@ describe('childMotionWarmupModel', () => { maxAngleDeg: 44, maxReach: 0.28, }); - expect(completed.rightHandSpace?.maxReach).toBe(0.31); - expect(completed.jumpSpace).toBe(0.14); + expect(withRightHand.rightHandSpace?.maxReach).toBe(0.31); }); }); diff --git a/src/components/child-motion-demo/childMotionWarmupModel.ts b/src/components/child-motion-demo/childMotionWarmupModel.ts index efcc591f..a54d5d13 100644 --- a/src/components/child-motion-demo/childMotionWarmupModel.ts +++ b/src/components/child-motion-demo/childMotionWarmupModel.ts @@ -8,7 +8,6 @@ export type ChildMotionWarmupStepId = | 'return_center_2' | 'wave_left_hand' | 'wave_right_hand' - | 'jump_once' | 'warmup_finish' | 'level_select'; @@ -55,7 +54,6 @@ export type ChildMotionWarmupCalibration = { rightHandPath: ChildMotionPoint[]; leftHandSpace: ChildMotionHandSpace | null; rightHandSpace: ChildMotionHandSpace | null; - jumpSpace: number | null; }; export type ChildMotionWarmupCompletion = @@ -71,10 +69,6 @@ export type ChildMotionWarmupCompletion = type: 'right-hand'; path: ChildMotionPoint[]; } - | { - type: 'jump'; - jumpSpace: number; - } | { type: 'narration'; }; @@ -92,14 +86,14 @@ export const CHILD_MOTION_WARMUP_STEPS: ChildMotionWarmupStep[] = [ id: 'center_arrive', kind: 'position', title: '来到圆圈这里', - spokenLines: ['欢迎你,小朋友,见到你真开心', '请你来到圆圈这里和我打个招呼吧'], + spokenLines: ['欢迎你,小朋友,见到你真开心', '来圆圈这里和我打个招呼吧'], target: 'center', }, { id: 'wave_greeting', kind: 'gesture', title: '打个招呼', - spokenLines: ['请你来到圆圈这里和我打个招呼吧'], + spokenLines: ['来圆圈这里和我打个招呼吧'], }, { id: 'warmup_intro', @@ -147,12 +141,6 @@ export const CHILD_MOTION_WARMUP_STEPS: ChildMotionWarmupStep[] = [ title: '挥动右手', spokenLines: ['挥动右手'], }, - { - id: 'jump_once', - kind: 'gesture', - title: '原地跳一下', - spokenLines: ['原地跳一下'], - }, { id: 'warmup_finish', kind: 'finish', @@ -224,7 +212,6 @@ export function createEmptyChildMotionCalibration(): ChildMotionWarmupCalibratio rightHandPath: [], leftHandSpace: null, rightHandSpace: null, - jumpSpace: null, }; } @@ -290,13 +277,6 @@ export function applyChildMotionWarmupCompletion( }; } - if (stepId === 'jump_once' && completion.type === 'jump') { - return { - ...calibration, - jumpSpace: completion.jumpSpace, - }; - } - return calibration; } diff --git a/src/components/custom-world-home/creationWorkShelf.test.ts b/src/components/custom-world-home/creationWorkShelf.test.ts index 300961d3..156f8ce7 100644 --- a/src/components/custom-world-home/creationWorkShelf.test.ts +++ b/src/components/custom-world-home/creationWorkShelf.test.ts @@ -86,6 +86,58 @@ test('buildCreationWorkShelfItems attaches open and delete actions through shelf expect(onDeletePuzzle).toHaveBeenCalledWith(puzzleWork); }); +test('buildCreationWorkShelfItems restores persisted generation state for puzzle and match3d drafts', () => { + const items = buildCreationWorkShelfItems({ + rpgItems: [], + bigFishItems: [], + puzzleItems: [ + { + workId: 'puzzle:generating', + profileId: 'puzzle-profile-generating', + ownerUserId: 'user-1', + sourceSessionId: 'puzzle-session-generating', + authorDisplayName: '测试作者', + levelName: '生成中拼图', + summary: '退出产品后仍应显示生成中。', + themeTags: [], + coverImageSrc: null, + publicationStatus: 'draft', + updatedAt: '2026-05-08T00:00:00.000Z', + publishedAt: null, + publishReady: false, + generationStatus: 'generating', + }, + ], + match3dItems: [ + { + workId: 'match3d:generating', + profileId: 'match3d-profile-generating', + ownerUserId: 'user-1', + sourceSessionId: 'match3d-session-generating', + gameName: '生成中抓鹅', + themeText: '糖果厨房', + summary: '退出产品后仍应显示生成中。', + tags: [], + coverImageSrc: null, + clearCount: 18, + difficulty: 1, + publicationStatus: 'draft', + playCount: 0, + updatedAt: '2026-05-07T00:00:00.000Z', + publishReady: false, + generationStatus: 'generating', + }, + ], + }); + + expect(items.find((item) => item.kind === 'puzzle')?.isGenerating).toBe( + true, + ); + expect(items.find((item) => item.kind === 'match3d')?.isGenerating).toBe( + true, + ); +}); + test('buildCreationWorkShelfItems maps baby object match local drafts', () => { const onOpenBabyObjectMatchDetail = vi.fn(); const onDeleteBabyObjectMatch = vi.fn(); diff --git a/src/components/custom-world-home/creationWorkShelf.ts b/src/components/custom-world-home/creationWorkShelf.ts index e97c6551..410f7ea7 100644 --- a/src/components/custom-world-home/creationWorkShelf.ts +++ b/src/components/custom-world-home/creationWorkShelf.ts @@ -238,13 +238,19 @@ export function buildCreationWorkShelfItems(params: { ] .map((item) => { const state = getItemState?.(item); + const persistedIsGenerating = isPersistedCreationWorkGenerating(item); return state ? { ...item, - isGenerating: state.isGenerating, + isGenerating: Boolean(state.isGenerating || persistedIsGenerating), hasUnreadUpdate: state.hasUnreadUpdate, } - : item; + : persistedIsGenerating + ? { + ...item, + isGenerating: true, + } + : item; }) .sort( (left, right) => @@ -793,6 +799,17 @@ function buildPuzzleWorkShelfActions( }; } +function isPersistedCreationWorkGenerating(item: CreationWorkShelfItem) { + switch (item.source.kind) { + case 'match3d': + return item.source.item.generationStatus === 'generating'; + case 'puzzle': + return item.source.item.generationStatus === 'generating'; + default: + return false; + } +} + function buildRpgWorkShelfActions( item: CustomWorldWorkSummary, adapter: RpgWorkShelfAdapter, diff --git a/src/components/edutainment-result/BabyObjectMatchResultView.test.tsx b/src/components/edutainment-result/BabyObjectMatchResultView.test.tsx index 8932272e..cef47414 100644 --- a/src/components/edutainment-result/BabyObjectMatchResultView.test.tsx +++ b/src/components/edutainment-result/BabyObjectMatchResultView.test.tsx @@ -94,14 +94,6 @@ function createGeneratedDraft() { generationProvider: 'vector-engine-gpt-image-2', prompt: 'background', }, - { - assetId: 'baby-object-visual-ui-frame', - assetKind: 'ui-frame', - imageSrc: 'data:image/png;base64,ui', - assetObjectId: null, - generationProvider: 'vector-engine-gpt-image-2', - prompt: 'ui', - }, { assetId: 'baby-object-visual-gift-box', assetKind: 'gift-box', @@ -118,14 +110,6 @@ function createGeneratedDraft() { generationProvider: 'vector-engine-gpt-image-2', prompt: 'basket', }, - { - assetId: 'baby-object-visual-smoke-puff', - assetKind: 'smoke-puff', - imageSrc: 'data:image/png;base64,smoke', - assetObjectId: null, - generationProvider: 'vector-engine-gpt-image-2', - prompt: 'smoke', - }, ], }, }); diff --git a/src/components/edutainment-result/BabyObjectMatchResultView.tsx b/src/components/edutainment-result/BabyObjectMatchResultView.tsx index 964617a1..5c3e8bd0 100644 --- a/src/components/edutainment-result/BabyObjectMatchResultView.tsx +++ b/src/components/edutainment-result/BabyObjectMatchResultView.tsx @@ -38,10 +38,8 @@ function normalizeDraftForAction(draft: BabyObjectMatchDraft) { const REQUIRED_VISUAL_ASSET_KINDS = [ 'background', - 'ui-frame', 'gift-box', 'basket', - 'smoke-puff', ] as const; export function BabyObjectMatchResultView({ diff --git a/src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx b/src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx index 0865c503..7f9150f7 100644 --- a/src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx +++ b/src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx @@ -157,6 +157,7 @@ function dispatchPointerEvent( options: { pointerId: number; button?: number; + buttons?: number; clientX: number; clientY: number; }, @@ -164,9 +165,10 @@ function dispatchPointerEvent( const event = new Event(type, { bubbles: true, cancelable: true }); Object.assign(event, options); target.dispatchEvent(event); + return event; } -function dragHand(stage: HTMLElement, button: 0 | 2) { +function setStageRect(stage: HTMLElement) { Object.defineProperty(stage, 'getBoundingClientRect', { configurable: true, value: () => ({ @@ -181,34 +183,67 @@ function dragHand(stage: HTMLElement, button: 0 | 2) { toJSON: () => ({}), }), }); +} + +function dragItemWithHand(stage: HTMLElement, button: 0 | 2, targetX: number) { + setStageRect(stage); act(() => { dispatchPointerEvent(stage, 'pointerdown', { pointerId: button + 1, button, - clientX: 20, - clientY: 140, + buttons: button === 2 ? 2 : 1, + clientX: 160, + clientY: 89, }); }); act(() => { dispatchPointerEvent(stage, 'pointermove', { pointerId: button + 1, button, - clientX: 120, - clientY: 140, + buttons: button === 2 ? 2 : 1, + clientX: targetX, + clientY: 190, }); }); act(() => { dispatchPointerEvent(stage, 'pointerup', { pointerId: button + 1, button, - clientX: 120, - clientY: 140, + buttons: 0, + clientX: targetX, + clientY: 190, }); }); } async function advanceRoundIntro() { + await advanceInitialTargetPreview(); + await advanceGiftIntro(); +} + +async function advanceInitialTargetPreview() { + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(720); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(720); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); +} + +async function advanceGiftIntro() { await act(async () => { await vi.advanceTimersByTimeAsync(620); }); @@ -236,6 +271,7 @@ test('shows the first gift item after gift and item animations', async () => { ); expect(screen.getByText('将物品放入对应的篮子里')).toBeTruthy(); + expect(screen.getByTestId('baby-object-intro-item')).toBeTruthy(); expect(screen.getByTestId('baby-object-current-item').textContent).toBe(''); await advanceRoundIntro(); @@ -246,6 +282,56 @@ test('shows the first gift item after gift and item animations', async () => { vi.useRealTimers(); }); +test('previews both target items before the first gift box round', async () => { + vi.useFakeTimers(); + render( + , + ); + + expect(screen.getByTestId('baby-object-intro-item')).toBeTruthy(); + expect( + within(screen.getByTestId('baby-object-intro-item')).getByAltText('苹果'), + ).toBeTruthy(); + expect(screen.queryByLabelText('礼物盒')).toBeNull(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + + expect(screen.getByTestId('baby-object-intro-item').className).toContain( + 'baby-object-runtime__intro-item--flying', + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(720); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + + expect(screen.getByTestId('baby-object-intro-item')).toBeTruthy(); + expect( + within(screen.getByTestId('baby-object-intro-item')).getByAltText('香蕉'), + ).toBeTruthy(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(720); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + + expect(screen.queryByTestId('baby-object-intro-item')).toBeNull(); + expect(screen.getByLabelText('礼物盒')).toBeTruthy(); + vi.useRealTimers(); +}); + test('applies generated visual package to stage, gift box, baskets, smoke and hud', async () => { vi.useFakeTimers(); const { container } = render( @@ -270,6 +356,8 @@ test('applies generated visual package to stage, gift box, baskets, smoke and hu expect(stage.style.getPropertyValue('--baby-object-smoke-image')).toContain( 'smoke', ); + await advanceInitialTargetPreview(); + expect(screen.getByAltText('礼物盒')).toBeTruthy(); expect( container.querySelector('.baby-object-runtime__basket-shell-image'), @@ -283,6 +371,80 @@ test('applies generated visual package to stage, gift box, baskets, smoke and hu vi.useRealTimers(); }); +test('uses default runtime hand indicators instead of per-draft generated hand assets', async () => { + vi.useFakeTimers(); + const random = createRandomSequence([0, 0]); + const draftWithLegacyHandAssets: BabyObjectMatchDraft = { + ...createVisualPackageDraft(), + visualPackage: { + ...createVisualPackageDraft().visualPackage!, + assets: [ + ...createVisualPackageDraft().visualPackage!.assets, + { + assetId: 'legacy-left-hand', + assetKind: 'left-hand', + imageSrc: 'data:image/png;base64,legacy-left-hand', + assetObjectId: null, + generationProvider: 'vector-engine-gpt-image-2', + prompt: '旧左手', + }, + { + assetId: 'legacy-right-hand', + assetKind: 'right-hand', + imageSrc: 'data:image/png;base64,legacy-right-hand', + assetObjectId: null, + generationProvider: 'vector-engine-gpt-image-2', + prompt: '旧右手', + }, + ], + }, + }; + const { container, rerender } = render( + , + ); + + await advanceRoundIntro(); + + rerender( + , + ); + + expect(screen.getByTestId('baby-object-left-hand')).toBeTruthy(); + const stage = container.querySelector('.baby-object-runtime__stage'); + expect(stage).toBeInstanceOf(HTMLElement); + expect( + (stage as HTMLElement).style.getPropertyValue( + '--baby-object-left-hand-image', + ), + ).toBe(''); + expect( + (stage as HTMLElement).style.getPropertyValue( + '--baby-object-right-hand-image', + ), + ).toBe(''); + vi.useRealTimers(); +}); + test('removes the gift box after smoke releases the current item', async () => { vi.useFakeTimers(); render( @@ -292,6 +454,8 @@ test('removes the gift box after smoke releases the current item', async () => { />, ); + await advanceInitialTargetPreview(); + expect(screen.getByLabelText('礼物盒')).toBeTruthy(); await act(async () => { @@ -335,10 +499,16 @@ test('keeps left and right baskets fixed while only the gift item is random', as ).toBeTruthy(); expect(screen.getByLabelText('左侧篮子 苹果')).toBeTruthy(); expect(screen.getByLabelText('右侧篮子 香蕉')).toBeTruthy(); + expect( + within(screen.getByLabelText('左侧篮子 苹果')).getByText('苹果'), + ).toBeTruthy(); + expect( + within(screen.getByLabelText('右侧篮子 香蕉')).getByText('香蕉'), + ).toBeTruthy(); vi.useRealTimers(); }); -test('mocap camera-right hand movement sends the player left hand item into the left basket', async () => { +test('mocap hand must touch the current item before dropping it into a basket', async () => { vi.useFakeTimers(); const random = createRandomSequence([0, 0]); const { rerender } = render( @@ -358,13 +528,211 @@ test('mocap camera-right hand movement sends the player left hand item into the mocapInput={createMocapInput({ latestCommand: { actions: [], - hands: [{ x: 0.22, y: 0.45, state: 'open_palm', side: 'right' }], - primaryHand: { x: 0.22, y: 0.45, state: 'open_palm', side: 'right' }, + hands: [{ x: 0.24, y: 0.72, state: 'open_palm', side: 'right' }], + primaryHand: { x: 0.24, y: 0.72, state: 'open_palm', side: 'right' }, leftHand: null, - rightHand: { x: 0.22, y: 0.45, state: 'open_palm', side: 'right' }, + rightHand: { x: 0.24, y: 0.72, state: 'open_palm', side: 'right' }, }, rawPacketPreview: { - text: 'camera-right-horizontal-1', + text: 'drop-without-grab', + receivedAtMs: 1, + }, + })} + />, + ); + + expect(screen.queryByText('真棒')).toBeNull(); + expect(screen.queryByText('再想一想吧')).toBeNull(); + + rerender( + , + ); + + expect(screen.getByTestId('baby-object-left-hand')).toBeTruthy(); + expect(screen.queryByText('真棒')).toBeNull(); + + rerender( + , + ); + + expect(screen.getByText('真棒')).toBeTruthy(); + expect(screen.getByLabelText('成功次数').textContent).toBe('1/20'); + vi.useRealTimers(); +}); + +test('mocap hand uses skeleton wrist before hand landmark points in baby object runtime', async () => { + vi.useFakeTimers(); + const random = createRandomSequence([0, 0]); + const { rerender } = render( + , + ); + + await advanceRoundIntro(); + + rerender( + , + ); + + expect(screen.queryByTestId('baby-object-left-hand')).toBeTruthy(); + expect(screen.queryByText('真棒')).toBeNull(); + + rerender( + , + ); + + expect(screen.getByTestId('baby-object-left-hand').className).toContain( + 'baby-object-runtime__hand--holding-left-corner', + ); + vi.useRealTimers(); +}); + +test('basket judgement accepts the enlarged basket edge while keeping center gap safe', async () => { + vi.useFakeTimers(); + const random = createRandomSequence([0, 0]); + const { rerender } = render( + , + ); + + await advanceRoundIntro(); + + rerender( + , ); - rerender( - , - ); - expect(screen.queryByText('真棒')).toBeNull(); + expect(screen.queryByText('再想一想吧')).toBeNull(); + expect(screen.getByLabelText('成功次数').textContent).toBe('0/20'); rerender( , @@ -438,7 +788,7 @@ test('mocap camera-right hand movement sends the player left hand item into the vi.useRealTimers(); }); -test('mocap camera-left hand movement sends the player right hand item into the right basket', async () => { +test('either mocap hand can drag the current item into either basket', async () => { vi.useFakeTimers(); const random = createRandomSequence([0, 0]); const { rerender } = render( @@ -458,12 +808,12 @@ test('mocap camera-left hand movement sends the player right hand item into the mocapInput={createMocapInput({ latestCommand: { actions: [], - hands: [{ x: 0.82, y: 0.45, state: 'open_palm', side: 'left' }], - primaryHand: { x: 0.82, y: 0.45, state: 'open_palm', side: 'left' }, - leftHand: { x: 0.82, y: 0.45, state: 'open_palm', side: 'left' }, + hands: [{ x: 0.5, y: 0.37, state: 'open_palm', side: 'left' }], + primaryHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'left' }, + leftHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'left' }, rightHand: null, }, - rawPacketPreview: { text: 'camera-left-horizontal-1', receivedAtMs: 1 }, + rawPacketPreview: { text: 'right-hand-touch-item', receivedAtMs: 1 }, })} />, ); @@ -475,48 +825,12 @@ test('mocap camera-left hand movement sends the player right hand item into the mocapInput={createMocapInput({ latestCommand: { actions: [], - hands: [{ x: 0.8, y: 0.45, state: 'open_palm', side: 'left' }], - primaryHand: { x: 0.8, y: 0.45, state: 'open_palm', side: 'left' }, - leftHand: { x: 0.8, y: 0.45, state: 'open_palm', side: 'left' }, + hands: [{ x: 0.78, y: 0.78, state: 'open_palm', side: 'left' }], + primaryHand: { x: 0.78, y: 0.78, state: 'open_palm', side: 'left' }, + leftHand: { x: 0.78, y: 0.78, state: 'open_palm', side: 'left' }, rightHand: null, }, - rawPacketPreview: { text: 'camera-left-horizontal-2', receivedAtMs: 2 }, - })} - />, - ); - - rerender( - , - ); - - expect(screen.queryByText('再想一想吧')).toBeNull(); - - rerender( - , ); @@ -526,7 +840,84 @@ test('mocap camera-left hand movement sends the player right hand item into the vi.useRealTimers(); }); -test('mocap action names do not select a basket without horizontal hand movement', async () => { +test('holding hand indicator anchors to the lower item corner by hand side', async () => { + vi.useFakeTimers(); + const leftHandRandom = createRandomSequence([0, 0]); + const leftHandRuntime = render( + , + ); + + await advanceRoundIntro(); + + leftHandRuntime.rerender( + , + ); + + expect(screen.getByTestId('baby-object-left-hand').className).toContain( + 'baby-object-runtime__hand--holding-left-corner', + ); + + leftHandRuntime.unmount(); + + const rightHandRandom = createRandomSequence([0, 0]); + const rightHandRuntime = render( + , + ); + + await advanceRoundIntro(); + + rightHandRuntime.rerender( + , + ); + + expect(screen.getByTestId('baby-object-right-hand').className).toContain( + 'baby-object-runtime__hand--holding-right-corner', + ); + rightHandRuntime.unmount(); + vi.useRealTimers(); +}); + +test('mocap action names do not select a basket without touching and dragging item', async () => { vi.useFakeTimers(); const random = createRandomSequence([0, 0]); const { rerender } = render( @@ -564,7 +955,7 @@ test('mocap action names do not select a basket without horizontal hand movement vi.useRealTimers(); }); -test('mocap unknown hand horizontal movement does not select a basket', async () => { +test('mocap unknown hand movement does not grab or select a basket', async () => { vi.useFakeTimers(); const random = createRandomSequence([0, 0]); const { rerender } = render( @@ -578,7 +969,8 @@ test('mocap unknown hand horizontal movement does not select a basket', async () await advanceRoundIntro(); for (let index = 0; index < 4; index += 1) { - const x = [0.22, 0.24, 0.22, 0.31][index] ?? 0.22; + const x = [0.5, 0.5, 0.22, 0.22][index] ?? 0.5; + const y = [0.37, 0.78, 0.78, 0.37][index] ?? 0.37; rerender( { +test('left mouse hand drags a correct item into the left basket', async () => { vi.useFakeTimers(); const { container } = render( { throw new Error('Missing baby object runtime stage'); } - dragHand(stage, 0); + dragItemWithHand(stage, 0, 70); expect(screen.queryByText('真棒')).toBeNull(); expect(screen.getByLabelText('成功次数').textContent).toBe('0/20'); await advanceRoundIntro(); - dragHand(stage, 0); + dragItemWithHand(stage, 0, 70); expect(screen.getByText('真棒')).toBeTruthy(); expect(screen.getByLabelText('成功次数').textContent).toBe('1/20'); vi.useRealTimers(); }); +test('keeps the back button outside active gameplay pointer input', async () => { + vi.useFakeTimers(); + const onBack = vi.fn(); + render( + , + ); + + await advanceRoundIntro(); + + const backButton = screen.getByRole('button', { name: '返回' }); + let pointerDownEvent!: Event; + act(() => { + pointerDownEvent = dispatchPointerEvent(backButton, 'pointerdown', { + pointerId: 9, + button: 0, + buttons: 1, + clientX: 16, + clientY: 16, + }); + }); + + expect(pointerDownEvent.defaultPrevented).toBe(false); + expect(screen.queryByTestId('baby-object-left-hand')).toBeNull(); + + act(() => { + backButton.click(); + }); + + expect(onBack).toHaveBeenCalledTimes(1); + vi.useRealTimers(); +}); + test('correct placement automatically shows the next gift item', async () => { vi.useFakeTimers(); const { container } = render( @@ -691,7 +1119,7 @@ test('correct placement automatically shows the next gift item', async () => { within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'), ).toBeTruthy(); - dragHand(stage, 0); + dragItemWithHand(stage, 0, 70); expect(screen.getByText('真棒')).toBeTruthy(); @@ -722,7 +1150,7 @@ test('wrong basket keeps the item active after feedback', async () => { } await advanceRoundIntro(); - dragHand(stage, 2); + dragItemWithHand(stage, 2, 250); expect(screen.getByText('再想一想吧')).toBeTruthy(); expect(screen.getByLabelText('成功次数').textContent).toBe('0/20'); @@ -752,7 +1180,7 @@ test('twenty correct placements completes the level', async () => { for (let index = 0; index < 20; index += 1) { await advanceRoundIntro(); - dragHand(stage, 0); + dragItemWithHand(stage, 0, 70); await advanceFeedback(); } diff --git a/src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx b/src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx index 4dffe18b..41409be1 100644 --- a/src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx +++ b/src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx @@ -33,8 +33,15 @@ const BABY_OBJECT_MATCH_GIFT_APPEAR_DURATION_MS = 620; const BABY_OBJECT_MATCH_GIFT_OPEN_DURATION_MS = 640; const BABY_OBJECT_MATCH_ITEM_APPEAR_DURATION_MS = 620; const BABY_OBJECT_MATCH_FEEDBACK_DURATION_MS = 1180; -const BABY_OBJECT_MATCH_MIN_HORIZONTAL_MOVE_DISTANCE = 0.05; -const BABY_OBJECT_MATCH_HAND_PATH_LIMIT = 16; +const BABY_OBJECT_MATCH_INTRO_SHOW_DURATION_MS = 2000; +const BABY_OBJECT_MATCH_INTRO_FLY_DURATION_MS = 720; +const BABY_OBJECT_MATCH_INTRO_READY_PAUSE_MS = 1000; +const BABY_OBJECT_MATCH_ITEM_CENTER: RuntimeHandPoint = { x: 0.5, y: 0.37 }; +const BABY_OBJECT_MATCH_ITEM_GRAB_RADIUS = 0.14; +// 篮子仍只认主体附近,但在上一版核心区基础上扩大约 50%,避免贴近篮子后仍难以命中。 +const BABY_OBJECT_MATCH_BASKET_DROP_Y = 0.62; +const BABY_OBJECT_MATCH_LEFT_BASKET_MAX_X = 0.36; +const BABY_OBJECT_MATCH_RIGHT_BASKET_MIN_X = 0.64; type BabyObjectMatchRuntimeShellProps = { draft: BabyObjectMatchDraft; @@ -48,6 +55,12 @@ type BabyObjectMatchRuntimeShellProps = { type BasketSide = 'left' | 'right'; type RuntimePhase = + | 'intro-left-showing' + | 'intro-left-flying' + | 'intro-left-ready' + | 'intro-right-showing' + | 'intro-right-flying' + | 'intro-right-ready' | 'gift-entering' | 'gift-opening' | 'item-appearing' @@ -61,10 +74,10 @@ type RuntimeRound = { baskets: Record; }; -type DragState = { +type RuntimeIntroShowcase = { side: BasketSide; - startX: number; - lastX: number; + item: BabyObjectMatchItemAsset; + isFlying: boolean; }; type RuntimeHandPoint = { @@ -72,9 +85,12 @@ type RuntimeHandPoint = { y: number; }; -type RuntimeMocapHandPaths = { - left: RuntimeHandPoint[]; - right: RuntimeHandPoint[]; +type RuntimeHandRole = 'left' | 'right'; + +type RuntimeHands = Record; + +type HeldItemState = { + hand: RuntimeHandRole; }; type BabyObjectMatchRandom = () => number; @@ -113,74 +129,72 @@ function buildRuntimeRound( }; } -function isHorizontalDrag(dragState: DragState) { - return ( - Math.abs(dragState.lastX - dragState.startX) >= - BABY_OBJECT_MATCH_MIN_HORIZONTAL_MOVE_DISTANCE - ); -} - function mocapHandToRuntimePoint( hand: MocapHandInput | null | undefined, + skeletonWrist: RuntimeHandPoint | null | undefined, ): RuntimeHandPoint | null { + if (skeletonWrist) { + return clampRuntimePoint(skeletonWrist); + } + if (!hand) { return null; } - return { x: hand.x, y: hand.y }; + // 骨架 wrist 缺失时再回退到手部 landmarks 的 wrist,最后才使用手部派生点。 + const point = hand.wrist ?? hand; + return clampRuntimePoint({ x: point.x, y: point.y }); } -function appendRuntimeHandPoint( - points: RuntimeHandPoint[], - point: RuntimeHandPoint, -) { - return [...points, point].slice(-BABY_OBJECT_MATCH_HAND_PATH_LIMIT); +function clampRuntimePoint(point: RuntimeHandPoint): RuntimeHandPoint { + return { + x: Math.max(0, Math.min(1, point.x)), + y: Math.max(0, Math.min(1, point.y)), + }; } -function hasRuntimeHorizontalMovePath(points: RuntimeHandPoint[]) { - if (points.length < 3) { - return false; - } +function isRuntimePointTouchingItem(point: RuntimeHandPoint) { + const dx = point.x - BABY_OBJECT_MATCH_ITEM_CENTER.x; + const dy = point.y - BABY_OBJECT_MATCH_ITEM_CENTER.y; + return Math.sqrt(dx * dx + dy * dy) <= BABY_OBJECT_MATCH_ITEM_GRAB_RADIUS; +} - const xValues = points.map((point) => point.x); +function isRuntimeControlPointerTarget(target: EventTarget | null) { return ( - Math.max(...xValues) - Math.min(...xValues) >= - BABY_OBJECT_MATCH_MIN_HORIZONTAL_MOVE_DISTANCE + target instanceof Element && + target.closest( + 'button, a, input, select, textarea, [role="button"], [data-baby-object-runtime-control="true"]', + ) !== null ); } -function resolveMocapHandPaths( - command: MocapInputCommand, - currentPaths: RuntimeMocapHandPaths, -) { - // 本地 mocap 当前按摄像头视角输出 handedness,这里换回用户身体视角再选篮。 - const leftPoint = mocapHandToRuntimePoint(command.rightHand); - const rightPoint = mocapHandToRuntimePoint(command.leftHand); - - return { - left: leftPoint - ? appendRuntimeHandPoint(currentPaths.left, leftPoint) - : currentPaths.left, - right: rightPoint - ? appendRuntimeHandPoint(currentPaths.right, rightPoint) - : currentPaths.right, - } satisfies RuntimeMocapHandPaths; -} - -function resolveMocapHorizontalMoveSide( - paths: RuntimeMocapHandPaths, -): BasketSide | null { - if (hasRuntimeHorizontalMovePath(paths.left)) { +function resolveBasketSideForPoint(point: RuntimeHandPoint): BasketSide | null { + if (point.y < BABY_OBJECT_MATCH_BASKET_DROP_Y) { + return null; + } + if (point.x <= BABY_OBJECT_MATCH_LEFT_BASKET_MAX_X) { return 'left'; } - - if (hasRuntimeHorizontalMovePath(paths.right)) { + if (point.x >= BABY_OBJECT_MATCH_RIGHT_BASKET_MIN_X) { return 'right'; } - return null; } +function resolveMocapRuntimeHands(command: MocapInputCommand): RuntimeHands { + // 本地 mocap 当前按摄像头视角输出 handedness,这里换回用户身体视角用于显示双手。 + return { + left: mocapHandToRuntimePoint( + command.rightHand, + command.bodyJoints?.rightWrist, + ), + right: mocapHandToRuntimePoint( + command.leftHand, + command.bodyJoints?.leftWrist, + ), + }; +} + function buildMocapPacketKey( command: MocapInputCommand, rawPacketPreview: UseMocapInputResult['rawPacketPreview'], @@ -204,6 +218,44 @@ function buildCssImageValue(src: string) { return `url("${src.replace(/"/gu, '\\"')}")`; } +function resolveIntroShowcase( + phase: RuntimePhase, + draft: BabyObjectMatchDraft, +): RuntimeIntroShowcase | null { + if (phase === 'intro-left-showing' || phase === 'intro-left-flying') { + const item = draft.itemAssets[0]; + return item + ? { side: 'left', item, isFlying: phase === 'intro-left-flying' } + : null; + } + + if (phase === 'intro-right-showing' || phase === 'intro-right-flying') { + const item = draft.itemAssets[1]; + return item + ? { side: 'right', item, isFlying: phase === 'intro-right-flying' } + : null; + } + + return null; +} + +function isBasketOptionReadyInIntro(side: BasketSide, phase: RuntimePhase) { + if (!phase.startsWith('intro-')) { + return true; + } + + if (side === 'left') { + return ( + phase === 'intro-left-ready' || + phase === 'intro-right-showing' || + phase === 'intro-right-flying' || + phase === 'intro-right-ready' + ); + } + + return phase === 'intro-right-ready'; +} + export function BabyObjectMatchRuntimeShell({ draft, embedded = false, @@ -218,20 +270,20 @@ export function BabyObjectMatchRuntimeShell({ ); const introTimerRef = useRef(null); const feedbackTimerRef = useRef(null); - const dragStateRef = useRef(null); const handledMocapPacketKeyRef = useRef(null); const latestMocapPacketKeyRef = useRef(null); - const mocapHandPathsRef = useRef({ - left: [], - right: [], - }); - const [phase, setPhase] = useState('gift-entering'); + const [phase, setPhase] = useState('intro-left-showing'); const [successCount, setSuccessCount] = useState(0); const [round, setRound] = useState(() => buildRuntimeRound(draft, randomRef.current), ); const [feedbackText, setFeedbackText] = useState(null); const [lastTargetSide, setLastTargetSide] = useState(null); + const [runtimeHands, setRuntimeHands] = useState({ + left: null, + right: null, + }); + const [heldItem, setHeldItem] = useState(null); const liveMocapInput = useMocapInput({ enabled: enableMocapInput && !mocapInput, }); @@ -276,6 +328,8 @@ export function BabyObjectMatchRuntimeShell({ const isComplete = phase === 'complete'; const currentItem = round?.item ?? null; const isJudgementOpen = phase === 'active'; + const introShowcase = resolveIntroShowcase(phase, draft); + const heldPoint = heldItem ? runtimeHands[heldItem.hand] : null; const shouldShowCurrentItem = currentItem && (phase === 'item-appearing' || @@ -314,14 +368,61 @@ export function BabyObjectMatchRuntimeShell({ }, []); const resetInputPaths = useCallback(() => { - dragStateRef.current = null; handledMocapPacketKeyRef.current = null; - mocapHandPathsRef.current = { left: [], right: [] }; + setHeldItem(null); }, []); useEffect(() => { clearIntroTimer(); + if (phase === 'intro-left-showing') { + introTimerRef.current = window.setTimeout(() => { + introTimerRef.current = null; + setPhase('intro-left-flying'); + }, BABY_OBJECT_MATCH_INTRO_SHOW_DURATION_MS); + return clearIntroTimer; + } + + if (phase === 'intro-left-flying') { + introTimerRef.current = window.setTimeout(() => { + introTimerRef.current = null; + setPhase('intro-left-ready'); + }, BABY_OBJECT_MATCH_INTRO_FLY_DURATION_MS); + return clearIntroTimer; + } + + if (phase === 'intro-left-ready') { + introTimerRef.current = window.setTimeout(() => { + introTimerRef.current = null; + setPhase('intro-right-showing'); + }, BABY_OBJECT_MATCH_INTRO_READY_PAUSE_MS); + return clearIntroTimer; + } + + if (phase === 'intro-right-showing') { + introTimerRef.current = window.setTimeout(() => { + introTimerRef.current = null; + setPhase('intro-right-flying'); + }, BABY_OBJECT_MATCH_INTRO_SHOW_DURATION_MS); + return clearIntroTimer; + } + + if (phase === 'intro-right-flying') { + introTimerRef.current = window.setTimeout(() => { + introTimerRef.current = null; + setPhase('intro-right-ready'); + }, BABY_OBJECT_MATCH_INTRO_FLY_DURATION_MS); + return clearIntroTimer; + } + + if (phase === 'intro-right-ready') { + introTimerRef.current = window.setTimeout(() => { + introTimerRef.current = null; + setPhase('gift-entering'); + }, BABY_OBJECT_MATCH_INTRO_READY_PAUSE_MS); + return clearIntroTimer; + } + if (phase === 'gift-entering') { introTimerRef.current = window.setTimeout(() => { introTimerRef.current = null; @@ -359,7 +460,7 @@ export function BabyObjectMatchRuntimeShell({ setRound(buildRuntimeRound(draft, randomRef.current)); setFeedbackText(null); setLastTargetSide(null); - setPhase('gift-entering'); + setPhase('intro-left-showing'); }, [clearFeedbackTimer, clearIntroTimer, draft, resetInputPaths]); const finishFeedback = useCallback( @@ -440,20 +541,38 @@ export function BabyObjectMatchRuntimeShell({ } handledMocapPacketKeyRef.current = packetKey; + const nextHands = resolveMocapRuntimeHands(command); + setRuntimeHands(nextHands); + if (!isJudgementOpen) { resetInputPaths(); return; } - const nextPaths = resolveMocapHandPaths(command, mocapHandPathsRef.current); - mocapHandPathsRef.current = nextPaths; - - const targetSide = resolveMocapHorizontalMoveSide(nextPaths); - if (targetSide) { + const currentHeldItem = heldItem; + if (currentHeldItem) { + const heldHandPoint = nextHands[currentHeldItem.hand]; + const targetSide = heldHandPoint + ? resolveBasketSideForPoint(heldHandPoint) + : null; + if (!targetSide) { + return; + } sendItemToBasket(targetSide); resetInputPaths(); + return; + } + + for (const hand of ['left', 'right'] as const) { + const point = nextHands[hand]; + if (!point || !isRuntimePointTouchingItem(point)) { + continue; + } + setHeldItem({ hand }); + return; } }, [ + heldItem, isComplete, isJudgementOpen, resetInputPaths, @@ -462,16 +581,24 @@ export function BabyObjectMatchRuntimeShell({ sendItemToBasket, ]); - const getPointerUnitX = ( + const getPointerUnitPoint = ( event: ReactPointerEvent, element: HTMLElement, - ) => { + ): RuntimeHandPoint => { const rect = element.getBoundingClientRect(); const width = rect.width || 1; - return Math.max(0, Math.min(1, (event.clientX - rect.left) / width)); + const height = rect.height || 1; + return clampRuntimePoint({ + x: (event.clientX - rect.left) / width, + y: (event.clientY - rect.top) / height, + }); }; const handlePointerDown = (event: ReactPointerEvent) => { + if (isRuntimeControlPointerTarget(event.target)) { + return; + } + if (!isJudgementOpen) { return; } @@ -480,13 +607,12 @@ export function BabyObjectMatchRuntimeShell({ return; } - const side: BasketSide = event.button === 2 ? 'right' : 'left'; - const pointerX = getPointerUnitX(event, event.currentTarget); - dragStateRef.current = { - side, - startX: pointerX, - lastX: pointerX, - }; + const hand: RuntimeHandRole = event.button === 2 ? 'right' : 'left'; + const point = getPointerUnitPoint(event, event.currentTarget); + setRuntimeHands((current) => ({ ...current, [hand]: point })); + if (isRuntimePointTouchingItem(point)) { + setHeldItem({ hand }); + } event.preventDefault(); if (typeof event.currentTarget.setPointerCapture === 'function') { event.currentTarget.setPointerCapture(event.pointerId); @@ -494,36 +620,44 @@ export function BabyObjectMatchRuntimeShell({ }; const handlePointerMove = (event: ReactPointerEvent) => { + if (isRuntimeControlPointerTarget(event.target)) { + return; + } + if (!isJudgementOpen) { - dragStateRef.current = null; return; } - if (!dragStateRef.current) { + if (event.buttons !== 1 && event.buttons !== 2) { return; } - dragStateRef.current = { - ...dragStateRef.current, - lastX: getPointerUnitX(event, event.currentTarget), - }; + const hand: RuntimeHandRole = event.buttons === 2 ? 'right' : 'left'; + const point = getPointerUnitPoint(event, event.currentTarget); + setRuntimeHands((current) => ({ ...current, [hand]: point })); + if (!heldItem && isRuntimePointTouchingItem(point)) { + setHeldItem({ hand }); + return; + } + + if (!heldItem || heldItem.hand !== hand) { + return; + } + + const targetSide = resolveBasketSideForPoint(point); + if (targetSide) { + sendItemToBasket(targetSide); + resetInputPaths(); + } }; const handlePointerUp = (event: ReactPointerEvent) => { - const dragState = dragStateRef.current; - dragStateRef.current = null; if ( typeof event.currentTarget.hasPointerCapture === 'function' && event.currentTarget.hasPointerCapture(event.pointerId) ) { event.currentTarget.releasePointerCapture(event.pointerId); } - - if (!dragState || !isHorizontalDrag(dragState)) { - return; - } - - sendItemToBasket(dragState.side); }; return ( @@ -556,6 +690,7 @@ export function BabyObjectMatchRuntimeShell({ -
-
- {isGenerating ? ( - - ) : ( - - )} -
-

- {PUZZLE_ONBOARDING_COPY} -

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