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 6f747f92..62d53d03 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -24,6 +24,117 @@ - 验证方式:创作 Tab 选择汪汪声浪后应看到轻配置表单;点击生成草稿进入结果页;结果页能看到玩家形象、对手形象、UI 背景和狗叫音效槽位,试玩在发布前可进入 runtime,发布成功后再进入正式 runtime。 - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 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-19 tracking outbox 改为 rotate 后异步 flush + +- 背景:普通 route tracking 写入压力上来后,不能让 HTTP 请求线程等待 SpacetimeDB 批量入库。 +- 决策:`api-server` tracking outbox 达到 `BATCH_SIZE` 时立即封存当前 active 文件并切新 active,sealed 文件交给后台 worker 异步 flush;`FLUSH_INTERVAL_MS` 只做长时间未满批的兜底封存;`MAX_BYTES` 只做磁盘保护阈值;成功后删除 sealed,失败保留重试,坏文件隔离为 `corrupt-*`。 +- 影响范围:`api-server` tracking outbox、埋点文档、压测口径和后续排障记忆。 +- 验证方式:HTTP route 请求在 SpacetimeDB 短暂不可用时仍可返回;恢复后 sealed 文件会被批量写入并清理。 +- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + +## 2026-05-19 OTLP 默认开启但日志本地输出保留 + +- 背景:生产和容器环境需要默认把 OTLP 接到本机 Collector,但压测或排障时也要能显式关闭。 +- 决策:生产与容器 `api-server` env 模板默认 `GENARRATIVE_OTEL_ENABLED=true`;生产 endpoint 用 `http://127.0.0.1:4318`,容器 endpoint 用 `http://otelcol:4318`;`OTEL_EXPORTER_OTLP_ENDPOINT` 只填 Collector HTTP base endpoint,不填 gRPC `4317` 或 Rider 端口;本地日志、Nginx 日志和 `GENARRATIVE_API_LOG` / `RUST_LOG` 仍保留。 +- 影响范围:`deploy/env/api-server.env.example`、`deploy/container/api-server.env.example`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`scripts/loadtest/README.md`。 +- 验证方式:检查 env 模板默认值与端点口径;压测若要关闭 OTLP,必须显式设置 `GENARRATIVE_OTEL_ENABLED=false`。 +- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`scripts/run-otelcol.mjs`。 + +## 2026-05-19 容器 collector 可切 Grafana Cloud + +- 背景:容器隔离压测时除了本地 debug exporter,还需要临时把 traces / metrics / logs 转发到 Grafana Cloud 做可视化验证。 +- 决策:`deploy/container/docker-compose.loadtest.yml` 里的 `otelcol` 支持通过 `GENARRATIVE_CONTAINER_OTELCOL_CONFIG=./otelcol.grafana.yaml` 切换配置;`deploy/container/otelcol.grafana.yaml` 同时保留 debug exporter,并通过 `GRAFANA_CLOUD_OTLP_ENDPOINT` 和 `GRAFANA_CLOUD_BASIC_AUTH_HEADER` 转发到 Grafana Cloud。 +- 影响范围:`deploy/container/docker-compose.loadtest.yml`、`deploy/container/otelcol.grafana.yaml`、`deploy/container/README.md`。 +- 验证方式:容器 `otelcol` 启动日志应能看到 OTLP receiver ready,debug exporter 仍可输出本地链路;Grafana Cloud 转发凭据只通过当前 shell 环境变量传入,不写入 Git。 +- 关联文档:`deploy/container/README.md`、`scripts/loadtest/README.md`。 + +## 2026-05-17 容器化方案只作为隔离压测与预发模拟路径 + +- 背景:Windows 本机直连极高 VU 压测会放大本地连接与发送缓冲行为,和线上 Linux + Nginx + systemd 拓扑不一致;需要一个更接近生产网络层的模拟方案,但不能扰动当前生产发布链路。 +- 决策:新增 `deploy/container/` 容器化方案,使用 Docker Compose 组合 Linux release `api-server`、容器 SpacetimeDB、容器 Nginx、`otelcol-contrib` debug exporter 和可选 k6。该方案只用于本机或预发压测模拟,不替换当前生产 `systemd + Nginx + Jenkins` 路径。 +- 服务器模拟参数:2026-05-18 通过 `ssh genarrative-release` 采样,目标机器为 2 vCPU / 约 2 GiB RAM / Ubuntu 24.04 / Nginx `worker_connections=768`;容器方案按待发布运行口径使用 `nofile=4096`,并在 compose 中限制 `spacetimedb cpus=1.0 mem_limit=768m`、`api-server cpus=2.0 mem_limit=1g`、`nginx cpus=0.25 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`、`k6 cpus=0.5 mem_limit=512m`;Collector 镜像默认使用 `otel/opentelemetry-collector-contrib:0.151.0`。 +- 隔离边界:容器方案使用独立 `deploy/container/api-server.env`、独立 Nginx 配置、独立 compose 命令和默认 `18080` 端口;真实 token 不进入镜像、不提交 Git;生产 systemd 单元、Jenkins 发布脚本和 `deploy/nginx/` 模板仍是正式线上来源。 +- 生产 Collector:server-provision 可安装 `otelcol-contrib.service` 和本机 debug exporter 配置,但二进制由 Jenkins 构建机先准备 `provision-tools/otelcol-contrib` 再上传到 release 部署 agent,目标机不从 GitHub 下载;api-server 是否发送 OTLP 仍由 `GENARRATIVE_OTEL_ENABLED` 控制。 +- 影响范围:`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-18 生产 provision 改为构建机准备工具包再上传安装 + +- 背景:目标 release 服务器无法访问 GitHub,之前的 server provision 默认仍假设 `spacetime` 和 `otelcol-contrib` 已经存在于目标机本地路径,和真实运维条件不符。 +- 决策:Jenkins 新增 `Prepare Provision Tools` 阶段,在 `linux && genarrative-build` 构建机执行 `scripts/prepare-server-provision-tools.sh`,通过官方 SpacetimeDB 安装入口和 OpenTelemetry release 包生成 `provision-tools/`,再用 `stash/unstash` 带到 release 部署 agent;`scripts/jenkins-server-provision.sh` 只从工作区工具包复制安装,不再要求目标机自己下载或预装二进制。 +- 影响范围:`jenkins/Jenkinsfile.production-server-provision`、`scripts/prepare-server-provision-tools.sh`、`scripts/jenkins-server-provision.sh`、生产运维文档。 +- 验证方式:Jenkins 构建机可完成工具包准备,release 部署 agent 只消费工作区文件;目标机不再依赖 GitHub 外网下载。 +- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + +## 2026-05-19 otelcol-contrib 改为 Jenkins 手动上传归档再解包 + +- 背景:Genarrative-Server-Provision 中 `otelcol-contrib` 的构建机下载步骤耗时较长,且本机已经提前准备好安装包。 +- 决策:`jenkins/Jenkinsfile.production-server-provision` 新增 `OTELCOL_CONTRIB_ARCHIVE` 手动上传参数,默认要求上传 `otelcol-contrib_0.151.0_linux_amd64.tar.gz`;`scripts/prepare-server-provision-tools.sh` 优先从上传归档解包生成 `provision-tools/otelcol-contrib`,不再默认联网下载 OpenTelemetry release 包。 +- 影响范围:`jenkins/Jenkinsfile.production-server-provision`、`scripts/prepare-server-provision-tools.sh`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +- 验证方式:Jenkins 日志应显示使用手动上传的 otelcol 包,`MANIFEST.txt` 记录 source 为 manual archive;当 `ENABLE_OTELCOL=false` 时可以跳过 collector 工具包准备。 +- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + +## 2026-05-19 公开 gallery 入口发布限流以快拒绝保护后端 + +- 背景:容器 2C / 2G 压测中,公开作品列表在约 5000 HTTP req/s 目标下可以保持 200 请求低延迟,但 SpacetimeDB 内存会随 api-server 重连和高压请求累积到容器上限附近。 +- 决策:发布配置采用公开 gallery list 专用入口限流:Nginx `genarrative_gallery_rps rate=5000r/s`、`burst=4096`、gallery list `limit_conn=320`;api-server 对应 `GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320`,公开详情维持更低的 `GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64`。超过容量时接受明确 `429`,不继续扩大入口并发。 +- 影响范围:`deploy/nginx/` 发布模板、`deploy/env/api-server.env.example`、`deploy/container/` 隔离压测模板和生产运维文档。 +- 验证方式:容器连续 10 轮不重启 SpacetimeDB 压测,`PEAK_RPS=2500` 等价约 5000 HTTP req/s,平均实际吞吐约 `4219 HTTP req/s`,总计 `0` 个 5xx,200 请求平均 `p95=123ms`、`p99=234ms`;同时观察 SpacetimeDB 内存高水位,后续优化先处理连接 / 订阅 / tracking 下游状态。 +- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`deploy/container/README.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`。 + +- 背景:压测与运行观测需要把 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 重绘 / 上传主图后不重绘”四条路径,抓大鹅封面和后续创作页也会复用同一套交互;继续在页面内复制会导致参考图、预览、删除确认和重绘开关漂移。 @@ -141,7 +252,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`。 @@ -516,6 +628,14 @@ - 验证方式:生产发布、服务器配置、Jenkins Job 重建或回滚时,先看 `PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。 - 关联文档:`PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。 +## 2026-05-19 release server provision 需预装 Nginx Brotli 动态模块 + +- 背景:release 服务器的 Nginx 站点配置已经预留 Brotli 指令占位,但当前 provision 流程只装了基础构建依赖,没有把 Ubuntu apt 下的 brotli 动态模块一起装上,导致 release 机器即使模板支持也可能无法启用 Brotli。 +- 决策:`scripts/jenkins-server-provision.sh` 在 apt 系统上额外安装 `libnginx-mod-http-brotli-filter` 与 `libnginx-mod-http-brotli-static`,然后继续用会先 `include /etc/nginx/modules-enabled/*.conf` 的临时 `nginx -t` 配置做能力探测;非 apt 系统仍只做探测不强制安装。不要用 `nginx -V` 判断该动态模块是否可用。 +- 影响范围:`jenkins/Jenkinsfile.production-server-provision`、`scripts/jenkins-server-provision.sh`、`deploy/nginx/README.md`、release 服务器 Nginx 初始化。 +- 验证方式:server provision 跑过后,目标机应同时具备 Brotli 模块包与 `nginx -t` 可接受的 brotli 指令;再由 Nginx 模板启用对应指令。 +- 关联文档:`deploy/nginx/README.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + ## 个人任务与埋点首版边界冻结 - 背景:“我的”Tab、任务、奖励、钱包和埋点涉及用户、运营、分析多条链路,需要避免范围泛化。 @@ -523,3 +643,11 @@ - 影响范围:用户侧任务中心、后台任务配置、运营查询、埋点查询、钱包流水。 - 验证方式:非 `user` scope 的个人任务配置应被 API 和领域构造层拒绝;任务查询与埋点查询分别放在 `docs/operations/` 和 `docs/tracking/`。 - 关联文档:`PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md`、`RUNTIME_PROFILE_TASK_SCOPE_2026-05-04.md`、`ANALYTICS_DATE_DIMENSION_IMPLEMENTATION_2026-05-04.md`。 + +## 普通 route tracking 先写本机 outbox 再批量入库 + +- 背景:公开作品列表压测中,成功响应后的全局 route tracking 会逐条调用 SpacetimeDB,导致数据库内存和事务压力先到边界。 +- 决策:普通 HTTP route tracking 先写入 `api-server` 本机 NDJSON outbox,后台按数量或时间阈值批量调用 SpacetimeDB;`daily_login`、`work_play_start`、支付、任务领奖、钱包等关键事件保持同步直写。 +- 默认阈值:每批 500 条或 1 秒 flush 一次;outbox 磁盘上限 256 MiB,超过后丢弃低价值 route 事件并记录指标 / 日志。 +- 影响范围:`api-server` tracking 中间件、SpacetimeDB tracking procedure、部署数据目录、OTLP 指标和运维排障。 +- 验证方式:数据库不可用时公开 route 请求不失败且 outbox 文件保留;恢复后批量写入成功并删除本地 sealed 文件;关键事件仍立即影响任务 / 统计。 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 13539b48..656466b7 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -22,6 +22,22 @@ - 验证:拼图入口测试仍可通过,且新组件可通过不同页面复用而不需要复制上传卡实现。 - 关联:`src/components/common/CreativeImageInputPanel.tsx`、`src/components/puzzle-agent/PuzzleAgentWorkspace.tsx`。 +## OTLP 端点只填 Collector HTTP base endpoint + +- 现象:生产或容器 env 里把 `OTEL_EXPORTER_OTLP_ENDPOINT` 填成 `4317`、Rider 端口或别的非 HTTP base endpoint 后,api-server 发不出 OTLP,或者链路被错误转发。 +- 原因:api-server 当前走 OTLP HTTP,不是 gRPC;Collector 才是接收和转发边界。 +- 处理:生产模板用 `http://127.0.0.1:4318`,容器模板用 `http://otelcol:4318`;需要关闭时显式设 `GENARRATIVE_OTEL_ENABLED=false`,不要通过改 endpoint 绕开 Collector 语义。 +- 验证:检查 env 模板和运行态配置都指向 Collector HTTP base endpoint,日志仍通过 `journalctl` / 文件日志保留。 +- 关联:`deploy/env/api-server.env.example`、`deploy/container/api-server.env.example`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + +## tracking outbox 到批量阈值后先封存再异步 flush + +- 现象:route tracking 高峰时如果主请求线程要等 SpacetimeDB 批量入库,接口延迟会被 outbox 写入链路拖长。 +- 原因:outbox 的职责是把普通 HTTP route tracking 从请求线程切走,不能把 flush 结果回写成同步阻塞。 +- 处理:达到 `BATCH_SIZE` 立即封存 active 文件并切新 active,`FLUSH_INTERVAL_MS` 只做兜底封存,后台 worker 异步 flush sealed 文件;成功删文件,失败保留重试,坏文件隔离为 `corrupt-*`,`MAX_BYTES` 只做磁盘保护。 +- 验证:普通 route 请求在 SpacetimeDB 不可用时仍能返回,恢复后 sealed 文件会继续被清理。 +- 关联:`server-rs/crates/api-server/src/tracking_outbox.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + ## 汪汪声浪入口不要再回到独立配置阶段 - 现象:汪汪声浪入口如果继续切换到独立配置阶段,会和拼图、抓大鹅的创作页内嵌结构不一致,用户会感觉入口跳页。 @@ -44,7 +60,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 +99,62 @@ - 验证:运行仓库已有编码检查;人工抽查修改文件中的中文内容。 - 关联:`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`。 + +## 容器高 VU 下 `/healthz` RSS 尖峰先查 Axum state 深拷贝 + +- 现象:容器 Linux release `api-server` 打 `/healthz`,500 HTTP req/s、`PREALLOCATED_VUS=100` 只跑 1 秒也能把 RSS 推到约 1 GiB;同样问题与作品列表、SpacetimeDB procedure、业务 cache 和请求日志等级无关。 +- 原因:`AppState` 曾直接 `#[derive(Clone)]` 大结构体,里面包含配置、SpacetimeDB client、平台服务、认证服务和多组 cache。Axum/Hyper 会在 router/service/connection 路径频繁 clone state,高并发 keepalive 下会放大为状态深拷贝高水位。 +- 处理:`server-rs/crates/api-server/src/state.rs` 的 `AppState` 必须保持 `Arc` 浅拷贝壳;新增共享状态字段时放入 `AppStateInner`,不要把外层改回大结构体 clone。 +- 验证:用容器内 k6 直连 `api-server:8082/healthz`,500 HTTP req/s、`PREALLOCATED_VUS=100`、30 秒压测后采样 `/proc/$pid/status`、`/proc/$pid/smaps_rollup` 和 cgroup `memory.current/memory.peak`。2026-05-18 修复后结果为 `15001` 请求、`http_req_failed=0`、`dropped_iterations=0`,RSS 约 18 MiB -> 52 MiB,cgroup peak 约 47 MiB。 +- 关联:`server-rs/crates/api-server/src/state.rs`、`deploy/container/README.md`、`deploy/container/api-server.Dockerfile`。 + +## Gallery 压测延迟升高先查入口过量放行和 TTL 边界刷新 + +- 现象:公开作品列表在 500-1000 HTTP req/s 附近可能吞吐没有明显提升,但 p95 变高、VU 上升,甚至出现排队和 dropped iterations。 +- 原因:Nginx、Axum 和缓存刷新边界如果同时允许过多请求进入,压力会先堆在连接、service 和 cache rebuild 周围;这类延迟不等同于数据库连接池不足。 +- 处理:Nginx 按 endpoint 使用 `limit_req` 快拒绝,api-server 按 `default/gallery/detail/admin` 分组 semaphore 快拒绝;拼图广场 TTL 过期时已有缓存先返回 stale 响应,只允许一个后台 refresh 任务重建,冷启动无缓存时才同步构建。 +- 验证:OTLP 看 `genarrative.http.server.request_permits.available{pool=...}`、`genarrative.puzzle_gallery.cache.stale_hits`、`refreshes_started`、`refreshes_failed`,Nginx access log 看 `request_time` 与 `upstream_response_time` 是否同步收敛;超过容量时应明确 429,而不是长时间排队或新增 502。 +- 关联:`deploy/nginx/genarrative.conf`、`deploy/container/nginx.conf`、`server-rs/crates/api-server/src/backpressure.rs`、`server-rs/crates/api-server/src/puzzle_gallery_cache.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` / `当前分组上游负载已饱和`;同一批次里后续图片会被前面的慢请求拖住。 @@ -390,6 +462,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 - 现象:点击上传凭证会打开文件选择框,但选择图片后页面没有展示预览,提交时也没有携带图片凭证。 @@ -410,8 +490,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 @@ -440,28 +520,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`。 @@ -656,6 +736,14 @@ - 验证:扫描 `jenkins/Jenkinsfile.production-database-export` 与 `jenkins/Jenkinsfile.production-database-import`,确认 `INCLUDE_TABLES`、`CHUNK_SIZE`、`SERVER_BACKUP_DIRECTORY`、`SMOKE_HEALTH_URL` 等可选参数不再裸读。 - 关联:`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`、`jenkins/Jenkinsfile.production-database-export`、`jenkins/Jenkinsfile.production-database-import`。 +## Jenkins 二次 checkout 后脚本执行位会被 Git 还原 + +- 现象:`Genarrative-Server-Provision` 已在 shell 块前面对脚本执行 `chmod +x`,但进入 `Prepare Provision Tools` 后仍报 `scripts/prepare-server-provision-tools.sh: Permission denied` / `exit code 126`。 +- 原因:该阶段会先运行 `scripts/jenkins-checkout-source.sh`,脚本内部执行 `git reset --hard HEAD` 和 `git clean -fd`,会把前面临时 `chmod` 的执行位还原为 Git 记录的 mode;若被直接执行的脚本在仓库里是 `100644`,二次 checkout 后仍不可执行。 +- 处理:需要直接以 `scripts/*.sh` 方式执行的 Jenkins 脚本应提交为 Git `100755`;如果只想临时授权,必须放在 `scripts/jenkins-checkout-source.sh` 完成之后。 +- 验证:运行 `git ls-files --stage scripts/prepare-server-provision-tools.sh`,确认 mode 为 `100755`;重新跑 `Genarrative-Server-Provision` 时应进入工具下载/打包日志,而不是停在 `Permission denied`。 +- 关联:`jenkins/Jenkinsfile.production-server-provision`、`scripts/prepare-server-provision-tools.sh`、`scripts/jenkins-checkout-source.sh`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + ## 个人任务 scope 不得扩成 work/site/module - 现象:个人任务配置为 `work` / `site` / `module` 后进度串桶或静默按 0 处理。 @@ -776,6 +864,22 @@ - 验证:执行 `cargo test -p api-server jsapi_order_request_sets_wechat_required_http_headers --manifest-path server-rs/Cargo.toml`。 - 关联:`server-rs/crates/api-server/src/wechat_pay.rs`、`docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md`。 +## 容器公开列表压测不要靠继续抬并发吃满 CPU + +- 现象:2C / 2G 容器压测公开 gallery list 时,`api-server` CPU 仍有余量,看起来像可以继续提高 `GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS` 或 Nginx `limit_conn`。 +- 原因:当前瓶颈不是 Tokio worker 线程数。`/api/runtime/puzzle/gallery` 和 `/api/runtime/custom-world-gallery` 成功响应后会走全局 route tracking,继续向 SpacetimeDB 写 `record_tracking_event_and_return`;入口并发从 320 抬到 336 / 352 时,SpacetimeDB 内存先逼近 `896m` 容器上限,200 请求 p95 变差,429 比例没有改善。 +- 处理:2C / 2G 容器模拟里公开 gallery list 暂以 `limit_conn=320`、`GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320` 作为稳定上限。若要继续提升吞吐,优先减少高频公开 GET 的 tracking 写入、做采样或改成批量/异步聚合;不要单纯放大入口并发。 +- 验证:宿主机 k6 打 `http://127.0.0.1:18080`,`PEAK_RPS=1000` 等价约 2000 HTTP req/s;320 档无 dropped iterations、无 5xx、无 OOM,200 请求 `request_time p95` 约 0.292s。336 / 352 档 p95 升到约 0.31s / 0.32s,SpacetimeDB 内存尾部可到约 `880MiB / 896MiB`。 +- 关联:`deploy/container/nginx.conf`、`deploy/container/api-server.env.example`、`deploy/container/README.md`、`server-rs/crates/api-server/src/tracking.rs`。 + +## tracking outbox 成功入库后删除 sealed 文件 + +- 现象:普通 route tracking 改为本机 outbox 后,容易误以为入库成功只需要清空文件内容。 +- 原因:清空文件会扩大崩溃窗口,进程在 truncate 和确认之间异常退出时可能丢失未确认事件。 +- 处理:当前 active NDJSON 达到数量或时间阈值后原子 rename 为 sealed 文件;后台批量 flush sealed 文件,SpacetimeDB 返回成功后直接删除该文件,失败则保留文件等待重试。sealed 文件如果出现无法解析的坏行,重命名为 `corrupt-*` 隔离并记录指标,避免阻塞后续批量入库。该路径是至少一次投递,重复事件由 `tracking_event.event_id` 幂等跳过。 +- 验证:模拟 SpacetimeDB 不可用时 sealed 文件保留;恢复后批量 procedure 成功,sealed 文件消失,`tracking_event` 与 `tracking_daily_stat` 均更新。 +- 关联:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`server-rs/crates/api-server/src/tracking.rs`、`server-rs/crates/spacetime-module/src/runtime/profile.rs`。 + ## 后台表查询展示 SpacetimeDB 枚举时不要套用 Option 解码 - 现象:后台“表查询”查看 `profile_recharge_order` 时,`kind` 和 `status` 显示为空数组 `[]`,例如充值订单原始行里 `points_60` 的类型和状态都不可读。 @@ -798,7 +902,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素材` 当编号剥掉 @@ -888,6 +992,22 @@ - 验证:`npm run test -- src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`,并执行 `npm run check:encoding`。 - 关联:`src/services/puzzle-works/puzzleHistoryAsset.ts`、`src/components/puzzle-agent/PuzzleHistoryAssetPickerDialog.tsx`、`docs/technical/ASSET_HISTORY_PUZZLE_COVER_KIND_FIX_2026-04-27.md`。 +## 拼图历史图关闭 AI 重绘不要强制 Data URL + +- 现象:拼图创作页从历史生成图片中选择主图,再关闭 AI 重绘生成草稿时,后端报“上传图必须是图片 Data URL”。 +- 原因:历史图 `imageSrc` 是 `/generated-puzzle-assets/...` 私有兼容路径;AI 重绘开启时后端参考图分支会解析该路径,但关闭 AI 重绘的“直用上传图”分支旧实现只调用 `parse_puzzle_image_data_url`。 +- 处理:关闭 AI 重绘时也复用拼图参考图解析入口,允许 Data URL 与 `/generated-*` 历史路径统一转成 `PuzzleDownloadedImage` 后持久化;前端不需要下载历史图再转 base64。 +- 验证:`npm run test -- src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`、`cargo test -p api-server puzzle_uploaded_cover_can_reuse_resolved_history_image --manifest-path server-rs\Cargo.toml`、`npm run dev:api-server` 后检查 `/healthz`。 +- 关联:`server-rs/crates/api-server/src/puzzle/draft.rs`、`server-rs/crates/api-server/src/puzzle/vector_engine.rs`、`src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx`。 + +## 拼图结果页局部生图不要污染草稿生成态 + +- 现象:拼图草稿已经生成完成后,在结果页重新生成 UI 背景或追加关卡生成图片,草稿页仍显示整卡“生成中”,点击草稿会回到生成过程页,无法查看已有结果;UI 背景生成中还会禁用“新增关卡”和关卡图生成。 +- 原因:结果页局部 action 复用了全局 `isPuzzleBusy` / 持久化 `generationStatus=generating` 语义,作品架没有区分“初始草稿不可查看”和“已有结果上的局部关卡生成”。 +- 处理:作品架只在拼图没有可用封面、首关候选图或任一可查看关卡时才把 `generationStatus=generating` 解释为初始草稿生成;结果页 UI 背景和关卡图走 background action,不设置全局 busy,UI 背景只禁用自己的按钮;SpacetimeDB/API mapper 读写时把已有图片但状态仍是 `generating` 的历史关卡归一为 `ready`。 +- 验证:`npm run test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`、`cargo test -p api-server puzzle --manifest-path server-rs\Cargo.toml`。 +- 关联:`src/components/custom-world-home/creationWorkShelf.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/puzzle-result/PuzzleResultView.tsx`、`server-rs/crates/api-server/src/puzzle/mappers.rs`、`server-rs/crates/spacetime-module/src/puzzle.rs`。 + ## Jenkins 数据库导入导出脚本先补 Node 工具链 PATH - 现象:`Genarrative-Database-Import` 或 `Genarrative-Database-Export` 运行到迁移脚本时,`bash` 报 `node: command not found`,常见在日志里表现为某个 `sh` 块内第 61 行直接调用 `node` 失败。 @@ -895,3 +1015,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..b9338457 --- /dev/null +++ b/deploy/container/README.md @@ -0,0 +1,183 @@ +# Genarrative 容器化压测与隔离部署方案 + +本目录只服务本机或预发的容器化模拟压测,不替换当前生产 `systemd + Nginx + Jenkins` 发布路径。生产服务器仍以 `deploy/systemd/`、`deploy/nginx/`、`scripts/jenkins-*.sh` 和 `scripts/deploy/production-api-deploy.sh` 为准。 + +## 拓扑 + +```text +Docker Compose +├─ spacetimedb :3101,独立数据卷,供 api-server 连接 +├─ nginx :80 -> api-server:8082,负责静态站点、/admin/、/api/ 反代、upstream timing log、连接限制 +├─ api-server :8082,Linux release 构建,连接 compose 内 SpacetimeDB +├─ otelcol :4317/4318,debug exporter,接收 traces / metrics / logs +└─ k6 profile=loadtest 时临时启动,在 compose 网络内压 nginx +``` + +当前容器模拟参数按 `genarrative-release` 服务器采样值收口为 2 vCPU / 2 GiB RAM / 4096 soft nofile / 768 worker_connections,并已在 compose 里落实到 `spacetimedb cpus=1.0 mem_limit=896m`、`api-server cpus=2.0 mem_limit=1g`、`nginx cpus=0.5 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`、`k6 cpus=1.0 mem_limit=512m`。SpacetimeDB 同时设置 `--page_pool_max_size=402653184`,给 reducer、订阅与运行时保留更多非 page pool 内存。 +容器 `api-server` 默认 `GENARRATIVE_API_WORKER_THREADS=4`,用于让 Tokio 在 2 vCPU 配额内有更多 I/O 调度 worker;该值不会突破 compose 里的 `cpus=2.0` CPU 上限。 +Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。 +生产服务器若启用 Collector,则由 `deploy/systemd/otelcol-contrib.service` 和 `deploy/otelcol/genarrative-debug.yaml` 托管,不走容器镜像。 + +默认 host 端口: + +- `http://127.0.0.1:13101`:容器 SpacetimeDB。 +- `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_SPACETIME_PORT="13102" +$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 下默认通过 `http://spacetimedb:3101` 连接 compose 内 SpacetimeDB;宿主机只负责用 CLI 发布模块: + +```env +GENARRATIVE_SPACETIME_SERVER_URL=http://spacetimedb:3101 +GENARRATIVE_SPACETIME_DATABASE=genarrative-loadtest +GENARRATIVE_SPACETIME_TOKEN= +``` + +宿主机发布模块时,先用 CLI 向 `http://127.0.0.1:13101` 发布到 `genarrative-loadtest`,再启动 `npm run container:up`。 + +Linux Docker Engine 若要从宿主机 CLI 连到容器内服务,直接用 `http://127.0.0.1:13101`;容器内部服务之间统一走 `http://spacetimedb:3101`。 + +## 构建工具链 + +`api-server` 容器镜像只构建 Linux release API 二进制,不构建 `spacetime-module`。当前 `api-server -> spacetime-client -> spacetimedb-sdk 2.2.0` 依赖链要求 Rust 1.93,因此 `deploy/container/api-server.Dockerfile` 的 Rust builder 固定为 `rust:1.93-bookworm`。如果本机 Docker Hub 拉取失败,可以先在本机准备同名本地 builder 镜像,但不要把临时 bootstrap 容器或私有 registry 凭据写入仓库。 + +## 启动与验证 + +```bash +npm run container:config +npm run container:build +npm run container:up -- spacetimedb +spacetime publish genarrative-loadtest --server http://127.0.0.1:13101 --module-path server-rs/crates/spacetime-module --yes --build-options="--debug" +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 +``` + +容器内 `api-server` 资源上限与 Nginx 连接模型已经按 `genarrative-release` 的 2C / 2G / `nofile=4096` / `worker_connections=768` 收口;如果你要改成别的机器,就先重新采样再改这里。 + +SpacetimeDB 容器默认只提供运行时,不自动发布模块。首次启动或清理 `spacetime-data` 卷后,先只启动 `spacetimedb` 服务,再发布模块: + +```bash +npm run container:up -- spacetimedb +spacetime publish genarrative-loadtest --server http://127.0.0.1:13101 --module-path server-rs/crates/spacetime-module --yes --build-options="--debug" +``` + +发布完成后再执行 `npm run container:up` 和 `npm run container:k6`。如果 `deploy/container/api-server.env` 里的 `GENARRATIVE_SPACETIME_DATABASE` 改成了别的库名,发布命令里的库名也要同步修改。 + +如果要压 1000 HTTP req/s,把 `PEAK_RPS` 调到 `500`;如果要压 5000 HTTP req/s,把 `PEAK_RPS` 调到 `2500`,并同时提高 `PREALLOCATED_VUS` / `MAX_VUS`,观察是否先被带宽、Nginx `limit_conn` / `limit_req` 或 api-server 分组背压限制。当前容器 Nginx 对公开 gallery list 使用 `genarrative_gallery_rps`,公开详情和普通 API 使用 `genarrative_api_rps`,后台 API 使用 `genarrative_admin_rps`;api-server 侧对应 `GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS`、`GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS` 和 `GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS`。 + +2026-05-19 的 2C / 2G 容器压测结论:公开 gallery list 的 `limit_conn=320`、`limit_req rate=5000r/s burst=4096` 与 `GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320` 是当前发布口径。用宿主机 k6 打 `http://127.0.0.1:18080`,`PEAK_RPS=2500` 等价于约 5000 HTTP req/s 的两接口组合压测;连续 10 轮不重启 SpacetimeDB 的平均实际吞吐约 `4219 HTTP req/s`,总计 `1,897,357` 个 200、`212,542` 个 429、`0` 个 5xx,200 请求平均 `p95=123ms`、`p99=234ms`。该档会让 SpacetimeDB 内存从约 `366MiB` 累积到约 `885MiB / 896MiB`,下游内存先到危险区。当前不要为了降低“剩余 CPU”继续抬公开列表并发;下一步应减少成功列表请求后的 SpacetimeDB tracking 写入或优化下游连接 / 订阅状态,而不是放大入口并发。 + +### 内存采样 + +排查 API 容器内存时,优先对比压测前后的 `/proc/$pid/smaps_rollup` 和 cgroup 当前/峰值,不把 Windows 任务管理器总占用当成单进程结论: + +```bash +docker exec genarrative-container-loadtest-api-server-1 sh -c 'pid=$(pidof api-server); grep VmRSS /proc/$pid/status; grep RssAnon /proc/$pid/status; cat /proc/$pid/smaps_rollup | grep Anonymous; echo cgroup_current=$(cat /sys/fs/cgroup/memory.current); echo cgroup_peak=$(cat /sys/fs/cgroup/memory.peak)' +``` + +`/healthz` 也能复现的内存尖峰应先按连接层、service clone 或 allocator 高水位排查,不要直接归因到 SpacetimeDB procedure、作品列表 cache 或业务 DTO。2026-05-18 验证:`AppState` 改为 `Arc` 浅拷贝后,容器内直连 `api-server:8082/healthz` 的 500 HTTP req/s、`PREALLOCATED_VUS=100`、30 秒压测完成 `15001` 次请求,`http_req_failed=0`、`dropped_iterations=0`,API 进程 RSS 从约 18 MiB 升至约 52 MiB,cgroup 峰值约 47 MiB,未再出现 1 GiB 级尖峰。 + +## 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 模板。 + +容器内需要临时转发到 Grafana Cloud 时,切换 Collector 配置并从当前 shell 传入 Grafana Cloud 凭据;真实 token 不写入仓库文件: + +```powershell +$env:GENARRATIVE_CONTAINER_OTELCOL_CONFIG="./otelcol.grafana.yaml" +$env:GRAFANA_CLOUD_OTLP_ENDPOINT="https://..." +$env:GRAFANA_CLOUD_BASIC_AUTH_HEADER="Basic ..." +npm run container:up +npm run container:logs -- otelcol +``` + +`deploy/container/otelcol.grafana.yaml` 会同时保留本地 debug exporter,并通过 `otlphttp/grafana` 把 traces / metrics / logs 发到 Grafana Cloud。 + +## 隔离边界 + +- 不改生产 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..1a0c1eaa --- /dev/null +++ b/deploy/container/api-server.Dockerfile @@ -0,0 +1,51 @@ +FROM rust:1.93-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 /var/lib/genarrative/tracking-outbox && \ + 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 \ + GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox + +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 scripts/vite-cli.mjs scripts/admin-web-build.mjs ./scripts/ +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..a3e0dd33 --- /dev/null +++ b/deploy/container/api-server.env.example @@ -0,0 +1,42 @@ +# 复制为 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_API_GALLERY_MAX_CONCURRENT_REQUESTS=320 +GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64 +GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS=16 +GENARRATIVE_TRACKING_OUTBOX_ENABLED=true +GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox +GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE=500 +GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS=1000 +GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES=268435456 + +GENARRATIVE_OTEL_ENABLED=true +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 + +# 默认连接 compose 内部 SpacetimeDB;宿主机发布模块使用 127.0.0.1:13101。 +GENARRATIVE_SPACETIME_SERVER_URL=http://spacetimedb: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..afac4962 --- /dev/null +++ b/deploy/container/docker-compose.loadtest.yml @@ -0,0 +1,147 @@ +name: genarrative-container-loadtest + +services: + spacetimedb: + image: clockworklabs/spacetime:v2.2.0 + user: root + command: + [ + "start", + "--listen-addr", + "0.0.0.0:3101", + "--data-dir", + "/var/lib/spacetimedb", + "--page_pool_max_size", + "402653184", + "--non-interactive", + ] + cpus: "1.0" + mem_limit: 896m + ports: + - "${GENARRATIVE_CONTAINER_SPACETIME_PORT:-13101}:3101" + volumes: + - spacetime-data:/var/lib/spacetimedb + ulimits: + nofile: + soft: 4096 + hard: 4096 + healthcheck: + test: + [ + "CMD-SHELL", + "spacetime server ping http://127.0.0.1:3101 >/dev/null 2>&1", + ] + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + + api-server: + build: + context: ../.. + dockerfile: deploy/container/api-server.Dockerfile + target: api-runtime + cpus: "2.0" + mem_limit: 1g + 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 + - api-tracking-outbox:/var/lib/genarrative/tracking-outbox + ulimits: + nofile: + soft: 4096 + hard: 4096 + depends_on: + spacetimedb: + condition: service_healthy + 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 + cpus: "0.5" + mem_limit: 128m + depends_on: + api-server: + condition: service_healthy + spacetimedb: + condition: service_healthy + ports: + - "${GENARRATIVE_CONTAINER_HTTP_PORT:-18080}:80" + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - nginx-logs:/var/log/nginx + ulimits: + nofile: + soft: 4096 + hard: 4096 + 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.151.0 + command: ["--config=/etc/otelcol/config.yaml"] + cpus: "0.25" + mem_limit: 128m + environment: + GRAFANA_CLOUD_OTLP_ENDPOINT: ${GRAFANA_CLOUD_OTLP_ENDPOINT:-} + GRAFANA_CLOUD_BASIC_AUTH_HEADER: ${GRAFANA_CLOUD_BASIC_AUTH_HEADER:-} + HOSTNAME: ${HOSTNAME:-genarrative-container-loadtest} + volumes: + - ${GENARRATIVE_CONTAINER_OTELCOL_CONFIG:-./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"] + cpus: "1.0" + mem_limit: 512m + 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: + spacetime-data: + api-auth-store: + api-tracking-outbox: + nginx-logs: diff --git a/deploy/container/nginx.conf b/deploy/container/nginx.conf new file mode 100644 index 00000000..2799af16 --- /dev/null +++ b/deploy/container/nginx.conf @@ -0,0 +1,220 @@ +worker_processes auto; + +events { + worker_connections 768; +} + +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; + limit_req_zone $binary_remote_addr zone=genarrative_gallery_rps:10m rate=5000r/s; + limit_req_zone $binary_remote_addr zone=genarrative_api_rps:10m rate=300r/s; + limit_req_zone $binary_remote_addr zone=genarrative_admin_rps:10m rate=30r/s; + + 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; + limit_req_status 429; + limit_req_log_level warn; + + root /srv/genarrative/web; + index index.html; + + location ^~ /admin/api/ { + default_type application/json; + limit_conn genarrative_api_conn 64; + limit_req zone=genarrative_admin_rps burst=16 nodelay; + + 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/runtime/puzzle/gallery { + default_type application/json; + limit_conn genarrative_api_conn 320; + limit_req zone=genarrative_gallery_rps burst=4096 nodelay; + + 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 = /api/runtime/custom-world-gallery { + default_type application/json; + limit_conn genarrative_api_conn 320; + limit_req zone=genarrative_gallery_rps burst=4096 nodelay; + + 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 ~ ^/api/runtime/puzzle/gallery/[^/]+$ { + default_type application/json; + limit_conn genarrative_api_conn 32; + limit_req zone=genarrative_api_rps burst=32 nodelay; + + 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 ~ ^/api/runtime/custom-world-gallery/[^/]+/[^/]+$ { + default_type application/json; + limit_conn genarrative_api_conn 32; + limit_req zone=genarrative_api_rps burst=32 nodelay; + + 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 ~ ^/api(?:/|$) { + default_type application/json; + limit_conn genarrative_api_conn 64; + limit_req zone=genarrative_api_rps burst=64 nodelay; + + 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://spacetimedb: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://spacetimedb: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.grafana.yaml b/deploy/container/otelcol.grafana.yaml new file mode 100644 index 00000000..ae0af6f4 --- /dev/null +++ b/deploy/container/otelcol.grafana.yaml @@ -0,0 +1,36 @@ +receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + +processors: + batch: + timeout: 5s + send_batch_size: 512 + send_batch_max_size: 1024 + +exporters: + debug: + verbosity: basic + otlp_http/grafana: + endpoint: ${env:GRAFANA_CLOUD_OTLP_ENDPOINT} + headers: + Authorization: ${env:GRAFANA_CLOUD_BASIC_AUTH_HEADER} + +service: + pipelines: + traces: + receivers: [otlp] + processors: [batch] + exporters: [debug, otlp_http/grafana] + metrics: + receivers: [otlp] + processors: [batch] + exporters: [debug, otlp_http/grafana] + logs: + receivers: [otlp] + processors: [batch] + exporters: [debug, otlp_http/grafana] 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..c7a85bee 100644 --- a/deploy/env/api-server.env.example +++ b/deploy/env/api-server.env.example @@ -5,6 +5,21 @@ 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_API_GALLERY_MAX_CONCURRENT_REQUESTS=320 +GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64 +GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS=16 +GENARRATIVE_TRACKING_OUTBOX_ENABLED=true +GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox +GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE=500 +GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS=1000 +GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES=268435456 +GENARRATIVE_OTEL_ENABLED=true +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 +94,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/README.md b/deploy/nginx/README.md index 817a5a85..2dfa2110 100644 --- a/deploy/nginx/README.md +++ b/deploy/nginx/README.md @@ -11,13 +11,18 @@ ## Brotli - Brotli 只在目标服务器 Nginx 接受 brotli 指令时开启。 +- Ubuntu / apt 系统的 `Genarrative-Server-Provision` 会安装 `libnginx-mod-http-brotli-filter` 与 `libnginx-mod-http-brotli-static`;非 apt 系统暂不自动安装,仍按下面的能力探测结果决定是否启用。 - Provision 脚本通过临时配置执行 `nginx -t` 做能力探测;探测配置会先 `include /etc/nginx/modules-enabled/*.conf`,避免 Ubuntu 动态模块已安装但测试配置未加载模块导致误判。可用时把模板中的 `# __GENARRATIVE_BROTLI_DIRECTIVES__` 替换为 brotli 指令,不可用时保留注释说明。 - 不要直接在静态模板里无条件写 `brotli on;`,否则没有 brotli 模块的服务器会 `nginx -t` 失败并回滚。 -- 不要用 `nginx -V | grep brotli` 判断 brotli 是否可用;Ubuntu apt 安装的 brotli 是动态模块,可能只出现在 `nginx -T` 的 `load_module` 配置里。 +- 不要用 `nginx -V | grep brotli` 判断 brotli 是否可用;Ubuntu apt 安装的 brotli 是动态模块,不会出现在普通编译参数里。应检查包、`/etc/nginx/modules-enabled/` 的 `load_module` 配置,或用包含 `include /etc/nginx/modules-enabled/*.conf` 的临时配置执行 `nginx -t`。 ## 验证 ```bash +dpkg -l 'libnginx-mod-http-brotli-*' +ls -l /etc/nginx/modules-enabled/*brotli* +nginx -T 2>/dev/null | grep -Ei 'brotli|load_module' + curl -sSI -H 'Accept-Encoding: gzip' \ http:///api/runtime/puzzle/gallery \ | grep -iE 'content-encoding|vary|content-type|content-length' diff --git a/deploy/nginx/genarrative-dev-http.conf b/deploy/nginx/genarrative-dev-http.conf index 824a8f5a..63234e30 100644 --- a/deploy/nginx/genarrative-dev-http.conf +++ b/deploy/nginx/genarrative-dev-http.conf @@ -1,9 +1,32 @@ # 开发服无域名时使用的 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; +limit_req_zone $binary_remote_addr zone=genarrative_gallery_rps:10m rate=5000r/s; +limit_req_zone $binary_remote_addr zone=genarrative_api_rps:10m rate=300r/s; +limit_req_zone $binary_remote_addr zone=genarrative_admin_rps:10m rate=30r/s; + 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; + limit_req_status 429; + limit_req_log_level warn; gzip on; gzip_vary on; @@ -29,13 +52,16 @@ server { location ^~ /admin/api/ { default_type application/json; + limit_conn genarrative_api_conn 64; + limit_req zone=genarrative_admin_rps burst=16 nodelay; 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; @@ -65,20 +91,119 @@ server { try_files $uri =404; } - # 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。 - location ~ ^/api(?:/|$) { + location = /api/runtime/puzzle/gallery { default_type application/json; + limit_conn genarrative_api_conn 320; + limit_req zone=genarrative_gallery_rps burst=4096 nodelay; 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; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Request-Id $request_id; + } + + location = /api/runtime/custom-world-gallery { + default_type application/json; + limit_conn genarrative_api_conn 320; + limit_req zone=genarrative_gallery_rps burst=4096 nodelay; + + if ($genarrative_maintenance) { + return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; + } + + 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 ~ ^/api/runtime/puzzle/gallery/[^/]+$ { + default_type application/json; + limit_conn genarrative_api_conn 32; + limit_req zone=genarrative_api_rps burst=32 nodelay; + + if ($genarrative_maintenance) { + return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; + } + + 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 ~ ^/api/runtime/custom-world-gallery/[^/]+/[^/]+$ { + default_type application/json; + limit_conn genarrative_api_conn 32; + limit_req zone=genarrative_api_rps burst=32 nodelay; + + if ($genarrative_maintenance) { + return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; + } + + 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; + } + + # 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。 + location ~ ^/api(?:/|$) { + default_type application/json; + limit_conn genarrative_api_conn 64; + limit_req zone=genarrative_api_rps burst=64 nodelay; + + if ($genarrative_maintenance) { + return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; + } + + 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..023a96f8 100644 --- a/deploy/nginx/genarrative.conf +++ b/deploy/nginx/genarrative.conf @@ -1,7 +1,30 @@ # 生产域名需要在部署前替换为真实域名,并由 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; +limit_req_zone $binary_remote_addr zone=genarrative_gallery_rps:10m rate=5000r/s; +limit_req_zone $binary_remote_addr zone=genarrative_api_rps:10m rate=300r/s; +limit_req_zone $binary_remote_addr zone=genarrative_admin_rps:10m rate=30r/s; + 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; + limit_req_status 429; + limit_req_log_level warn; location /.well-known/acme-challenge/ { root /var/www/html; @@ -15,6 +38,12 @@ 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; + limit_conn_status 429; + limit_conn_log_level warn; + limit_req_status 429; + limit_req_log_level warn; gzip on; gzip_vary on; @@ -43,13 +72,16 @@ server { location ^~ /admin/api/ { default_type application/json; + limit_conn genarrative_api_conn 64; + limit_req zone=genarrative_admin_rps burst=16 nodelay; 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; @@ -79,20 +111,119 @@ server { try_files $uri =404; } - # 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。 - location ~ ^/api(?:/|$) { + location = /api/runtime/puzzle/gallery { default_type application/json; + limit_conn genarrative_api_conn 320; + limit_req zone=genarrative_gallery_rps burst=4096 nodelay; 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; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Request-Id $request_id; + } + + location = /api/runtime/custom-world-gallery { + default_type application/json; + limit_conn genarrative_api_conn 320; + limit_req zone=genarrative_gallery_rps burst=4096 nodelay; + + if ($genarrative_maintenance) { + return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; + } + + 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 ~ ^/api/runtime/puzzle/gallery/[^/]+$ { + default_type application/json; + limit_conn genarrative_api_conn 32; + limit_req zone=genarrative_api_rps burst=32 nodelay; + + if ($genarrative_maintenance) { + return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; + } + + 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 ~ ^/api/runtime/custom-world-gallery/[^/]+/[^/]+$ { + default_type application/json; + limit_conn genarrative_api_conn 32; + limit_req zone=genarrative_api_rps burst=32 nodelay; + + if ($genarrative_maintenance) { + return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; + } + + 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; + } + + # 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。 + location ~ ^/api(?:/|$) { + default_type application/json; + limit_conn genarrative_api_conn 64; + limit_req zone=genarrative_api_rps burst=64 nodelay; + + if ($genarrative_maintenance) { + return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; + } + + 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/otelcol/genarrative-debug.yaml b/deploy/otelcol/genarrative-debug.yaml new file mode 100644 index 00000000..216a591b --- /dev/null +++ b/deploy/otelcol/genarrative-debug.yaml @@ -0,0 +1,23 @@ +receivers: + otlp: + protocols: + grpc: + endpoint: 127.0.0.1:4317 + http: + endpoint: 127.0.0.1:4318 + +exporters: + debug: + verbosity: normal + +service: + pipelines: + traces: + receivers: [otlp] + exporters: [debug] + metrics: + receivers: [otlp] + exporters: [debug] + logs: + receivers: [otlp] + exporters: [debug] 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/deploy/systemd/otelcol-contrib.service b/deploy/systemd/otelcol-contrib.service new file mode 100644 index 00000000..ad891f02 --- /dev/null +++ b/deploy/systemd/otelcol-contrib.service @@ -0,0 +1,22 @@ +[Unit] +Description=Genarrative OpenTelemetry Collector Contrib +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=otelcol +Group=otelcol +WorkingDirectory=/etc/otelcol +ExecStart=/usr/local/bin/otelcol-contrib --config=/etc/otelcol/genarrative-debug.yaml +Restart=always +RestartSec=5 +LimitNOFILE=65535 + +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ReadWritePaths=/etc/otelcol /var/log/genarrative + +[Install] +WantedBy=multi-user.target diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index f65a7b87..d6a26702 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,30 +608,39 @@ 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` - Rust 结构体:`TrackingDailyStat` - 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 写入:由单条或批量 tracking procedure 在同一事务中随 `tracking_event` 更新,作为运营查询和个人任务进度的聚合投影。 ### `tracking_event` - Rust 结构体:`TrackingEvent` - 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 写入:关键业务埋点同步调用单条 procedure;普通 HTTP route tracking 由 `api-server` 本机 outbox 批量调用 `record_tracking_events_and_return`。outbox 到达批量阈值时先封存 active 文件并切新 active,后台 worker 异步 flush sealed 文件,HTTP 请求线程不等待 SpacetimeDB。`FLUSH_INTERVAL_MS` 只负责兜底封存长时间未满批的 active 文件,`MAX_BYTES` 只做磁盘保护阈值。`event_id` 必须稳定且全局唯一,批量重试时用唯一索引做幂等跳过。 ### `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 +681,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..8bc5ec79 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,53 @@ 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,不写入文档示例。 +`Genarrative-Server-Provision` 会安装基础构建依赖、systemd 模板和 Nginx 站点模板。Ubuntu / apt 目标机会额外安装 `libnginx-mod-http-brotli-filter` 与 `libnginx-mod-http-brotli-static`,随后由 `scripts/jenkins-server-provision.sh` 通过临时 `nginx -t` 配置探测 Brotli 指令是否可用;该临时配置必须先 `include /etc/nginx/modules-enabled/*.conf`,因为 apt 安装的 Brotli 是动态模块,不会出现在普通 `nginx -V` 编译参数里。探测成功才在渲染后的 `deploy/nginx/genarrative.conf` / `genarrative-dev-http.conf` 中启用 Brotli,避免未安装模块的机器直接写入无效配置。 + +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 并发背压;`GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320`、`GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64`、`GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS=16` 分别限制公开列表、公开详情和后台 API 热路径。超过许可时直接返回 `429 Too Many Requests` 和 `Retry-After: 1`,`/healthz` 不受该限制。这些值不是 RPS 限速;如果压测中 429 上升但内存和 p95 收敛,说明背压正在保护进程。直连 `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` 核对。 +- Server provision 不在目标机下载 SpacetimeDB 或 `otelcol-contrib`。Jenkins 的 `Prepare Provision Tools` 阶段在 `linux && genarrative-build` 构建机执行 `scripts/prepare-server-provision-tools.sh`:SpacetimeDB 仍通过官方安装入口 `https://install.spacetimedb.com` 准备;`otelcol-contrib` 默认要求在 Jenkins 参数 `OTELCOL_CONTRIB_ARCHIVE` 手动上传 `otelcol-contrib_0.151.0_linux_amd64.tar.gz`,再从上传包解出 `provision-tools/otelcol-contrib`。最终工具包通过 `stash/unstash` 上传到 release 部署 agent。目标机上的 `scripts/jenkins-server-provision.sh` 只从该工作区工具包安装 `/stdb/spacetime`、`/stdb/bin/current/*` 和 `/usr/local/bin/otelcol-contrib`。注意 `scripts/jenkins-checkout-source.sh` 会执行 `git reset --hard` / `git clean`,因此被直接执行的新增脚本必须以 Git `100755` 模式提交,或在二次 checkout 之后再补 `chmod +x`。 +- `otelcol-contrib.service` 作为可选系统服务加入 provision,默认监听 `127.0.0.1:4317/4318` 并使用 `deploy/otelcol/genarrative-debug.yaml`。api-server 是否发送 OTLP 仍由 `GENARRATIVE_OTEL_ENABLED` 控制,服务 unit 见 `deploy/systemd/otelcol-contrib.service`。 +- Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代理到 `127.0.0.1:8082`,upstream keepalive 为 64;`limit_conn` 负责连接 / 并发保护,`limit_req` 负责入口 RPS 快拒绝。当前模板把公开 gallery list 单独放到 `genarrative_gallery_rps`,默认 `rate=5000r/s`、`burst=4096`、`limit_conn=320`;公开详情和普通 API 放到 `genarrative_api_rps`,后台 API 放到 `genarrative_admin_rps`。压测时看 `/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。2026-05-19 容器 2C / 2G 连续 10 轮不重启 SpacetimeDB 压测:`PEAK_RPS=2500` 等价约 5000 HTTP req/s,平均实际吞吐约 `4219 HTTP req/s`,10 轮总计 `1,897,357` 个 200、`212,542` 个 429、`0` 个 5xx,200 请求平均 `p95=123ms`、`p99=234ms`;该档会把 SpacetimeDB 容器内存从约 `366MiB` 推到约 `885MiB / 896MiB`,因此当前不要继续抬公开 gallery 入口并发,应优先处理 SpacetimeDB 侧连接 / 订阅 / tracking 写入后的内存高水位。 + +容器化压测与隔离部署方案单独放在 `deploy/container/`,用于本机或预发模拟 Linux release + Nginx + OTLP Collector 拓扑,不替换当前生产 `systemd + Nginx + Jenkins` 发布路径。当前容器模拟参数按 `genarrative-release` 采样值收口为 2 vCPU / 2 GiB RAM / `nofile=4096` / `worker_connections=768`,并在 compose 里落实到 `spacetimedb cpus=1.0 mem_limit=768m`、`api-server cpus=2.0 mem_limit=1g`、`nginx cpus=0.25 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`、`k6 cpus=0.5 mem_limit=512m`。容器 `api-server` 默认 `GENARRATIVE_API_WORKER_THREADS=4`,只增加 Tokio worker 调度并发,不突破 `api-server cpus=2.0` 的 CPU 配额: + +```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 也纳入 compose,容器内由 `spacetimedb:3101` 提供服务,宿主机通过 `http://127.0.0.1:13101` 进行模块发布;Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。生产 provision 侧则通过 Jenkins 构建机准备的 `provision-tools/otelcol-contrib` 安装本机 `otelcol-contrib.service`,真实库名、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 文件日志仍保留: + +- 生产与容器 `api-server` env 模板默认 `GENARRATIVE_OTEL_ENABLED=true`;压测、排障或短期要关闭 OTLP 时,必须显式设置 `GENARRATIVE_OTEL_ENABLED=false`。 +- Collector 使用官方 `otelcol-contrib`,安装与启用仍由 `ENABLE_OTELCOL` / provision 控制,只监听 `127.0.0.1:4317/4318`;本地用 `npm run otel:debug` 启动 debug exporter,用 `npm run otel:rider` 转发到 Rider,再接 Jaeger、Tempo、Prometheus、Grafana 或托管平台。 +- api-server 发送 OTLP HTTP 时,生产模板使用 `OTEL_SERVICE_NAME=genarrative-api`、`OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318`,容器模板使用 `OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4318`。 +- `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.cpu.time`、`genarrative.process.cpu.usage_percent`、`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`,后者带低基数 `pool=default|gallery|detail|admin` label,用于区分业务 handler / 背压 permit 是否仍被占用;拼图广场热点缓存补充发送 `genarrative.puzzle_gallery.cache.*` 指标,记录 fresh hit、stale hit、未命中、后台刷新开始 / 失败、重建耗时和预序列化 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,9 +211,33 @@ 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`。任务配置、进度、领奖、钱包流水分别写入: +用户行为埋点原始事实写入 `tracking_event`,聚合投影写入 `tracking_daily_stat`。高频 HTTP route tracking 不直接阻塞请求链路:`api-server` 将普通 route tracking 先写入本机 tracking outbox,再由后台 worker 按数量或时间阈值批量写入 SpacetimeDB;`daily_login`、作品游玩 `work_play_start`、付费、任务领奖和钱包相关关键事件继续同步直写数据库,避免用户任务进度、游玩统计或支付状态出现可感知延迟。任务配置、进度、领奖、钱包流水分别写入: - `profile_task_config` - `profile_task_progress` @@ -175,6 +246,18 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分 个人任务首版 scope 仅支持 `user`。后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 等特定链路按 tracking 中间件排除规则处理;作品游玩统一使用 `work_play_start`。 +tracking outbox 默认配置: + +```env +GENARRATIVE_TRACKING_OUTBOX_ENABLED=true +GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox +GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE=500 +GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS=1000 +GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES=268435456 +``` + +outbox 采用 NDJSON 文件保存原始事件。达到 `BATCH_SIZE` 时会立刻把当前 active 文件原子封存为 sealed 文件,并马上切到新的 active 继续写入;后台 worker 异步 flush sealed 文件,HTTP 请求线程不等待 SpacetimeDB。`FLUSH_INTERVAL_MS` 只负责兜底封存长时间未满批的 active 文件。SpacetimeDB 批量 procedure 返回成功后删除 sealed 文件,失败则保留文件并重试。`MAX_BYTES` 是磁盘保护阈值,不是 flush 阈值;超过后低价值 route tracking 可以被丢弃并记录日志 / 指标,关键同步事件不进入该丢弃路径。sealed 文件若出现无法解析的坏行,会重命名为 `corrupt-*` 隔离并记录 `genarrative.tracking_outbox.files.corrupt` 指标,避免一个坏文件阻塞后续批量入库。该机制提供至少一次投递语义,依赖 `tracking_event.event_id` 幂等跳过重复事件。 + 常用检查思路: ```sql diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 61e12cea..c14aa004 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` 换签读取。 ## 拼图 @@ -27,9 +31,17 @@ 当前口径: - 图像输入复用 `CreativeImageInputPanel`。 -- 支持画面描述生图、多参考图生图、上传主图后 AI 重绘、上传主图后不重绘。 -- 草稿生成会保留关卡图和 UI 背景;当前不自动生成背景音乐。 +- 支持画面描述生图、多参考图生图、上传或历史生成主图后 AI 重绘、上传或历史生成主图后不重绘;关闭 AI 重绘时,前端可提交本地上传 Data URL 或历史 `/generated-*` 图片路径,后端统一解析为首关正式图后再持久化。 +- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要,生成完成并回写关卡图、UI 背景后再变为 `ready`;首关关卡图和 UI 背景在命名稳定后并行启动,当前不自动生成背景音乐。 +- 作品架拼图草稿的“生成中”遮罩只表示初始草稿还没有可查看结果;只要作品摘要、首关封面或任一关卡候选图已经可用,后续 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 背景重生成只禁用 UI 背景自己的按钮和确认动作,不禁用“新增关卡”、关卡图片生成、关卡详情编辑和结果页导航;关卡图片生成也只标记对应关卡的局部生成进度。 +- 结果页生成关卡图时若关卡名为空,前端必须传 `shouldAutoNameLevel=true`,后端复用首关命名契约先按画面描述生成关卡名,再在图片生成后用视觉命名结果精修,并把生成名和 UI 背景提示词随本次关卡快照写回。 +- 拼图 UI 背景是作品运行态背景,不只属于第一关;本地试玩、直达指定关卡和正式 `next-level` 推进时,目标关卡缺 `uiBackgroundImageSrc/uiBackgroundImageObjectKey` 必须继承同作品首个可用 UI 背景,仍缺失时才沿用当前运行态快照背景或默认 UI。 - 拼图运行态棋盘不叠加分块蒙版、描边、阴影、选中底色或合并块 SVG 轮廓;拼图片本体需要裁切为圆角形状,单块使用独立圆角裁切,合并块使用 SVG 原生 `clipPath` 裁切整体外轮廓,外凸角和内凹角分别计算半径,内凹角半径要比外凸角更明显以避免手机 WebView 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。 - 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform,不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。 - 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。 @@ -51,24 +63,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 +93,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 尺寸,避免右下溢出。 @@ -172,3 +185,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-server-provision b/jenkins/Jenkinsfile.production-server-provision index ee5d13d5..98757f54 100644 --- a/jenkins/Jenkinsfile.production-server-provision +++ b/jenkins/Jenkinsfile.production-server-provision @@ -22,7 +22,8 @@ pipeline { string(name: 'COMMIT_HASH', defaultValue: '', description: '部署脚本来源 commit') string(name: 'SERVER_NAME', defaultValue: 'genarrative.example.com', description: '证书主域名;也作为 Nginx server_name 的第一个域名') string(name: 'SERVER_ALIASES', defaultValue: '', description: '可选,额外 Nginx server_name,多个用空格或逗号分隔,例如 www.genarrative.world') - string(name: 'SPACETIME_BIN_SOURCE', defaultValue: '/usr/local/bin/spacetime', description: '服务器上已有 spacetime CLI 路径') + string(name: 'PROVISION_TOOLS_DIR', defaultValue: 'provision-tools', description: '构建机准备并上传到目标机工作区的工具包目录') + string(name: 'SPACETIME_DOWNLOAD_ROOT', defaultValue: 'https://github.com/clockworklabs/SpacetimeDB/releases/latest/download', description: '构建机下载 SpacetimeDB 官方安装产物的根地址;目标机不访问该地址') string(name: 'SPACETIME_ROOT', defaultValue: '/stdb', description: 'SpacetimeDB root-dir') string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: 'release 根目录') string(name: 'CURRENT_LINK', defaultValue: '/opt/genarrative/current', description: '当前版本软链接') @@ -31,6 +32,9 @@ pipeline { string(name: 'API_PORT', defaultValue: '8082', description: 'api-server 本机监听端口') choice(name: 'NGINX_CONFIG_MODE', choices: ['none', 'production-https', 'development-http'], description: 'Nginx 配置模式;开发服无域名时选 development-http,release 正式入口选 production-https') booleanParam(name: 'ENABLE_SERVICES', defaultValue: true, description: '启用并启动 spacetimedb 与 api-server systemd 服务') + booleanParam(name: 'ENABLE_OTELCOL', defaultValue: true, description: '安装并启用本机 OpenTelemetry Collector;api-server 模板默认开启 OTLP,如需关闭请在 API_ENV_FILE 中将 GENARRATIVE_OTEL_ENABLED 改为 false') + string(name: 'OTELCOL_VERSION', defaultValue: '0.151.0', description: 'otelcol-contrib 版本') + stashedFile 'OTELCOL_CONTRIB_ARCHIVE' } stages { @@ -60,8 +64,30 @@ pipeline { } } } - if (!params.SPACETIME_BIN_SOURCE?.trim()) { - error('SPACETIME_BIN_SOURCE 不能为空。') + if (!params.PROVISION_TOOLS_DIR?.trim()) { + error('PROVISION_TOOLS_DIR 不能为空。') + } + if (!(params.PROVISION_TOOLS_DIR.trim() ==~ /^[0-9A-Za-z._\/-]+$/) || params.PROVISION_TOOLS_DIR.startsWith('/') || params.PROVISION_TOOLS_DIR.contains('..')) { + error("PROVISION_TOOLS_DIR 只能是工作区内的相对目录,不能包含绝对路径或连续点号: ${params.PROVISION_TOOLS_DIR}") + } + if (!(params.OTELCOL_VERSION?.trim() ==~ /^[0-9]+\.[0-9]+\.[0-9]+$/)) { + error("OTELCOL_VERSION 格式应为 x.y.z: ${params.OTELCOL_VERSION}") + } + def otelcolArchiveFilename = env.OTELCOL_CONTRIB_ARCHIVE_FILENAME?.trim() + def expectedOtelcolArchiveFilename = "otelcol-contrib_${params.OTELCOL_VERSION.trim()}_linux_amd64.tar.gz" + if (params.ENABLE_OTELCOL) { + if (!otelcolArchiveFilename) { + error("ENABLE_OTELCOL=true 时必须在 OTELCOL_CONTRIB_ARCHIVE 上传 ${expectedOtelcolArchiveFilename}。") + } + if (otelcolArchiveFilename != expectedOtelcolArchiveFilename) { + error("OTELCOL_CONTRIB_ARCHIVE 文件名必须是 ${expectedOtelcolArchiveFilename},当前上传: ${otelcolArchiveFilename}") + } + } + if (!params.ENABLE_OTELCOL && otelcolArchiveFilename) { + echo "ENABLE_OTELCOL=false,已上传的 OTELCOL_CONTRIB_ARCHIVE 将不会被安装。" + } + if (!params.SPACETIME_DOWNLOAD_ROOT?.trim()) { + error('SPACETIME_DOWNLOAD_ROOT 不能为空。') } def nginxMode = params.NGINX_CONFIG_MODE?.trim() if (!(nginxMode in ['none', 'production-https', 'development-http'])) { @@ -77,6 +103,80 @@ pipeline { } } + stage('Prepare Provision Tools') { + agent { + label 'linux && genarrative-build' + } + steps { + script { + def checkoutFromRemote = { String remoteUrl -> + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + doGenerateSubmoduleConfigurations: false, + extensions: [ + [$class: 'CleanBeforeCheckout'], + [$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true], + ], + userRemoteConfigs: [[url: remoteUrl, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]], + ]) + } + try { + checkoutFromRemote(env.GIT_REMOTE_URL) + env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL + } catch (error) { + echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}" + checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL) + env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL + } + } + sh ''' + bash <<'BASH' + set -euo pipefail + chmod +x scripts/jenkins-checkout-source.sh + SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \ + COMMIT_HASH="${COMMIT_HASH:-}" \ + GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \ + GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \ + SOURCE_COMMIT_FILE=".jenkins-source-commit" \ + scripts/jenkins-checkout-source.sh + # jenkins-checkout-source.sh 会 reset/clean 到目标 commit,前面的临时 chmod 可能被 Git mode 还原; + # 直接执行脚本前在二次 checkout 之后再补执行位,避免 Linux agent 报 Permission denied。 + chmod +x scripts/prepare-server-provision-tools.sh +BASH + ''' + script { + if (params.ENABLE_OTELCOL) { + echo "准备使用手动上传的 otelcol-contrib 包: ${env.OTELCOL_CONTRIB_ARCHIVE_FILENAME}" + sh 'bash -lc "rm -rf manual-provision-tool-upload && mkdir -p manual-provision-tool-upload"' + dir('manual-provision-tool-upload') { + unstash 'OTELCOL_CONTRIB_ARCHIVE' + } + env.OTELCOL_ARCHIVE_SOURCE = 'manual-provision-tool-upload/OTELCOL_CONTRIB_ARCHIVE' + } else { + env.OTELCOL_ARCHIVE_SOURCE = '' + } + } + sh ''' + bash <<'BASH' + set -euo pipefail + + PROVISION_TOOLS_DIR="${PROVISION_TOOLS_DIR:-provision-tools}" \ + OTELCOL_VERSION="${OTELCOL_VERSION:-0.151.0}" \ + PREPARE_OTELCOL="${ENABLE_OTELCOL:-true}" \ + OTELCOL_ARCHIVE_SOURCE="${OTELCOL_ARCHIVE_SOURCE:-}" \ + SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/latest/download}" \ + scripts/prepare-server-provision-tools.sh +BASH + ''' + script { + env.SOURCE_COMMIT = readFile('.jenkins-source-commit').trim() + echo "Provision 工具包已准备,源码 commit=${env.SOURCE_COMMIT}" + } + stash name: 'server-provision-tools', includes: "${params.PROVISION_TOOLS_DIR}/**", useDefaultExcludes: false + } + } + stage('Checkout Provision Files') { agent { label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}" @@ -109,7 +209,7 @@ pipeline { set -euo pipefail chmod +x scripts/jenkins-checkout-source.sh SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \ - COMMIT_HASH="${COMMIT_HASH:-}" \ + COMMIT_HASH="${COMMIT_HASH:-${SOURCE_COMMIT:-}}" \ GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \ GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \ SOURCE_COMMIT_FILE=".jenkins-source-commit" \ @@ -124,10 +224,20 @@ BASH label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}" } steps { + unstash 'server-provision-tools' sh ''' bash <<'BASH' set -euo pipefail + if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then + chmod +x "${PROVISION_TOOLS_DIR:-provision-tools}/otelcol-contrib" + fi + chmod +x "${PROVISION_TOOLS_DIR:-provision-tools}/spacetime/spacetime" \ + "${PROVISION_TOOLS_DIR:-provision-tools}/spacetime/bin/current/spacetimedb-cli" \ + "${PROVISION_TOOLS_DIR:-provision-tools}/spacetime/bin/current/spacetimedb-standalone" chmod +x scripts/jenkins-server-provision.sh + PROVISION_TOOLS_DIR="${PROVISION_TOOLS_DIR:-provision-tools}" \ + SPACETIME_BIN_SOURCE="${PROVISION_TOOLS_DIR:-provision-tools}/spacetime/spacetime" \ + OTELCOL_BIN_SOURCE="${PROVISION_TOOLS_DIR:-provision-tools}/otelcol-contrib" \ scripts/jenkins-server-provision.sh BASH ''' 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/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/puzzleAgentActions.ts b/packages/shared/src/contracts/puzzleAgentActions.ts index 594d66ca..9e6d2cb3 100644 --- a/packages/shared/src/contracts/puzzleAgentActions.ts +++ b/packages/shared/src/contracts/puzzleAgentActions.ts @@ -76,6 +76,7 @@ export type PuzzleAgentActionRequest = imageModel?: string | null; aiRedraw?: boolean; candidateCount?: number; + shouldAutoNameLevel?: boolean; workTitle?: string; workDescription?: string; summary?: string; 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..35a4bed9 --- /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 后台启动 spacetimedb + 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-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/jenkins-server-provision.sh b/scripts/jenkins-server-provision.sh index 203518d4..b584b90b 100755 --- a/scripts/jenkins-server-provision.sh +++ b/scripts/jenkins-server-provision.sh @@ -1,6 +1,24 @@ #!/usr/bin/env bash set -euo pipefail +PROVISION_TOOLS_DIR="${PROVISION_TOOLS_DIR:-provision-tools}" +SPACETIME_BIN_SOURCE="${SPACETIME_BIN_SOURCE:-${PROVISION_TOOLS_DIR}/spacetime/spacetime}" +OTELCOL_BIN_SOURCE="${OTELCOL_BIN_SOURCE:-${PROVISION_TOOLS_DIR}/otelcol-contrib}" + +require_non_root_relative_path() { + local label="$1" + local path="$2" + + if [[ -z "${path}" ]]; then + echo "[server-provision] ${label} 不能为空。" >&2 + exit 1 + fi + if [[ "${path}" == /* || "${path}" == *..* ]]; then + echo "[server-provision] ${label} 只能是工作区内的相对路径: ${path}" >&2 + exit 1 + fi +} + require_path() { local path="$1" if [[ ! -e "${path}" ]]; then @@ -63,6 +81,15 @@ install_build_dependencies() { fi } +install_nginx_brotli_modules() { + echo "[server-provision] 安装 Nginx Brotli 动态模块依赖" + if command -v apt-get >/dev/null 2>&1; then + run_cmd apt-get install -y libnginx-mod-http-brotli-filter libnginx-mod-http-brotli-static + else + echo "[server-provision] 当前系统未使用 apt,无法自动安装 Nginx Brotli 动态模块;将继续通过 nginx -t 能力探测决定是否启用 Brotli。" + fi +} + install_sccache() { for tool_dir in "${HOME:-}/.cargo/bin" /root/.cargo/bin /usr/local/cargo/bin; do if [[ -d "${tool_dir}" && ":${PATH}:" != *":${tool_dir}:"* ]]; then @@ -81,16 +108,16 @@ install_sccache() { fi echo "[server-provision] 未找到 sccache,准备通过 cargo install sccache 安装。" - if ! command -v cargo >/dev/null 2>&1; then - echo "[server-provision] 未找到 cargo,无法自动安装 sccache。请先安装 Rust 工具链后重跑 Server-Provision。" >&2 - exit 1 - fi - if [[ "${DRY_RUN}" == "true" ]]; then echo "+ cargo install sccache --locked" return fi + if ! command -v cargo >/dev/null 2>&1; then + echo "[server-provision] 未找到 cargo,无法自动安装 sccache。请先安装 Rust 工具链后重跑 Server-Provision。" >&2 + exit 1 + fi + cargo install sccache --locked if ! command -v sccache >/dev/null 2>&1 && [[ ! -x /root/.cargo/bin/sccache ]]; then echo "[server-provision] sccache 安装后仍不可用,请检查 cargo bin 目录是否在 PATH 中。" >&2 @@ -98,6 +125,42 @@ install_sccache() { fi } +sync_otelcol_install() { + local target_bin="/usr/local/bin/otelcol-contrib" + local source_bin="${OTELCOL_BIN_SOURCE}" + local version="${OTELCOL_VERSION:-0.151.0}" + local resolved_source="${source_bin}" + + if [[ "${ENABLE_OTELCOL:-true}" != "true" ]]; then + echo "[server-provision] ENABLE_OTELCOL=${ENABLE_OTELCOL:-},跳过 otelcol-contrib 配置。" + return + fi + + if command -v readlink >/dev/null 2>&1; then + resolved_source="$(readlink -f "${source_bin}" 2>/dev/null || echo "${source_bin}")" + fi + + if [[ ! -x "${resolved_source}" ]]; then + echo "[server-provision] otelcol-contrib 不存在或不可执行: ${source_bin}" >&2 + echo "[server-provision] 请先在构建机准备好 otelcol-contrib ${version},再通过 provision-tools 上传到目标机。" >&2 + exit 1 + fi + + if [[ "${DRY_RUN}" == "true" ]]; then + echo "+ install -m 0755 ${resolved_source} ${target_bin}" + return + fi + + install -m 0755 "${resolved_source}" "${target_bin}" + if ! "${target_bin}" --version >/dev/null 2>&1; then + echo "[server-provision] otelcol-contrib 安装后无法执行: ${target_bin}" >&2 + exit 1 + fi + if ! "${target_bin}" --version 2>/dev/null | grep -q "${version}"; then + echo "[server-provision] 警告: otelcol-contrib 版本不是期望的 ${version}: $("${target_bin}" --version 2>/dev/null || true)" >&2 + fi +} + sync_spacetime_install() { local root_dir="$1" local target_bin_dir="${root_dir}/bin/current" @@ -106,14 +169,6 @@ sync_spacetime_install() { local resolved_command="${SPACETIME_BIN_SOURCE}" local install_dir="" local root_bin="${root_dir}/bin" - local share_bin_dir="" - local version_dir="" - local parent_dir="" - - if [[ -x "${target_cli}" && -x "${target_standalone}" ]]; then - echo "[server-provision] SpacetimeDB current 目录已存在: ${target_bin_dir}" - return - fi echo "[server-provision] 同步 SpacetimeDB current 目录到 ${target_bin_dir}" if [[ "${DRY_RUN}" == "true" ]]; then @@ -128,26 +183,10 @@ sync_spacetime_install() { install_dir="$(cd -- "$(dirname -- "${resolved_command}")" && pwd)" mkdir -p "${root_bin}" - for share_bin_dir in \ - "/usr/.local/share/spacetime/bin" \ - "/root/.local/share/spacetime/bin" \ - "${HOME:-}/.local/share/spacetime/bin"; do - if [[ -d "${share_bin_dir}" ]]; then - version_dir="$(find "${share_bin_dir}" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -n 1)" - if [[ -n "${version_dir}" && -x "${version_dir}/spacetimedb-cli" && -x "${version_dir}/spacetimedb-standalone" ]]; then - echo "[server-provision] 同步 SpacetimeDB 安装: ${version_dir} -> ${target_bin_dir}" - rm -rf "${target_bin_dir}" - mkdir -p "${target_bin_dir}" - cp -a "${version_dir}/." "${target_bin_dir}/" - chmod +x "${target_cli}" "${target_standalone}" - chown -R spacetimedb:spacetimedb "${root_bin}" - return - fi - fi - done - if [[ -d "${install_dir}/bin" ]]; then echo "[server-provision] 同步 SpacetimeDB 安装: ${install_dir}/bin -> ${root_bin}" + rm -rf "${root_bin}" + mkdir -p "${root_bin}" cp -a "${install_dir}/bin/." "${root_bin}/" elif [[ -x "${install_dir}/spacetimedb-cli" && -x "${install_dir}/spacetimedb-standalone" ]]; then echo "[server-provision] 同步 SpacetimeDB 安装: ${install_dir} -> ${target_bin_dir}" @@ -156,14 +195,8 @@ sync_spacetime_install() { cp -f "${install_dir}/spacetimedb-cli" "${target_cli}" cp -f "${install_dir}/spacetimedb-standalone" "${target_standalone}" chmod +x "${target_cli}" "${target_standalone}" - elif [[ -f "${resolved_command}" ]]; then - parent_dir="$(cd -- "${install_dir}/.." && pwd)" - if [[ -d "${parent_dir}/bin" && -x "${parent_dir}/bin/current/spacetimedb-cli" && -x "${parent_dir}/bin/current/spacetimedb-standalone" ]]; then - echo "[server-provision] 同步 SpacetimeDB 安装: ${parent_dir}/bin -> ${root_bin}" - cp -a "${parent_dir}/bin/." "${root_bin}/" - else - echo "[server-provision] 未能从 spacetime 命令路径推断完整 SpacetimeDB 安装目录: ${resolved_command}" >&2 - fi + else + echo "[server-provision] 未能从 SpacetimeDB 交付包推断完整安装目录: ${resolved_command}" >&2 fi if [[ ! -x "${target_cli}" || ! -x "${target_standalone}" ]]; then @@ -387,6 +420,10 @@ render_api_env_example() { deploy/env/api-server.env.example } +render_otelcol_service() { + cat deploy/systemd/otelcol-contrib.service +} + validate_nginx_tls() { local cert_dir="/etc/letsencrypt/live/${SERVER_NAME}" if [[ "${SERVER_NAME}" == "genarrative.example.com" ]]; then @@ -523,6 +560,8 @@ render_api_service() { require_path deploy/systemd/spacetimedb.service require_path deploy/systemd/genarrative-api.service +require_path deploy/systemd/otelcol-contrib.service +require_path deploy/otelcol/genarrative-debug.yaml require_path deploy/nginx/genarrative.conf require_path deploy/nginx/genarrative-dev-http.conf require_path deploy/nginx/snippets/genarrative-maintenance.conf @@ -532,13 +571,15 @@ require_path scripts/deploy/maintenance-off.sh require_path scripts/deploy/maintenance-status.sh validate_server_names +require_non_root_relative_path "PROVISION_TOOLS_DIR" "${PROVISION_TOOLS_DIR}" echo "[server-provision] target=${DEPLOY_TARGET}, dry_run=${DRY_RUN}, nginx_config_mode=${NGINX_CONFIG_MODE}, source_commit=$(cat .jenkins-source-commit)" run_cmd id install_build_dependencies +install_nginx_brotli_modules install_sccache -run_cmd mkdir -p "${SPACETIME_ROOT}" "${RELEASE_ROOT}" "$(dirname "${CURRENT_LINK}")" "$(dirname "${WEB_LINK}")" /etc/genarrative /var/lib/genarrative/maintenance /var/lib/genarrative/auth +run_cmd mkdir -p "${SPACETIME_ROOT}" "${RELEASE_ROOT}" "$(dirname "${CURRENT_LINK}")" "$(dirname "${WEB_LINK}")" /etc/genarrative /var/lib/genarrative/maintenance /var/lib/genarrative/auth /var/lib/genarrative/tracking-outbox if ! id spacetimedb >/dev/null 2>&1; then run_cmd useradd --system --home-dir "${SPACETIME_ROOT}" --shell /usr/sbin/nologin spacetimedb @@ -585,6 +626,16 @@ else echo "[server-provision] 已存在环境文件,保留不覆盖: ${API_ENV_FILE}" fi +if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then + sync_otelcol_install + otelcol_service="$(mktemp)" + render_otelcol_service >"${otelcol_service}" + install_file "${otelcol_service}" /etc/systemd/system/otelcol-contrib.service 0644 + rm -f "${otelcol_service}" +else + echo "[server-provision] ENABLE_OTELCOL=${ENABLE_OTELCOL:-},跳过 otelcol-contrib service 安装。" +fi + if [[ "${NGINX_CONFIG_MODE}" != "none" ]]; then install_nginx_config_with_rollback else @@ -593,7 +644,13 @@ fi run_cmd systemctl daemon-reload if [[ "${ENABLE_SERVICES}" == "true" ]]; then + if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then + run_cmd systemctl enable otelcol-contrib.service + fi run_cmd systemctl enable spacetimedb.service genarrative-api.service + if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then + run_cmd systemctl restart otelcol-contrib.service + fi run_cmd systemctl restart spacetimedb.service wait_for_spacetimedb_service ensure_spacetime_owner_client_token diff --git a/scripts/loadtest/README.md b/scripts/loadtest/README.md index 0b406675..2f071e8d 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,123 @@ npm run loadtest:k6:works ## 排障 - 如果公开 gallery 返回 `creation_entry_disabled` 或 503,检查本地 creation entry 配置是否禁用了对应入口。 +- 如果高压下返回 429,优先确认目标环境是否设置了 `GENARRATIVE_API_MAX_CONCURRENT_REQUESTS` 以及 `GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS`、`GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS`、`GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS`。429 表示 Nginx 或 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 在生产与容器模板里默认开启。需要临时关闭时,显式把 `GENARRATIVE_OTEL_ENABLED=false`;需要验证 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.cpu.time`:进程 user + system 累计 CPU 秒数。 +- `genarrative.process.cpu.usage_percent`:两次指标采集之间的进程 CPU 使用率;100% 约等于占满 1 个 CPU core。 +- `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 数,带 `pool=default|gallery|detail|admin`;如果目标 pool 未接近 0,说明没有打满对应 `GENARRATIVE_API_*_MAX_CONCURRENT_REQUESTS`。 +- `genarrative.puzzle_gallery.cache.hits` / `genarrative.puzzle_gallery.cache.stale_hits` / `genarrative.puzzle_gallery.cache.misses` / `genarrative.puzzle_gallery.cache.refreshes_started` / `genarrative.puzzle_gallery.cache.refreshes_failed` / `genarrative.puzzle_gallery.cache.rebuilds`:拼图广场响应缓存 fresh 命中、stale 命中、未命中、后台刷新和重建次数。 +- `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/data/works-list.sample.json b/scripts/loadtest/data/works-list.sample.json index 250157bd..d60cf0c0 100644 --- a/scripts/loadtest/data/works-list.sample.json +++ b/scripts/loadtest/data/works-list.sample.json @@ -1,10 +1,12 @@ { - "source": "spacetime-migration-7.local.json", - "generatedAt": "2026-05-11T13:09:51.569Z", + "source": "spacetime-migration-1.json", + "generatedAt": "2026-05-18T11:54:04.280Z", "counts": { "puzzle_work_profile": 3, "custom_world_profile": 1, - "match3d_work_profile": 0 + "match3d_work_profile": 0, + "square_hole_work_profile": 0, + "visual_novel_work_profile": 0 }, "tables": { "puzzle_work_profile": [ @@ -113,7 +115,9 @@ } } ], - "match3d_work_profile": [] + "match3d_work_profile": [], + "square_hole_work_profile": [], + "visual_novel_work_profile": [] }, "profileIds": { "puzzle": [ diff --git a/scripts/loadtest/k6-works-list.js b/scripts/loadtest/k6-works-list.js index 45e51a82..95f0d212 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'], }, }, }; @@ -135,12 +137,12 @@ function unwrapPayload(json) { } function hasCollection(payload, keys) { - return keys.some((key) => Array.isArray(payload?.[key])); + return Boolean(payload) && keys.some((key) => Array.isArray(payload[key])); } function firstCollection(payload, keys) { for (const key of keys) { - if (Array.isArray(payload?.[key])) return payload[key]; + if (payload && Array.isArray(payload[key])) return payload[key]; } return []; } @@ -150,10 +152,11 @@ function hasListItemShape(payload, keys) { if (collection.length === 0) return true; const item = collection[0]; const hasId = Boolean( - item?.profileId || item?.profile_id || item?.workId || item?.work_id || item?.publicWorkCode, + item && + (item.profileId || item.profile_id || item.workId || item.work_id || item.publicWorkCode), ); const hasTitle = Boolean( - item?.title || item?.workTitle || item?.work_title || item?.levelName || item?.worldName, + item && (item.title || item.workTitle || item.work_title || item.levelName || item.worldName), ); return hasId && hasTitle; } @@ -211,7 +214,8 @@ function performDetailRequest() { const payload = unwrapPayload(json); const ok = check(response, { [`${endpoint.name} status is 200`]: (res) => res.status === 200, - [`${endpoint.name} has detail payload`]: () => endpoint.expectKeys.some((key) => payload?.[key]), + [`${endpoint.name} has detail payload`]: () => + Boolean(payload) && endpoint.expectKeys.some((key) => payload[key]), }); worksDetailShapeErrorRate.add(!ok, { endpoint: endpoint.name }); } diff --git a/scripts/prepare-server-provision-tools.sh b/scripts/prepare-server-provision-tools.sh new file mode 100755 index 00000000..2e73a5f4 --- /dev/null +++ b/scripts/prepare-server-provision-tools.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROVISION_TOOLS_DIR="${PROVISION_TOOLS_DIR:-provision-tools}" +OTELCOL_VERSION="${OTELCOL_VERSION:-0.151.0}" +PREPARE_OTELCOL="${PREPARE_OTELCOL:-${ENABLE_OTELCOL:-true}}" +OTELCOL_ARCHIVE_SOURCE="${OTELCOL_ARCHIVE_SOURCE:-}" +OTELCOL_DOWNLOAD_ROOT="${OTELCOL_DOWNLOAD_ROOT:-https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download}" +SPACETIME_INSTALLER_URL="${SPACETIME_INSTALLER_URL:-https://install.spacetimedb.com}" +SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/latest/download}" +PROVISION_TOOLS_TMP_PARENT="${PROVISION_TOOLS_TMP_PARENT:-${WORKSPACE:-$(pwd)}/.tmp/server-provision-tools}" +TMP_DIR_TO_CLEAN="" +OTELCOL_SOURCE_DESCRIPTION="skipped" + +cleanup_tmp_dir() { + if [[ -n "${TMP_DIR_TO_CLEAN}" ]]; then + rm -rf "${TMP_DIR_TO_CLEAN}" + fi +} + +require_cmd() { + local name="$1" + if ! command -v "${name}" >/dev/null 2>&1; then + echo "[prepare-provision-tools] 缺少命令: ${name}" >&2 + exit 1 + fi +} + +download_file() { + local url="$1" + local output="$2" + + if command -v curl >/dev/null 2>&1; then + curl -fsSL --retry 3 --retry-delay 2 "${url}" -o "${output}" + elif command -v wget >/dev/null 2>&1; then + wget -O "${output}" "${url}" + else + echo "[prepare-provision-tools] 需要 curl 或 wget 下载: ${url}" >&2 + exit 1 + fi +} + +make_spacetime_wrapper() { + local target="$1" + + cat >"${target}" <<'EOF' +#!/usr/bin/env sh +set -eu +SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec "$SELF_DIR/bin/current/spacetimedb-cli" "$@" +EOF + chmod 0755 "${target}" +} + +prepare_otelcol() { + local tmp_dir="$1" + local archive="${tmp_dir}/otelcol-contrib.tar.gz" + local extract_dir="${tmp_dir}/otelcol-contrib" + local url="${OTELCOL_DOWNLOAD_ROOT}/v${OTELCOL_VERSION}/otelcol-contrib_${OTELCOL_VERSION}_linux_amd64.tar.gz" + local source_archive="${OTELCOL_ARCHIVE_SOURCE}" + local target="${PROVISION_TOOLS_DIR}/otelcol-contrib" + + require_cmd tar + + mkdir -p "${extract_dir}" + if [[ -n "${source_archive}" ]]; then + if [[ ! -f "${source_archive}" ]]; then + echo "[prepare-provision-tools] 上传的 otelcol-contrib 包不存在: ${source_archive}" >&2 + exit 1 + fi + echo "[prepare-provision-tools] 使用手动上传的 otelcol-contrib 包: ${source_archive}" + cp "${source_archive}" "${archive}" + OTELCOL_SOURCE_DESCRIPTION="manual archive ${source_archive}" + else + echo "[prepare-provision-tools] 下载 otelcol-contrib: ${url}" + download_file "${url}" "${archive}" + OTELCOL_SOURCE_DESCRIPTION="download ${url}" + fi + tar -xzf "${archive}" -C "${extract_dir}" + + if [[ ! -x "${extract_dir}/otelcol-contrib" ]]; then + echo "[prepare-provision-tools] otelcol-contrib 包中缺少可执行文件。" >&2 + exit 1 + fi + + install -m 0755 "${extract_dir}/otelcol-contrib" "${target}" + "${target}" --version >/dev/null +} + +prepare_spacetime() { + local tmp_dir="$1" + local install_root="${tmp_dir}/spacetime-root" + local target_dir="${PROVISION_TOOLS_DIR}/spacetime" + + echo "[prepare-provision-tools] 使用官方安装器准备 SpacetimeDB: ${SPACETIME_INSTALLER_URL}" + mkdir -p "${install_root}" + download_file "${SPACETIME_INSTALLER_URL}" "${tmp_dir}/spacetime-install.sh" + chmod 0755 "${tmp_dir}/spacetime-install.sh" + TMPDIR="${tmp_dir}" SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT}" sh "${tmp_dir}/spacetime-install.sh" --root-dir "${install_root}" -y + + if [[ ! -x "${install_root}/bin/current/spacetimedb-cli" ]]; then + echo "[prepare-provision-tools] SpacetimeDB 安装结果缺少 bin/current/spacetimedb-cli。" >&2 + exit 1 + fi + if [[ ! -x "${install_root}/bin/current/spacetimedb-standalone" ]]; then + echo "[prepare-provision-tools] SpacetimeDB 安装结果缺少 bin/current/spacetimedb-standalone。" >&2 + exit 1 + fi + + mkdir -p "${target_dir}" + cp -a "${install_root}/bin" "${target_dir}/bin" + make_spacetime_wrapper "${target_dir}/spacetime" + + "${target_dir}/spacetime" --version >/dev/null +} + +main() { + local tmp_dir + + require_cmd chmod + require_cmd cp + require_cmd install + require_cmd mktemp + require_cmd rm + + mkdir -p "${PROVISION_TOOLS_TMP_PARENT}" + tmp_dir="$(mktemp -d "${PROVISION_TOOLS_TMP_PARENT%/}/run.XXXXXX")" + TMP_DIR_TO_CLEAN="${tmp_dir}" + trap cleanup_tmp_dir EXIT + + rm -rf "${PROVISION_TOOLS_DIR}" + mkdir -p "${PROVISION_TOOLS_DIR}" + + if [[ "${PREPARE_OTELCOL}" == "true" ]]; then + prepare_otelcol "${tmp_dir}" + else + echo "[prepare-provision-tools] PREPARE_OTELCOL=${PREPARE_OTELCOL},跳过 otelcol-contrib 工具包准备。" + fi + prepare_spacetime "${tmp_dir}" + + cat >"${PROVISION_TOOLS_DIR}/MANIFEST.txt" < { + 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..b423be50 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", "fs", "io-util"] } 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..e5e4f27c 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -1,7 +1,7 @@ use axum::{ Router, body::Body, - extract::Extension, + extract::{Extension, FromRef}, http::Request, middleware, response::Response, @@ -11,17 +11,19 @@ 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, request_context::{RequestContext, attach_request_context, resolve_request_id}, response_headers::propagate_request_id_header, runtime_inventory::get_runtime_inventory_state, - state::AppState, + state::{AppState, BackpressureState}, + 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( + BackpressureState::from_ref(&state), + 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..3fc2b689 --- /dev/null +++ b/server-rs/crates/api-server/src/backpressure.rs @@ -0,0 +1,481 @@ +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::{BackpressureState, HttpRequestPermitPool, HttpRequestPermitPoolKind}, +}; + +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 requested_pool = classify_request_permit_pool(request.uri().path()); + let Some((permit_pool_kind, permit_pool)) = state.request_permit_pool(requested_pool) else { + return next.run(request).await; + }; + + match acquire_http_request_permit(permit_pool_kind, 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_kind: HttpRequestPermitPoolKind, + permit_pool: Arc, +) -> Result { + match permit_pool.clone().try_acquire_owned() { + Ok(permit) => { + crate::telemetry::update_http_request_permits_available( + permit_pool_kind, + permit_pool.available_permits(), + ); + Ok(HttpRequestPermitGuard { + permit_pool_kind, + permit: Some(permit), + permit_pool, + }) + } + Err(error) => { + crate::telemetry::update_http_request_permits_available( + permit_pool_kind, + 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_pool_kind: HttpRequestPermitPoolKind, + 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_kind, + 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" +} + +fn classify_request_permit_pool(path: &str) -> HttpRequestPermitPoolKind { + if is_gallery_list_path(path) { + HttpRequestPermitPoolKind::Gallery + } else if is_gallery_detail_path(path) { + HttpRequestPermitPoolKind::Detail + } else if path.starts_with("/admin/api/") { + HttpRequestPermitPoolKind::Admin + } else { + HttpRequestPermitPoolKind::Default + } +} + +fn is_gallery_list_path(path: &str) -> bool { + matches!( + path, + "/api/runtime/puzzle/gallery" | "/api/runtime/custom-world-gallery" + ) +} + +fn is_gallery_detail_path(path: &str) -> bool { + let puzzle_prefix = "/api/runtime/puzzle/gallery/"; + if let Some(profile_id) = path.strip_prefix(puzzle_prefix) { + return !profile_id.is_empty() && !profile_id.contains('/'); + } + + let custom_world_prefix = "/api/runtime/custom-world-gallery/"; + if let Some(remainder) = path.strip_prefix(custom_world_prefix) { + let mut segments = remainder.split('/'); + return matches!( + (segments.next(), segments.next(), segments.next()), + (Some(owner_user_id), Some(profile_id), None) + if !owner_user_id.is_empty() && !profile_id.is_empty() + ); + } + + false +} + +#[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 axum::extract::FromRef; + + use crate::{ + config::AppConfig, + state::{AppState, BackpressureState}, + }; + + use super::{classify_request_permit_pool, 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"); + let backpressure_state = BackpressureState::from_ref(&state); + + Router::new() + .route("/held", get(held_request)) + .route("/fast", get(fast_request)) + .route("/healthz", get(fast_request)) + .layer(middleware::from_fn_with_state( + backpressure_state, + limit_concurrent_requests, + )) + .layer(Extension(gate)) + .with_state(state) + } + + fn build_grouped_test_app( + default_max_concurrent_requests: usize, + gallery_max_concurrent_requests: usize, + admin_max_concurrent_requests: usize, + gate: HeldRequestGate, + ) -> Router { + let mut config = AppConfig::default(); + config.max_concurrent_requests = Some(default_max_concurrent_requests); + config.gallery_max_concurrent_requests = Some(gallery_max_concurrent_requests); + config.admin_max_concurrent_requests = Some(admin_max_concurrent_requests); + let state = AppState::new(config).expect("state should build"); + let backpressure_state = BackpressureState::from_ref(&state); + + Router::new() + .route("/held", get(held_request)) + .route("/api/runtime/puzzle/gallery", get(held_request)) + .route("/api/runtime/custom-world-gallery", get(held_request)) + .route("/api/runtime/puzzle/gallery/profile-1", get(held_request)) + .route( + "/api/runtime/puzzle/gallery/profile-1/like", + get(fast_request), + ) + .route( + "/api/runtime/custom-world-gallery/user-1/profile-1", + get(held_request), + ) + .route("/admin/api/overview", get(held_request)) + .route("/fast", get(fast_request)) + .layer(middleware::from_fn_with_state( + backpressure_state, + 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); + } + + #[tokio::test] + async fn gallery_pool_rejects_gallery_without_blocking_default_routes() { + let gate = HeldRequestGate { + entered: Arc::new(Notify::new()), + release: Arc::new(Notify::new()), + }; + let app = build_grouped_test_app(2, 1, 1, gate.clone()); + let entered = gate.entered.notified(); + + let held_response = tokio::spawn( + app.clone() + .oneshot(test_request("/api/runtime/puzzle/gallery")), + ); + entered.await; + + let rejected_gallery_response = app + .clone() + .oneshot(test_request("/api/runtime/custom-world-gallery")) + .await + .expect("rejected gallery request should complete"); + assert_eq!( + rejected_gallery_response.status(), + StatusCode::TOO_MANY_REQUESTS + ); + + let accepted_default_response = app + .clone() + .oneshot(test_request("/fast")) + .await + .expect("default request should complete"); + assert_eq!(accepted_default_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 detail_pool_falls_back_to_default_when_unset() { + let gate = HeldRequestGate { + entered: Arc::new(Notify::new()), + release: Arc::new(Notify::new()), + }; + let mut config = AppConfig::default(); + config.max_concurrent_requests = Some(1); + config.detail_max_concurrent_requests = None; + let state = AppState::new(config).expect("state should build"); + let backpressure_state = BackpressureState::from_ref(&state); + let app = Router::new() + .route("/api/runtime/puzzle/gallery/profile-1", get(held_request)) + .route("/fast", get(fast_request)) + .layer(middleware::from_fn_with_state( + backpressure_state, + limit_concurrent_requests, + )) + .layer(Extension(gate.clone())) + .with_state(state); + let entered = gate.entered.notified(); + + let held_response = tokio::spawn( + app.clone() + .oneshot(test_request("/api/runtime/puzzle/gallery/profile-1")), + ); + entered.await; + + let rejected_default_response = app + .clone() + .oneshot(test_request("/fast")) + .await + .expect("default request should complete"); + assert_eq!( + rejected_default_response.status(), + StatusCode::TOO_MANY_REQUESTS + ); + + 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 admin_pool_is_isolated_from_default_routes() { + let gate = HeldRequestGate { + entered: Arc::new(Notify::new()), + release: Arc::new(Notify::new()), + }; + let app = build_grouped_test_app(2, 1, 1, gate.clone()); + let entered = gate.entered.notified(); + + let held_response = tokio::spawn(app.clone().oneshot(test_request("/admin/api/overview"))); + entered.await; + + let rejected_admin_response = app + .clone() + .oneshot(test_request("/admin/api/overview")) + .await + .expect("rejected admin request should complete"); + assert_eq!( + rejected_admin_response.status(), + StatusCode::TOO_MANY_REQUESTS + ); + + let accepted_default_response = app + .clone() + .oneshot(test_request("/fast")) + .await + .expect("default request should complete"); + assert_eq!(accepted_default_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); + } + + #[test] + fn classifies_only_exact_gallery_detail_paths_as_detail() { + assert_eq!( + classify_request_permit_pool("/api/runtime/puzzle/gallery/profile-1"), + crate::state::HttpRequestPermitPoolKind::Detail + ); + assert_eq!( + classify_request_permit_pool("/api/runtime/puzzle/gallery/profile-1/like"), + crate::state::HttpRequestPermitPoolKind::Default + ); + assert_eq!( + classify_request_permit_pool("/api/runtime/custom-world-gallery/user-1/profile-1"), + crate::state::HttpRequestPermitPoolKind::Detail + ); + assert_eq!( + classify_request_permit_pool("/api/runtime/custom-world-gallery/user-1/profile-1/like"), + crate::state::HttpRequestPermitPoolKind::Default + ); + } +} diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index b8af62a4..0398c948 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -20,7 +20,19 @@ 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 gallery_max_concurrent_requests: Option, + pub detail_max_concurrent_requests: Option, + pub admin_max_concurrent_requests: Option, + pub tracking_outbox_enabled: bool, + pub tracking_outbox_dir: PathBuf, + pub tracking_outbox_batch_size: usize, + pub tracking_outbox_flush_interval: Duration, + pub tracking_outbox_max_bytes: u64, pub log_filter: String, + pub otel_enabled: bool, pub admin_username: Option, pub admin_password: Option, pub admin_token_ttl_seconds: u64, @@ -147,7 +159,19 @@ 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, + gallery_max_concurrent_requests: None, + detail_max_concurrent_requests: None, + admin_max_concurrent_requests: None, + tracking_outbox_enabled: true, + tracking_outbox_dir: PathBuf::from("server-rs/.data/tracking-outbox"), + tracking_outbox_batch_size: 500, + tracking_outbox_flush_interval: Duration::from_millis(1_000), + tracking_outbox_max_bytes: 256 * 1024 * 1024, 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 +188,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 +325,57 @@ 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(max_concurrent_requests) = + read_first_usize_env(&["GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS"]) + { + config.gallery_max_concurrent_requests = Some(max_concurrent_requests); + } + if let Some(max_concurrent_requests) = + read_first_usize_env(&["GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS"]) + { + config.detail_max_concurrent_requests = Some(max_concurrent_requests); + } + if let Some(max_concurrent_requests) = + read_first_usize_env(&["GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS"]) + { + config.admin_max_concurrent_requests = Some(max_concurrent_requests); + } + if let Some(enabled) = read_first_bool_env(&["GENARRATIVE_TRACKING_OUTBOX_ENABLED"]) { + config.tracking_outbox_enabled = enabled; + } + if let Some(dir) = read_first_non_empty_env(&["GENARRATIVE_TRACKING_OUTBOX_DIR"]) { + config.tracking_outbox_dir = PathBuf::from(dir); + } + if let Some(batch_size) = read_first_usize_env(&["GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE"]) + { + config.tracking_outbox_batch_size = batch_size; + } + if let Some(flush_interval_ms) = + read_first_positive_u64_env(&["GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS"]) + { + config.tracking_outbox_flush_interval = Duration::from_millis(flush_interval_ms); + } + if let Some(max_bytes) = + read_first_positive_u64_env(&["GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES"]) + { + config.tracking_outbox_max_bytes = max_bytes; + } + 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 +956,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 +1029,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 +1064,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 +1114,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 +1139,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 +1286,79 @@ 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_API_GALLERY_MAX_CONCURRENT_REQUESTS"); + std::env::remove_var("GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS"); + std::env::remove_var("GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS"); + std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED"); + std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_DIR"); + std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE"); + std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS"); + std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES"); + 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_API_GALLERY_MAX_CONCURRENT_REQUESTS", "64"); + std::env::set_var("GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS", "32"); + std::env::set_var("GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS", "16"); + std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED", "false"); + std::env::set_var( + "GENARRATIVE_TRACKING_OUTBOX_DIR", + "/tmp/genarrative-tracking-outbox", + ); + std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE", "250"); + std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS", "2000"); + std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES", "1048576"); + 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_eq!(config.gallery_max_concurrent_requests, Some(64)); + assert_eq!(config.detail_max_concurrent_requests, Some(32)); + assert_eq!(config.admin_max_concurrent_requests, Some(16)); + assert!(!config.tracking_outbox_enabled); + assert_eq!( + config.tracking_outbox_dir, + std::path::PathBuf::from("/tmp/genarrative-tracking-outbox") + ); + assert_eq!(config.tracking_outbox_batch_size, 250); + assert_eq!( + config.tracking_outbox_flush_interval, + std::time::Duration::from_millis(2_000) + ); + assert_eq!(config.tracking_outbox_max_bytes, 1_048_576); + 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_API_GALLERY_MAX_CONCURRENT_REQUESTS"); + std::env::remove_var("GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS"); + std::env::remove_var("GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS"); + std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED"); + std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_DIR"); + std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE"); + std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS"); + std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES"); + 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/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..01ed6555 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; @@ -54,10 +55,12 @@ mod password_entry; mod password_management; mod phone_auth; mod platform_errors; +mod process_metrics; mod profile_identity; mod prompt; mod puzzle; mod puzzle_agent_turn; +mod puzzle_gallery_cache; mod refresh_session; mod registration_reward; mod request_context; @@ -75,7 +78,9 @@ mod square_hole_agent_turn; mod state; mod story_battles; mod story_sessions; +mod telemetry; mod tracking; +mod tracking_outbox; mod vector_engine_audio_generation; mod visual_novel; mod volcengine_speech; @@ -85,8 +90,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 +115,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 +135,55 @@ 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(); + if let Some(outbox) = state.tracking_outbox() { + outbox.spawn_worker(); + } 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..4d3adad2 --- /dev/null +++ b/server-rs/crates/api-server/src/process_metrics.rs @@ -0,0 +1,493 @@ +use std::{ + sync::{Mutex, OnceLock}, + time::Instant, +}; + +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 + .f64_observable_counter("process.cpu.time") + .with_unit("s") + .with_description("api-server total user plus system CPU time") + .with_callback(|observer| { + let Some(snapshot) = ProcessMetricsSnapshot::collect() else { + return; + }; + if let Some(cpu_time_seconds) = snapshot.cpu_time_seconds { + observer.observe(cpu_time_seconds, &[]); + } + }) + .build(); + + meter + .f64_observable_gauge("genarrative.process.cpu.usage_percent") + .with_unit("%") + .with_description("api-server process CPU usage between metric collections") + .with_callback(|observer| { + let Some(snapshot) = ProcessMetricsSnapshot::collect() else { + return; + }; + if let Some(cpu_time_seconds) = snapshot.cpu_time_seconds { + if let Some(usage_percent) = + process_cpu_usage_percent(cpu_time_seconds, Instant::now()) + { + observer.observe(usage_percent, &[]); + } + } + }) + .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)] +struct ProcessMetricsSnapshot { + rss_bytes: u64, + private_bytes: Option, + virtual_bytes: Option, + cpu_time_seconds: 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() + } +} + +#[derive(Debug, Clone, Copy)] +struct CpuUsageSample { + cpu_time_seconds: f64, + observed_at: Instant, +} + +fn process_cpu_usage_percent(cpu_time_seconds: f64, observed_at: Instant) -> Option { + static LAST_SAMPLE: OnceLock>> = OnceLock::new(); + + let mut last_sample = LAST_SAMPLE.get_or_init(|| Mutex::new(None)).lock().ok()?; + let previous = *last_sample; + *last_sample = Some(CpuUsageSample { + cpu_time_seconds, + observed_at, + }); + + let previous = previous?; + let wall_delta_seconds = observed_at + .checked_duration_since(previous.observed_at)? + .as_secs_f64(); + cpu_usage_ratio_between_samples( + previous.cpu_time_seconds, + cpu_time_seconds, + 0.0, + wall_delta_seconds, + ) + .map(|ratio| ratio * 100.0) +} + +fn cpu_usage_ratio_between_samples( + previous_cpu_seconds: f64, + current_cpu_seconds: f64, + previous_wall_seconds: f64, + current_wall_seconds: f64, +) -> Option { + let cpu_delta_seconds = current_cpu_seconds - previous_cpu_seconds; + let wall_delta_seconds = current_wall_seconds - previous_wall_seconds; + if cpu_delta_seconds < 0.0 || wall_delta_seconds <= 0.0 { + return None; + } + + Some(cpu_delta_seconds / wall_delta_seconds) +} + +#[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)) + }; + + let cpu_time_seconds = windows_process_cpu_time_seconds(handle); + + Ok(ProcessMetricsSnapshot { + rss_bytes: counters.WorkingSetSize as u64, + private_bytes: Some(counters.PrivateUsage as u64), + virtual_bytes: Some(counters.PrivateUsage as u64), + cpu_time_seconds, + thread_count: u64::from(unsafe { GetCurrentProcessId() }.thread_count()?), + windows_handle_count: handle_count, + unix_fd_count: None, + }) +} + +#[cfg(windows)] +fn windows_process_cpu_time_seconds(handle: windows_sys::Win32::Foundation::HANDLE) -> Option { + use windows_sys::Win32::{ + Foundation::FILETIME, + System::Threading::GetProcessTimes, + }; + + let mut creation_time = FILETIME::default(); + let mut exit_time = FILETIME::default(); + let mut kernel_time = FILETIME::default(); + let mut user_time = FILETIME::default(); + let ok = unsafe { + GetProcessTimes( + handle, + &mut creation_time, + &mut exit_time, + &mut kernel_time, + &mut user_time, + ) + }; + if ok == 0 { + return None; + } + + let total_100ns = filetime_100ns(kernel_time) + filetime_100ns(user_time); + Some(total_100ns as f64 / 10_000_000.0) +} + +#[cfg(windows)] +fn filetime_100ns(filetime: windows_sys::Win32::Foundation::FILETIME) -> u64 { + ((filetime.dwHighDateTime as u64) << 32) | u64::from(filetime.dwLowDateTime) +} + +#[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 stat = std::fs::read_to_string("/proc/self/stat") + .map_err(|error| format!("read /proc/self/stat 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 cpu_time_seconds = linux_cpu_time_seconds(&stat)?; + 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), + cpu_time_seconds: Some(cpu_time_seconds), + thread_count, + windows_handle_count: None, + unix_fd_count: linux_fd_count(), + }) +} + +#[cfg(target_os = "linux")] +fn linux_cpu_time_seconds(stat: &str) -> Result { + let cpu_ticks = parse_linux_proc_stat_cpu_ticks(stat) + .ok_or_else(|| "missing /proc/self/stat utime/stime fields".to_string())?; + let ticks_per_second = linux_clock_ticks_per_second()?; + Ok(cpu_ticks as f64 / ticks_per_second as f64) +} + +#[cfg(target_os = "linux")] +fn linux_clock_ticks_per_second() -> Result { + static CLOCK_TICKS_PER_SECOND: OnceLock> = OnceLock::new(); + + CLOCK_TICKS_PER_SECOND + .get_or_init(|| { + let output = std::process::Command::new("getconf") + .arg("CLK_TCK") + .output() + .map_err(|error| format!("getconf CLK_TCK failed: {error}"))?; + if !output.status.success() { + return Err(format!("getconf CLK_TCK exited with {}", output.status)); + } + let text = String::from_utf8(output.stdout) + .map_err(|error| format!("getconf CLK_TCK output is not utf8: {error}"))?; + text.trim() + .parse::() + .map_err(|error| format!("parse CLK_TCK failed: {error}")) + }) + .clone() +} + +#[cfg(target_os = "linux")] +fn parse_linux_proc_stat_cpu_ticks(stat: &str) -> Option { + let fields_after_comm = stat.rsplit_once(") ")?.1; + let mut fields = fields_after_comm.split_whitespace(); + let utime = fields.nth(11)?.parse::().ok()?; + let stime = fields.next()?.parse::().ok()?; + Some(utime + stime) +} + +#[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 { + use super::cpu_usage_ratio_between_samples; + + #[cfg(target_os = "linux")] + use super::{ + parse_linux_proc_stat_cpu_ticks, 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); + } + + #[cfg(target_os = "linux")] + #[test] + fn parses_linux_proc_stat_cpu_ticks_with_space_in_process_name() { + let stat = "123 (api server) S 1 2 3 4 5 6 7 8 9 10 120 30 0 0 20 0 18 0 12345"; + + assert_eq!(parse_linux_proc_stat_cpu_ticks(stat), Some(150)); + } + + #[test] + fn cpu_usage_ratio_uses_cpu_time_delta_over_wall_time() { + assert_eq!( + cpu_usage_ratio_between_samples(10.0, 12.5, 100.0, 101.0), + Some(2.5) + ); + assert_eq!( + cpu_usage_ratio_between_samples(10.0, 9.0, 100.0, 101.0), + None + ); + assert_eq!( + cpu_usage_ratio_between_samples(10.0, 11.0, 100.0, 100.0), + 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..018c02c7 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,6142 +134,25 @@ 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 元素"; -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, Response> { - 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), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - PuzzleGalleryResponse { - items: items - .into_iter() - .map(|item| map_puzzle_work_summary_response(&state, item)) - .collect(), - }, - )) -} - -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), - }, - )) -} +mod handlers; +pub(crate) use self::handlers::*; mod mappers; -use mappers::*; +use self::mappers::*; -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()), - }) -} - -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, - }) -} - -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), - )), - } -} - -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) -} - -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": "拼图草稿缺少可编辑关卡", - })) - }) -} - -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()) -} - -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)), - } -} - -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), - }) -} - -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), - } -} - -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(), - } -} - -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}"), - })), - ) - }) -} - -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}")) -} - -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)] -struct PuzzleLevelNaming { - level_name: String, - work_description: Option, - work_tags: Vec, - 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, - } - } -} - -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) -} - -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 - } - } -} - -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) - )) -} - -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()) -} - -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)] -fn parse_puzzle_first_level_name_from_text(text: &str) -> Option { - parse_puzzle_level_naming_from_text(text).map(|naming| naming.level_name) -} - -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) -} - -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) -} - -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) -} - -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) -} - -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 -} - -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 - } -} - -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 - } -} - -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) - }) -} - -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)) -} - -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() -} - -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() -} - -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 -} - -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(); - } -} - -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)) -} - -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() -} - -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、按钮、按钮文字、数字、文字、水印、拼图碎片、完整拼图图像、教程浮层或角色手指。中央区域保持干净通透,方便运行态后续叠加默认拼图槽和正式拼图图块。" - ) -} - -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); -} - -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 -} - -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)) -} - -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, - })), - ) -} - -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()) -} - -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)), - } -} - -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)), - } -} - -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 -} - -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 -} - -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 -} - -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 -} - -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); -} - -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()), - }); - } -} - -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 -} - -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 -} +mod draft; +use self::draft::*; 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..d301c0b5 --- /dev/null +++ b/server-rs/crates/api-server/src/puzzle/draft.rs @@ -0,0 +1,1943 @@ +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 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, + &target_level.level_name, + &image_prompt, + reference_image_src, + true, + image_model, + 1, + target_level.candidates.len(), + ); + 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, + ); + // 中文注释:命名稳定后并行发起首关图与 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.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); + 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) = ui_background_result?; + 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 http_client = reqwest::Client::new(); + let uploaded_downloaded_image = + resolve_puzzle_reference_image_as_data_url(state, &http_client, uploaded_image_src) + .await + .map(PuzzleDownloadedImage::from_resolved_reference_image) + .map_err(|error| { + if error.status_code() == StatusCode::BAD_REQUEST { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "field": "referenceImageSrc", + "message": "关闭 AI 重绘时上传图必须是图片 Data URL 或历史生成图片路径。", + })) + } else { + error + } + })?; + 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, + ); + // 中文注释:关闭 AI 重绘时首关图不请求 VectorEngine;上传图直接成为首关正式图候选。 + let candidate_id = format!( + "{}-candidate-{}", + compiled_session.session_id, + target_level.candidates.len() + 1 + ); + 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 (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() { + 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 mut updated_levels = + build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src); + 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, + ); + // 中文注释:直用上传图时并行完成上传图持久化与 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(), + 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_metadata_to_session_snapshot( + mut session: PuzzleAgentSessionRecord, + target_level_id: &str, + metadata: &PuzzleLevelNaming, + previous_level_name: &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; + }; + + draft.levels[target_index].level_name = metadata.level_name.clone(); + if metadata.ui_background_prompt.is_some() { + draft.levels[target_index].ui_background_prompt = metadata.ui_background_prompt.clone(); + } + + if target_index == 0 { + apply_generated_puzzle_initial_metadata_to_draft( + draft, + metadata, + previous_level_name, + updated_at_micros, + ); + } else { + 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_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..a47c00b1 --- /dev/null +++ b/server-rs/crates/api-server/src/puzzle/handlers.rs @@ -0,0 +1,2044 @@ +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 { + "已编译首关草稿、并行生成首关画面和 UI 背景并写入正式草稿。" + } else { + "已编译首关草稿,并直接应用上传图片、生成 UI 背景为第一关图片。" + }, + 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 should_auto_name_level = payload + .should_auto_name_level + .unwrap_or_else(|| target_level.level_name.trim().is_empty()); + let mut generated_naming = if should_auto_name_level { + let naming = generate_puzzle_first_level_name( + &state, + target_level.picture_description.as_str(), + ) + .await; + target_level.level_name = naming.level_name.clone(); + target_level.ui_background_prompt = naming.ui_background_prompt.clone(); + Some(naming) + } else { + None + }; + 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 + .filter(|_| should_auto_name_level) + { + target_level.level_name = refined_naming.level_name.clone(); + if refined_naming.ui_background_prompt.is_some() { + target_level.ui_background_prompt = + refined_naming.ui_background_prompt.clone(); + } + generated_naming = Some(refined_naming); + } + 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); + let fallback_session = if should_auto_name_level { + 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, + ) + } else { + fallback_session + }; + let mut fallback_session = + apply_generated_puzzle_candidates_to_session_snapshot( + fallback_session, + target_level.level_id.as_str(), + candidates.into_records(), + primary_reference_image_src, + now, + ); + if let Some(generated_naming) = generated_naming.as_ref() { + fallback_session = + apply_generated_puzzle_metadata_to_session_snapshot( + fallback_session, + target_level.level_id.as_str(), + generated_naming, + fallback_level_name.as_str(), + now, + ); + } + Ok(fallback_session) + } + 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..6e6c91fd 100644 --- a/server-rs/crates/api-server/src/puzzle/mappers.rs +++ b/server-rs/crates/api-server/src/puzzle/mappers.rs @@ -96,6 +96,7 @@ pub(super) fn map_puzzle_form_draft_response( pub(super) fn map_puzzle_draft_level_response( level: PuzzleDraftLevelRecord, ) -> PuzzleDraftLevelResponse { + let generation_status = resolve_puzzle_level_generation_status(&level); PuzzleDraftLevelResponse { level_id: level.level_id, level_name: level.level_name, @@ -115,7 +116,7 @@ pub(super) fn map_puzzle_draft_level_response( 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, + generation_status, } } @@ -278,9 +279,120 @@ pub(super) fn map_puzzle_result_preview_finding_response( } } +fn resolve_puzzle_work_generation_status(item: &PuzzleWorkProfileRecord) -> Option { + let has_viewable_result = item + .cover_image_src + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + || item.levels.iter().any(has_puzzle_level_image); + if has_viewable_result { + return Some("ready".to_string()); + } + + item.levels + .iter() + .map(resolve_puzzle_level_generation_status) + .find(|status| status.as_str() == "generating") + .or_else(|| { + item.levels + .iter() + .map(resolve_puzzle_level_generation_status) + .find(|status| status.as_str() == "ready") + }) + .or_else(|| { + item.levels + .iter() + .map(resolve_puzzle_level_generation_status) + .find(|status| !status.is_empty()) + }) +} + +fn resolve_puzzle_level_generation_status(level: &PuzzleDraftLevelRecord) -> String { + if level.generation_status.trim() == "generating" && has_puzzle_level_image(level) { + return "ready".to_string(); + } + + level.generation_status.trim().to_string() +} + +fn has_puzzle_level_image(level: &PuzzleDraftLevelRecord) -> bool { + let has_cover = level + .cover_image_src + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()); + let has_selected_candidate = level + .selected_candidate_id + .as_deref() + .and_then(|candidate_id| { + level + .candidates + .iter() + .find(|candidate| candidate.candidate_id == candidate_id) + }) + .map(|candidate| candidate.image_src.trim()) + .is_some_and(|value| !value.is_empty()); + let has_fallback_candidate = level + .candidates + .last() + .map(|candidate| candidate.image_src.trim()) + .is_some_and(|value| !value.is_empty()); + + has_cover || has_selected_candidate || has_fallback_candidate +} + 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: item + .levels + .iter() + .map(|x| map_puzzle_draft_level_response(x.clone())) + .collect(), + } +} + +pub(super) fn map_puzzle_gallery_card_response( + state: &AppState, + item: PuzzleGalleryCardRecord, ) -> PuzzleWorkSummaryResponse { let author = resolve_work_author_by_user_id( state, @@ -316,6 +428,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..69425e82 --- /dev/null +++ b/server-rs/crates/api-server/src/puzzle/tests.rs @@ -0,0 +1,898 @@ +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), + should_auto_name_level: None, + 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_uploaded_cover_can_reuse_resolved_history_image() { + let resolved = PuzzleResolvedReferenceImage { + mime_type: "image/png".to_string(), + bytes_len: 8, + bytes: b"pngbytes".to_vec(), + }; + + let downloaded = PuzzleDownloadedImage::from_resolved_reference_image(resolved); + + assert_eq!(downloaded.extension, "png"); + assert_eq!(downloaded.mime_type, "image/png"); + assert_eq!(downloaded.bytes, b"pngbytes"); +} + +#[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), + should_auto_name_level: None, + 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: "generating".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].generation_status, "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..40383193 --- /dev/null +++ b/server-rs/crates/api-server/src/puzzle/vector_engine.rs @@ -0,0 +1,1283 @@ +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, +} + +impl PuzzleDownloadedImage { + pub(crate) fn from_resolved_reference_image(image: PuzzleResolvedReferenceImage) -> Self { + Self { + extension: puzzle_mime_to_extension(image.mime_type.as_str()).to_string(), + mime_type: normalize_puzzle_downloaded_image_mime_type(image.mime_type.as_str()), + bytes: image.bytes, + } + } +} + +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..adb24caf --- /dev/null +++ b/server-rs/crates/api-server/src/puzzle_gallery_cache.rs @@ -0,0 +1,252 @@ +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, OwnedMutexGuard, 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 read_stale_response(&self) -> Option { + let guard = self.inner.read().await; + let entry = guard.as_ref()?; + Some(PuzzleGalleryCachedResponse { + data_json: entry.data_json.clone(), + }) + } + + pub fn try_acquire_owned_rebuild_guard(&self) -> Option> { + self.rebuild_lock.clone().try_lock_owned().ok() + } + + 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); + } + + #[tokio::test] + async fn stale_response_remains_readable_after_fresh_ttl() { + let cache = PuzzleGalleryCache::new(); + let response = + build_puzzle_gallery_window_response((0..8).map(build_summary).collect::>()); + cache + .store_response(response) + .await + .expect("cache response should serialize"); + + { + let mut guard = cache.inner.write().await; + let entry = guard.as_mut().expect("cache entry should exist"); + entry.built_at = Instant::now() - PUZZLE_GALLERY_CACHE_TTL - Duration::from_secs(1); + } + + assert!(cache.read_fresh_response().await.is_none()); + assert!(cache.read_stale_response().await.is_some()); + } + + #[tokio::test] + async fn try_owned_rebuild_guard_allows_only_one_refresher() { + let cache = PuzzleGalleryCache::new(); + let first_guard = cache.try_acquire_owned_rebuild_guard(); + + assert!(first_guard.is_some()); + assert!(cache.try_acquire_owned_rebuild_guard().is_none()); + + drop(first_guard); + assert!(cache.try_acquire_owned_rebuild_guard().is_some()); + } +} diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index ee4b9a7e..9249e4e5 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -6,6 +6,7 @@ use std::{ time::{SystemTime, UNIX_EPOCH}, }; +use axum::extract::FromRef; use module_ai::{AiTaskService, InMemoryAiTaskStore}; use module_auth::{ AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService, @@ -27,20 +28,126 @@ 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::tracking_outbox::TrackingOutbox; 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, Copy, Debug, Eq, PartialEq)] +pub enum HttpRequestPermitPoolKind { + Default, + Gallery, + Detail, + Admin, +} + +impl HttpRequestPermitPoolKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Default => "default", + Self::Gallery => "gallery", + Self::Detail => "detail", + Self::Admin => "admin", + } + } +} + #[derive(Clone, Debug)] -pub struct AppState { +pub struct HttpRequestPermitPools { + default: Option>, + gallery: Option>, + detail: Option>, + admin: Option>, +} + +impl HttpRequestPermitPools { + fn from_config(config: &AppConfig) -> Self { + Self { + default: config + .max_concurrent_requests + .map(HttpRequestPermitPool::new) + .map(Arc::new), + gallery: config + .gallery_max_concurrent_requests + .map(HttpRequestPermitPool::new) + .map(Arc::new), + detail: config + .detail_max_concurrent_requests + .map(HttpRequestPermitPool::new) + .map(Arc::new), + admin: config + .admin_max_concurrent_requests + .map(HttpRequestPermitPool::new) + .map(Arc::new), + } + } + + pub fn pool( + &self, + kind: HttpRequestPermitPoolKind, + ) -> Option<(HttpRequestPermitPoolKind, Arc)> { + let selected = match kind { + HttpRequestPermitPoolKind::Default => self.default.clone(), + HttpRequestPermitPoolKind::Gallery => self.gallery.clone(), + HttpRequestPermitPoolKind::Detail => self.detail.clone(), + HttpRequestPermitPoolKind::Admin => self.admin.clone(), + }; + selected.map(|pool| (kind, pool)).or_else(|| { + self.default + .clone() + .map(|pool| (HttpRequestPermitPoolKind::Default, pool)) + }) + } +} + +#[derive(Clone, Debug)] +pub struct BackpressureState { + permit_pools: HttpRequestPermitPools, +} + +impl BackpressureState { + pub fn request_permit_pool( + &self, + kind: HttpRequestPermitPoolKind, + ) -> Option<(HttpRequestPermitPoolKind, Arc)> { + self.permit_pools.pool(kind) + } +} + +#[derive(Clone, Debug)] +pub struct AppState(Arc); + +impl std::ops::Deref for AppState { + type Target = AppStateInner; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl FromRef for BackpressureState { + fn from_ref(state: &AppState) -> Self { + Self { + permit_pools: state.http_request_permit_pools(), + } + } +} + +// Axum/Hyper 会在路由树和连接 service 上频繁 clone state;AppState 外层必须保持浅拷贝。 +#[derive(Debug)] +pub struct AppStateInner { // 配置会在后续中间件、路由和平台适配接入时逐步消费。 #[allow(dead_code)] pub config: AppConfig, + http_request_permit_pools: HttpRequestPermitPools, auth_jwt_config: JwtConfig, admin_runtime: Option, refresh_cookie_config: RefreshCookieConfig, @@ -60,6 +167,8 @@ pub struct AppState { #[cfg_attr(not(test), allow(dead_code))] ai_task_service: AiTaskService, spacetime_client: SpacetimeClient, + puzzle_gallery_cache: PuzzleGalleryCache, + tracking_outbox: Option>, llm_client: Option, creative_agent_gpt5_client: Option, creative_agent_executor: Arc, @@ -190,11 +299,14 @@ impl AppState { pool_size: config.spacetime_pool_size, procedure_timeout: config.spacetime_procedure_timeout, }); + let tracking_outbox = TrackingOutbox::from_config(&config, spacetime_client.clone()); let llm_client = build_llm_client(&config)?; let creative_agent_gpt5_client = build_creative_agent_gpt5_client(&config)?; + let http_request_permit_pools = HttpRequestPermitPools::from_config(&config); - Ok(Self { + Ok(Self(Arc::new(AppStateInner { config, + http_request_permit_pools, auth_jwt_config, admin_runtime, refresh_cookie_config, @@ -214,13 +326,15 @@ impl AppState { wechat_pay_client, ai_task_service, spacetime_client, + puzzle_gallery_cache: PuzzleGalleryCache::new(), + tracking_outbox, llm_client, creative_agent_gpt5_client, creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor), creative_agent_sessions: Arc::new(Mutex::new(HashMap::new())), #[cfg(test)] test_runtime_snapshot_store: Arc::new(Mutex::new(HashMap::new())), - }) + }))) } pub fn auth_jwt_config(&self) -> &JwtConfig { @@ -235,6 +349,10 @@ impl AppState { &self.refresh_cookie_config } + pub fn http_request_permit_pools(&self) -> HttpRequestPermitPools { + self.http_request_permit_pools.clone() + } + pub async fn upsert_creation_entry_type_config( &self, input: module_runtime::CreationEntryTypeAdminUpsertInput, @@ -464,6 +582,14 @@ impl AppState { &self.spacetime_client } + pub fn puzzle_gallery_cache(&self) -> &PuzzleGalleryCache { + &self.puzzle_gallery_cache + } + + pub fn tracking_outbox(&self) -> Option> { + self.tracking_outbox.clone() + } + 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..8c217634 --- /dev/null +++ b/server-rs/crates/api-server/src/telemetry.rs @@ -0,0 +1,512 @@ +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, HttpRequestPermitPoolKind}, +}; + +static HTTP_RESPONSE_BODY_IN_FLIGHT: AtomicI64 = AtomicI64::new(0); +static TRACKING_OUTBOX_PENDING_BYTES: AtomicI64 = AtomicI64::new(0); +static TRACKING_OUTBOX_PENDING_FILES: 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( + pool: HttpRequestPermitPoolKind, + available: usize, +) { + HTTP_REQUEST_PERMITS_AVAILABLE + .get_or_init(register_http_request_permits_available_metric) + .store(pool, available); +} + +pub(crate) fn record_puzzle_gallery_cache_hit() { + puzzle_gallery_cache_metrics().hits.add(1, &[]); +} + +pub(crate) fn record_puzzle_gallery_cache_stale_hit() { + puzzle_gallery_cache_metrics().stale_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_refresh_started() { + puzzle_gallery_cache_metrics().refreshes_started.add(1, &[]); +} + +pub(crate) fn record_puzzle_gallery_cache_refresh_failed() { + puzzle_gallery_cache_metrics().refreshes_failed.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, &[]); +} + +pub(crate) fn record_tracking_outbox_enqueued() { + tracking_outbox_metrics().enqueued.add(1, &[]); +} + +pub(crate) fn record_tracking_outbox_dropped(reason: &'static str) { + tracking_outbox_metrics() + .dropped + .add(1, &[KeyValue::new("reason", reason)]); +} + +pub(crate) fn record_tracking_outbox_sealed(reason: &'static str) { + tracking_outbox_metrics() + .sealed_files + .add(1, &[KeyValue::new("reason", reason)]); +} + +pub(crate) fn record_tracking_outbox_corrupt_file() { + tracking_outbox_metrics().corrupt_files.add(1, &[]); +} + +pub(crate) fn record_tracking_outbox_flush( + duration: std::time::Duration, + accepted_count: u32, + file_bytes: u64, + failed: bool, +) { + let status_class = if failed { "error" } else { "ok" }; + let labels = [KeyValue::new("status_class", status_class)]; + let metrics = tracking_outbox_metrics(); + metrics.flushes.add(1, &labels); + metrics + .flush_duration + .record(duration.as_secs_f64(), &labels); + metrics + .flushed_events + .add(u64::from(accepted_count), &labels); + metrics.flushed_bytes.add(file_bytes, &labels); +} + +pub(crate) fn update_tracking_outbox_pending_bytes(bytes: u64) { + TRACKING_OUTBOX_PENDING_BYTES.store(bytes.min(i64::MAX as u64) as i64, Ordering::Relaxed); +} + +pub(crate) fn update_tracking_outbox_pending_files(files: usize) { + TRACKING_OUTBOX_PENDING_FILES.store(files.min(i64::MAX as usize) as i64, Ordering::Relaxed); +} + +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, + stale_hits: Counter, + misses: Counter, + refreshes_started: Counter, + refreshes_failed: Counter, + rebuilds: Counter, + rebuild_duration: opentelemetry::metrics::Histogram, + data_json_bytes: opentelemetry::metrics::Histogram, +} + +struct TrackingOutboxMetrics { + enqueued: Counter, + dropped: Counter, + sealed_files: Counter, + corrupt_files: Counter, + flushes: Counter, + flush_duration: opentelemetry::metrics::Histogram, + flushed_events: Counter, + flushed_bytes: Counter, +} + +struct HttpRequestPermitsAvailableGauges { + default: Arc, + gallery: Arc, + detail: Arc, + admin: Arc, +} + +impl HttpRequestPermitsAvailableGauges { + fn new() -> Self { + Self { + default: Arc::new(AtomicI64::new(0)), + gallery: Arc::new(AtomicI64::new(0)), + detail: Arc::new(AtomicI64::new(0)), + admin: Arc::new(AtomicI64::new(0)), + } + } + + fn store(&self, pool: HttpRequestPermitPoolKind, available: usize) { + let value = available.min(i64::MAX as usize) as i64; + match pool { + HttpRequestPermitPoolKind::Default => &self.default, + HttpRequestPermitPoolKind::Gallery => &self.gallery, + HttpRequestPermitPoolKind::Detail => &self.detail, + HttpRequestPermitPoolKind::Admin => &self.admin, + } + .store(value, Ordering::Relaxed); + } +} + +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(), + stale_hits: meter + .u64_counter("genarrative.puzzle_gallery.cache.stale_hits") + .with_description("Puzzle gallery stale response cache hits") + .build(), + misses: meter + .u64_counter("genarrative.puzzle_gallery.cache.misses") + .with_description("Puzzle gallery response cache misses") + .build(), + refreshes_started: meter + .u64_counter("genarrative.puzzle_gallery.cache.refreshes_started") + .with_description("Puzzle gallery background refresh start count") + .build(), + refreshes_failed: meter + .u64_counter("genarrative.puzzle_gallery.cache.refreshes_failed") + .with_description("Puzzle gallery background refresh failure count") + .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 tracking_outbox_metrics() -> &'static TrackingOutboxMetrics { + static METRICS: std::sync::OnceLock = std::sync::OnceLock::new(); + METRICS.get_or_init(|| { + let meter = global::meter("genarrative-api"); + TrackingOutboxMetrics { + enqueued: meter + .u64_counter("genarrative.tracking_outbox.events.enqueued") + .with_description("Tracking events appended to the local outbox") + .build(), + dropped: meter + .u64_counter("genarrative.tracking_outbox.events.dropped") + .with_description("Tracking events dropped by local outbox protection") + .build(), + sealed_files: meter + .u64_counter("genarrative.tracking_outbox.files.sealed") + .with_description("Tracking outbox active files sealed for flushing") + .build(), + corrupt_files: meter + .u64_counter("genarrative.tracking_outbox.files.corrupt") + .with_description( + "Tracking outbox sealed files quarantined because they could not be parsed", + ) + .build(), + flushes: meter + .u64_counter("genarrative.tracking_outbox.flushes") + .with_description("Tracking outbox sealed file flush attempts") + .build(), + flush_duration: meter + .f64_histogram("genarrative.tracking_outbox.flush.duration") + .with_unit("s") + .with_description("Tracking outbox sealed file flush duration") + .build(), + flushed_events: meter + .u64_counter("genarrative.tracking_outbox.events.flushed") + .with_description("Tracking events accepted by SpacetimeDB batch procedure") + .build(), + flushed_bytes: meter + .u64_counter("genarrative.tracking_outbox.bytes.flushed") + .with_unit("By") + .with_description("Tracking outbox bytes removed after successful flush") + .build(), + } + }) +} + +fn register_http_request_permits_available_metric() -> HttpRequestPermitsAvailableGauges { + let gauges = HttpRequestPermitsAvailableGauges::new(); + let meter = global::meter("genarrative-api"); + let default_gauge = gauges.default.clone(); + let gallery_gauge = gauges.gallery.clone(); + let detail_gauge = gauges.detail.clone(); + let admin_gauge = gauges.admin.clone(); + 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( + default_gauge.load(Ordering::Relaxed), + &[KeyValue::new( + "pool", + HttpRequestPermitPoolKind::Default.as_str(), + )], + ); + observer.observe( + gallery_gauge.load(Ordering::Relaxed), + &[KeyValue::new( + "pool", + HttpRequestPermitPoolKind::Gallery.as_str(), + )], + ); + observer.observe( + detail_gauge.load(Ordering::Relaxed), + &[KeyValue::new( + "pool", + HttpRequestPermitPoolKind::Detail.as_str(), + )], + ); + observer.observe( + admin_gauge.load(Ordering::Relaxed), + &[KeyValue::new( + "pool", + HttpRequestPermitPoolKind::Admin.as_str(), + )], + ); + }) + .build(); + gauges +} + +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(); + meter + .i64_observable_up_down_counter("genarrative.tracking_outbox.pending.bytes") + .with_unit("By") + .with_description("Tracking outbox bytes waiting on local disk") + .with_callback(|observer| { + observer.observe(TRACKING_OUTBOX_PENDING_BYTES.load(Ordering::Relaxed), &[]); + }) + .build(); + meter + .i64_observable_up_down_counter("genarrative.tracking_outbox.pending.files") + .with_unit("{file}") + .with_description("Tracking outbox sealed files waiting for flush") + .with_callback(|observer| { + observer.observe(TRACKING_OUTBOX_PENDING_FILES.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/api-server/src/tracking.rs b/server-rs/crates/api-server/src/tracking.rs index 0f3aad21..ad3b187c 100644 --- a/server-rs/crates/api-server/src/tracking.rs +++ b/server-rs/crates/api-server/src/tracking.rs @@ -85,7 +85,7 @@ pub async fn record_route_tracking_event_after_success( draft.owner_user_id = draft.user_id.clone(); } - record_tracking_event_after_success(state, request_context, draft).await; + record_route_tracking_event_via_outbox_after_success(state, request_context, draft).await; } fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option { @@ -524,26 +524,101 @@ pub async fn record_tracking_event_after_success( request_context: &RequestContext, draft: TrackingEventDraft, ) { - let occurred_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; - let event_id = build_tracking_event_id(&draft, occurred_at_micros); - let event_key = draft.event_key.to_string(); - let scope_kind = draft.scope_kind; - let scope_id = draft.scope_id; - let metadata_json = draft.metadata.to_string(); + record_tracking_event_input_after_success( + state, + request_context, + build_tracking_event_input(draft), + ) + .await; +} + +async fn record_route_tracking_event_via_outbox_after_success( + state: &AppState, + request_context: &RequestContext, + draft: TrackingEventDraft, +) { + let event = build_tracking_event_input(draft); + let event_key = event.event_key.clone(); + let scope_kind = event.scope_kind; + let scope_id = event.scope_id.clone(); + + if let Some(outbox) = state.tracking_outbox() { + match outbox.enqueue(event.clone()).await { + Ok(crate::tracking_outbox::TrackingOutboxEnqueueOutcome::Enqueued) => { + tracing::debug!( + request_id = request_context.request_id(), + operation = request_context.operation(), + event_key = %event_key, + scope_kind = %scope_kind.as_str(), + scope_id = %scope_id, + "后端 route 埋点已写入本机 outbox" + ); + return; + } + Ok(crate::tracking_outbox::TrackingOutboxEnqueueOutcome::Dropped { reason }) => { + tracing::warn!( + request_id = request_context.request_id(), + operation = request_context.operation(), + event_key = %event_key, + scope_kind = %scope_kind.as_str(), + scope_id = %scope_id, + reason, + "后端 route 埋点因 outbox 保护阈值被丢弃,主业务流程继续" + ); + return; + } + Err(error) => { + tracing::warn!( + request_id = request_context.request_id(), + operation = request_context.operation(), + event_key = %event_key, + scope_kind = %scope_kind.as_str(), + scope_id = %scope_id, + error = %error, + "后端 route 埋点写入 outbox 失败,回退同步直写 SpacetimeDB" + ); + } + } + } + + record_tracking_event_input_after_success(state, request_context, event).await; +} + +async fn record_tracking_event_input_after_success( + state: &AppState, + request_context: &RequestContext, + event: module_runtime::RuntimeTrackingEventInput, +) { + let event_key = event.event_key.clone(); + let log_scope_kind = event.scope_kind; + let scope_id = event.scope_id.clone(); + + let module_runtime::RuntimeTrackingEventInput { + event_id, + event_key: procedure_event_key, + scope_kind: procedure_scope_kind, + scope_id: procedure_scope_id, + user_id, + owner_user_id, + profile_id, + module_key, + metadata_json, + occurred_at_micros, + } = event; match state .spacetime_client() .record_tracking_event( event_id, - event_key.clone(), - scope_kind, - scope_id.clone(), - draft.user_id, - draft.owner_user_id, - draft.profile_id, - draft.module_key.map(str::to_string), + procedure_event_key, + procedure_scope_kind, + procedure_scope_id, + user_id, + owner_user_id, + profile_id, + module_key, metadata_json, - occurred_at_micros as i64, + occurred_at_micros, ) .await { @@ -551,7 +626,7 @@ pub async fn record_tracking_event_after_success( request_id = request_context.request_id(), operation = request_context.operation(), event_key = %event_key, - scope_kind = %scope_kind.as_str(), + scope_kind = %log_scope_kind.as_str(), scope_id = %scope_id, "后端埋点已记录" ), @@ -559,7 +634,7 @@ pub async fn record_tracking_event_after_success( request_id = request_context.request_id(), operation = request_context.operation(), event_key = %event_key, - scope_kind = %scope_kind.as_str(), + scope_kind = %log_scope_kind.as_str(), scope_id = %scope_id, error = %error, "后端埋点记录失败,主业务流程继续" @@ -567,6 +642,26 @@ pub async fn record_tracking_event_after_success( } } +fn build_tracking_event_input( + draft: TrackingEventDraft, +) -> module_runtime::RuntimeTrackingEventInput { + let occurred_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; + let event_id = build_tracking_event_id(&draft, occurred_at_micros); + + module_runtime::RuntimeTrackingEventInput { + event_id, + event_key: draft.event_key.to_string(), + scope_kind: draft.scope_kind, + scope_id: draft.scope_id, + user_id: draft.user_id, + owner_user_id: draft.owner_user_id, + profile_id: draft.profile_id, + module_key: draft.module_key.map(str::to_string), + metadata_json: draft.metadata.to_string(), + occurred_at_micros: occurred_at_micros as i64, + } +} + fn build_tracking_event_id(draft: &TrackingEventDraft, occurred_at_micros: i128) -> String { if draft.event_key == "daily_login" && draft.scope_kind == RuntimeTrackingScopeKind::User diff --git a/server-rs/crates/api-server/src/tracking_outbox.rs b/server-rs/crates/api-server/src/tracking_outbox.rs new file mode 100644 index 00000000..cf2b4a97 --- /dev/null +++ b/server-rs/crates/api-server/src/tracking_outbox.rs @@ -0,0 +1,621 @@ +use std::{ + fmt, + path::{Path, PathBuf}, + sync::Arc, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +use module_runtime::RuntimeTrackingEventInput; +use serde::{Deserialize, Serialize}; +use spacetime_client::{SpacetimeClient, SpacetimeClientError}; +use tokio::{ + fs::{self, File, OpenOptions}, + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + sync::{Mutex, Notify}, + time::sleep, +}; +use tracing::{debug, warn}; + +use crate::config::AppConfig; + +const ACTIVE_FILE_NAME: &str = "active.ndjson"; +const SEALED_FILE_PREFIX: &str = "sealed-"; +const CORRUPT_FILE_PREFIX: &str = "corrupt-"; +const SEALED_FILE_EXTENSION: &str = ".ndjson"; + +#[derive(Clone)] +pub struct TrackingOutbox { + dir: PathBuf, + batch_size: usize, + flush_interval: Duration, + max_bytes: u64, + spacetime_client: SpacetimeClient, + inner: Arc>, + flush_notify: Arc, +} + +struct TrackingOutboxInner { + initialized: bool, + active_file: Option, + active_count: usize, + active_bytes: u64, + total_bytes: u64, + last_sealed_at: Instant, +} + +#[derive(Debug)] +pub enum TrackingOutboxEnqueueOutcome { + Enqueued, + Dropped { reason: &'static str }, +} + +#[derive(Debug)] +pub enum TrackingOutboxError { + Io(std::io::Error), + Json(serde_json::Error), + Spacetime(SpacetimeClientError), +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct TrackingOutboxRecord { + event: RuntimeTrackingEventInput, +} + +impl TrackingOutbox { + pub fn from_config(config: &AppConfig, spacetime_client: SpacetimeClient) -> Option> { + if !config.tracking_outbox_enabled { + return None; + } + + let total_bytes = directory_size_if_exists(&config.tracking_outbox_dir).unwrap_or(0); + let outbox = Self { + dir: config.tracking_outbox_dir.clone(), + batch_size: config.tracking_outbox_batch_size.max(1), + flush_interval: config.tracking_outbox_flush_interval, + max_bytes: config.tracking_outbox_max_bytes, + spacetime_client, + inner: Arc::new(Mutex::new(TrackingOutboxInner { + initialized: false, + active_file: None, + active_count: 0, + active_bytes: 0, + total_bytes, + last_sealed_at: Instant::now(), + })), + flush_notify: Arc::new(Notify::new()), + }; + crate::telemetry::update_tracking_outbox_pending_bytes(total_bytes); + Some(Arc::new(outbox)) + } + + pub async fn enqueue( + &self, + event: RuntimeTrackingEventInput, + ) -> Result { + let record = TrackingOutboxRecord { event }; + let mut line = serde_json::to_vec(&record)?; + line.push(b'\n'); + let line_bytes = line.len().min(u64::MAX as usize) as u64; + + let mut inner = self.inner.lock().await; + self.ensure_initialized_locked(&mut inner).await?; + + if inner.total_bytes.saturating_add(line_bytes) > self.max_bytes { + crate::telemetry::record_tracking_outbox_dropped("max_bytes"); + return Ok(TrackingOutboxEnqueueOutcome::Dropped { + reason: "max_bytes", + }); + } + + let active_path = self.active_path(); + if inner.active_file.is_none() { + inner.active_file = Some( + OpenOptions::new() + .create(true) + .append(true) + .open(&active_path) + .await?, + ); + } + + let file = inner + .active_file + .as_mut() + .expect("active file should be open before append"); + file.write_all(&line).await?; + inner.active_count = inner.active_count.saturating_add(1); + inner.active_bytes = inner.active_bytes.saturating_add(line_bytes); + inner.total_bytes = inner.total_bytes.saturating_add(line_bytes); + crate::telemetry::record_tracking_outbox_enqueued(); + crate::telemetry::update_tracking_outbox_pending_bytes(inner.total_bytes); + + if inner.active_count >= self.batch_size { + self.seal_active_locked(&mut inner, "batch_size").await?; + self.flush_notify.notify_one(); + } + + Ok(TrackingOutboxEnqueueOutcome::Enqueued) + } + + pub fn spawn_worker(self: Arc) { + tokio::spawn(async move { + loop { + tokio::select! { + _ = sleep(self.flush_interval) => { + if let Err(error) = self.seal_active_if_due().await { + warn!(error = %error, "tracking outbox 定时封存 active 文件失败"); + } + if let Err(error) = self.flush_sealed_files_once().await { + warn!(error = %error, "tracking outbox 批量写入 SpacetimeDB 失败,将保留 sealed 文件等待重试"); + } + } + _ = self.flush_notify.notified() => { + if let Err(error) = self.flush_sealed_files_once().await { + warn!(error = %error, "tracking outbox 批量写入 SpacetimeDB 失败,将保留 sealed 文件等待重试"); + } + } + } + } + }); + } + + async fn seal_active_if_due(&self) -> Result<(), TrackingOutboxError> { + let mut inner = self.inner.lock().await; + self.ensure_initialized_locked(&mut inner).await?; + if inner.active_count == 0 || inner.last_sealed_at.elapsed() < self.flush_interval { + return Ok(()); + } + + self.seal_active_locked(&mut inner, "flush_interval").await + } + + async fn flush_sealed_files_once(&self) -> Result<(), TrackingOutboxError> { + self.ensure_initialized().await?; + + let sealed_files = self.list_sealed_files().await?; + crate::telemetry::update_tracking_outbox_pending_files(sealed_files.len()); + for path in sealed_files { + let started_at = Instant::now(); + let metadata = fs::metadata(&path).await?; + let file_bytes = metadata.len(); + let events = match read_outbox_events(&path).await { + Ok(events) => events, + Err(error) if error.is_data_corruption() => { + let corrupt_path = self.corrupt_path_for(&path); + fs::rename(&path, &corrupt_path).await?; + self.subtract_total_bytes(file_bytes).await; + crate::telemetry::record_tracking_outbox_corrupt_file(); + warn!( + error = %error, + source = %path.display(), + target = %corrupt_path.display(), + "tracking outbox sealed 文件含无法解析的记录,已隔离并继续处理后续文件" + ); + continue; + } + Err(error) => return Err(error), + }; + if events.is_empty() { + fs::remove_file(&path).await?; + self.subtract_total_bytes(file_bytes).await; + continue; + } + + match self.spacetime_client.record_tracking_events(events).await { + Ok(accepted_count) => { + fs::remove_file(&path).await?; + self.subtract_total_bytes(file_bytes).await; + crate::telemetry::record_tracking_outbox_flush( + started_at.elapsed(), + accepted_count, + file_bytes, + false, + ); + debug!( + accepted_count, + file_bytes, + path = %path.display(), + "tracking outbox sealed 文件已批量入库并删除" + ); + } + Err(error) => { + crate::telemetry::record_tracking_outbox_flush( + started_at.elapsed(), + 0, + file_bytes, + true, + ); + return Err(TrackingOutboxError::Spacetime(error)); + } + } + } + + Ok(()) + } + + async fn ensure_initialized(&self) -> Result<(), TrackingOutboxError> { + let mut inner = self.inner.lock().await; + self.ensure_initialized_locked(&mut inner).await + } + + async fn ensure_initialized_locked( + &self, + inner: &mut TrackingOutboxInner, + ) -> Result<(), TrackingOutboxError> { + if inner.initialized { + return Ok(()); + } + + fs::create_dir_all(&self.dir).await?; + self.seal_existing_active_file().await?; + inner.total_bytes = directory_size(&self.dir).await?; + inner.initialized = true; + inner.last_sealed_at = Instant::now(); + crate::telemetry::update_tracking_outbox_pending_bytes(inner.total_bytes); + Ok(()) + } + + async fn seal_active_locked( + &self, + inner: &mut TrackingOutboxInner, + reason: &'static str, + ) -> Result<(), TrackingOutboxError> { + if inner.active_count == 0 && inner.active_bytes == 0 { + return Ok(()); + } + + if let Some(mut file) = inner.active_file.take() { + file.flush().await?; + file.sync_data().await?; + drop(file); + } + + let active_path = self.active_path(); + match fs::metadata(&active_path).await { + Ok(metadata) if metadata.len() > 0 => { + let sealed_path = self.next_sealed_path(); + fs::rename(&active_path, &sealed_path).await?; + crate::telemetry::record_tracking_outbox_sealed(reason); + debug!( + reason, + event_count = inner.active_count, + file_bytes = metadata.len(), + path = %sealed_path.display(), + "tracking outbox active 文件已封存" + ); + } + Ok(_) => { + let _ = fs::remove_file(&active_path).await; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + + inner.active_count = 0; + inner.active_bytes = 0; + inner.last_sealed_at = Instant::now(); + Ok(()) + } + + async fn seal_existing_active_file(&self) -> Result<(), TrackingOutboxError> { + let active_path = self.active_path(); + match fs::metadata(&active_path).await { + Ok(metadata) if metadata.len() > 0 => { + fs::rename(&active_path, self.next_sealed_path()).await?; + crate::telemetry::record_tracking_outbox_sealed("startup"); + } + Ok(_) => { + let _ = fs::remove_file(&active_path).await; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + Ok(()) + } + + async fn list_sealed_files(&self) -> Result, TrackingOutboxError> { + let mut entries = fs::read_dir(&self.dir).await?; + let mut files = Vec::new(); + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|value| value.to_str()) else { + continue; + }; + if name.starts_with(SEALED_FILE_PREFIX) && name.ends_with(SEALED_FILE_EXTENSION) { + files.push(path); + } + } + files.sort(); + Ok(files) + } + + async fn subtract_total_bytes(&self, bytes: u64) { + let mut inner = self.inner.lock().await; + inner.total_bytes = inner.total_bytes.saturating_sub(bytes); + crate::telemetry::update_tracking_outbox_pending_bytes(inner.total_bytes); + } + + fn active_path(&self) -> PathBuf { + self.dir.join(ACTIVE_FILE_NAME) + } + + fn next_sealed_path(&self) -> PathBuf { + self.dir.join(format!( + "{SEALED_FILE_PREFIX}{}-{uuid}{SEALED_FILE_EXTENSION}", + current_unix_micros(), + uuid = uuid::Uuid::new_v4() + )) + } + + fn corrupt_path_for(&self, path: &Path) -> PathBuf { + let name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("unknown.ndjson"); + self.dir.join(format!( + "{CORRUPT_FILE_PREFIX}{}-{uuid}-{name}", + current_unix_micros(), + uuid = uuid::Uuid::new_v4() + )) + } +} + +impl fmt::Debug for TrackingOutbox { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TrackingOutbox") + .field("dir", &self.dir) + .field("batch_size", &self.batch_size) + .field("flush_interval", &self.flush_interval) + .field("max_bytes", &self.max_bytes) + .finish() + } +} + +impl fmt::Display for TrackingOutboxError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(error) => write!(f, "{error}"), + Self::Json(error) => write!(f, "{error}"), + Self::Spacetime(error) => write!(f, "{error}"), + } + } +} + +impl From for TrackingOutboxError { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} + +impl From for TrackingOutboxError { + fn from(value: serde_json::Error) -> Self { + Self::Json(value) + } +} + +impl TrackingOutboxError { + fn is_data_corruption(&self) -> bool { + matches!(self, Self::Json(_)) + } +} + +async fn read_outbox_events( + path: &Path, +) -> Result, TrackingOutboxError> { + let file = File::open(path).await?; + let mut lines = BufReader::new(file).lines(); + let mut events = Vec::new(); + while let Some(line) = lines.next_line().await? { + if line.trim().is_empty() { + continue; + } + let record = serde_json::from_str::(&line)?; + events.push(record.event); + } + Ok(events) +} + +async fn directory_size(path: &Path) -> Result { + let mut total = 0u64; + let mut entries = fs::read_dir(path).await?; + while let Some(entry) = entries.next_entry().await? { + if !is_pending_outbox_file_name(&entry.file_name()) { + continue; + } + let metadata = entry.metadata().await?; + if metadata.is_file() { + total = total.saturating_add(metadata.len()); + } + } + Ok(total) +} + +fn directory_size_if_exists(path: &Path) -> Result { + if !path.is_dir() { + return Ok(0); + } + + let mut total = 0u64; + for entry in std::fs::read_dir(path)? { + let entry = entry?; + if !is_pending_outbox_file_name(&entry.file_name()) { + continue; + } + let metadata = entry.metadata()?; + if metadata.is_file() { + total = total.saturating_add(metadata.len()); + } + } + Ok(total) +} + +fn current_unix_micros() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_micros() +} + +fn is_pending_outbox_file_name(name: &std::ffi::OsStr) -> bool { + name.to_str().is_some_and(|value| { + value == ACTIVE_FILE_NAME + || (value.starts_with(SEALED_FILE_PREFIX) && value.ends_with(SEALED_FILE_EXTENSION)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_event(event_id: &str) -> RuntimeTrackingEventInput { + RuntimeTrackingEventInput { + event_id: event_id.to_string(), + event_key: "puzzle_route_success".to_string(), + scope_kind: module_runtime::RuntimeTrackingScopeKind::Site, + scope_id: "site".to_string(), + user_id: None, + owner_user_id: None, + profile_id: None, + module_key: Some("puzzle".to_string()), + metadata_json: "{}".to_string(), + occurred_at_micros: 1_713_680_000_000_000, + } + } + + fn test_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "genarrative-tracking-outbox-{name}-{}", + current_unix_micros() + )); + let _ = std::fs::remove_dir_all(&dir); + dir + } + + fn test_outbox(dir: PathBuf, batch_size: usize, max_bytes: u64) -> Arc { + let config = AppConfig { + tracking_outbox_dir: dir, + tracking_outbox_batch_size: batch_size, + tracking_outbox_max_bytes: max_bytes, + tracking_outbox_flush_interval: Duration::from_secs(60), + ..AppConfig::default() + }; + TrackingOutbox::from_config( + &config, + SpacetimeClient::new(spacetime_client::SpacetimeClientConfig { + server_url: "http://127.0.0.1:1".to_string(), + database: "missing".to_string(), + token: None, + pool_size: 1, + procedure_timeout: Duration::from_millis(10), + }), + ) + .expect("outbox should be enabled") + } + + #[tokio::test] + async fn enqueue_seals_active_file_when_batch_size_reached_and_rotates_active() { + let dir = test_dir("batch"); + let outbox = test_outbox(dir.clone(), 2, 1024 * 1024); + + outbox.enqueue(sample_event("event-1")).await.unwrap(); + outbox.enqueue(sample_event("event-2")).await.unwrap(); + + assert!(!dir.join(ACTIVE_FILE_NAME).exists()); + let sealed_count = std::fs::read_dir(&dir) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with(SEALED_FILE_PREFIX)) + }) + .count(); + assert_eq!(sealed_count, 1); + + outbox.enqueue(sample_event("event-3")).await.unwrap(); + + let active_contents = std::fs::read_to_string(dir.join(ACTIVE_FILE_NAME)).unwrap(); + assert!(active_contents.contains("event-3")); + let sealed_count_after_rotate = std::fs::read_dir(&dir) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with(SEALED_FILE_PREFIX)) + }) + .count(); + assert_eq!(sealed_count_after_rotate, 1); + + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn enqueue_drops_when_outbox_exceeds_max_bytes() { + let dir = test_dir("max-bytes"); + let outbox = test_outbox(dir.clone(), 500, 1); + + let outcome = outbox.enqueue(sample_event("event-1")).await.unwrap(); + + assert!(matches!( + outcome, + TrackingOutboxEnqueueOutcome::Dropped { + reason: "max_bytes" + } + )); + assert!(!dir.join(ACTIVE_FILE_NAME).exists()); + + let _ = std::fs::remove_dir_all(dir); + } + + #[tokio::test] + async fn flush_quarantines_corrupt_sealed_file() { + let dir = test_dir("corrupt"); + std::fs::create_dir_all(&dir).unwrap(); + let sealed_path = dir.join(format!("{SEALED_FILE_PREFIX}bad{SEALED_FILE_EXTENSION}")); + std::fs::write(&sealed_path, b"{not-json}\n").unwrap(); + let outbox = test_outbox(dir.clone(), 500, 1024 * 1024); + + outbox.flush_sealed_files_once().await.unwrap(); + + assert!(!sealed_path.exists()); + let corrupt_count = std::fs::read_dir(&dir) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with(CORRUPT_FILE_PREFIX)) + }) + .count(); + assert_eq!(corrupt_count, 1); + + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn directory_size_excludes_quarantined_corrupt_files() { + let dir = test_dir("directory-size"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(ACTIVE_FILE_NAME), b"active").unwrap(); + std::fs::write( + dir.join(format!("{SEALED_FILE_PREFIX}one{SEALED_FILE_EXTENSION}")), + b"sealed", + ) + .unwrap(); + std::fs::write( + dir.join(format!("{CORRUPT_FILE_PREFIX}one{SEALED_FILE_EXTENSION}")), + b"corrupt", + ) + .unwrap(); + + let total = directory_size_if_exists(&dir).unwrap(); + + assert_eq!(total, 12); + + let _ = std::fs::remove_dir_all(dir); + } +} 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..da2f52de 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( @@ -2069,6 +2061,7 @@ fn map_sms_provider_error_to_phone_error(error: SmsProviderError) -> PhoneAuthEr SmsProviderError::InvalidConfig(message) => { PhoneAuthError::SmsProviderInvalidConfig(message) } + SmsProviderError::InvalidVerifyCode => PhoneAuthError::InvalidVerifyCode, SmsProviderError::Upstream(message) => PhoneAuthError::SmsProviderUpstream(message), } } @@ -2139,6 +2132,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 +2678,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-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index a10f0cc2..4d1da0bc 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -706,6 +706,14 @@ pub struct RuntimeTrackingEventProcedureResult { pub error_message: Option, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeTrackingEventBatchProcedureResult { + pub ok: bool, + pub accepted_count: u32, + pub error_message: Option, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileTaskConfigSnapshot { 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_agent.rs b/server-rs/crates/shared-contracts/src/puzzle_agent.rs index 32f79da4..aec6e3e9 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_agent.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_agent.rs @@ -49,6 +49,8 @@ pub struct ExecutePuzzleAgentActionRequest { #[serde(default)] pub candidate_count: Option, #[serde(default)] + pub should_auto_name_level: Option, + #[serde(default)] pub candidate_id: Option, #[serde(default)] pub level_id: Option, 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..5f8af651 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_batch_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..1a96e2c2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/mapper/runtime.rs @@ -0,0 +1,467 @@ +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, + } + } +} + +impl From for RuntimeTrackingEventInput { + fn from(input: module_runtime::RuntimeTrackingEventInput) -> Self { + Self { + event_id: input.event_id, + event_key: input.event_key, + scope_kind: map_runtime_tracking_scope_kind(input.scope_kind), + scope_id: input.scope_id, + user_id: input.user_id, + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + module_key: input.module_key, + metadata_json: input.metadata_json, + occurred_at_micros: input.occurred_at_micros, + } + } +} + +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_tracking_event_batch_procedure_result( + result: RuntimeTrackingEventBatchProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(result.accepted_count) +} + +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..6a53dc72 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; @@ -578,6 +624,7 @@ pub mod record_custom_world_profile_play_procedure; pub mod record_daily_login_tracking_event_and_return_procedure; pub mod record_puzzle_work_like_procedure; pub mod record_tracking_event_and_return_procedure; +pub mod record_tracking_events_and_return_procedure; pub mod record_visual_novel_runtime_event_procedure; pub mod redeem_profile_referral_invite_code_procedure; pub mod redeem_profile_reward_code_procedure; @@ -718,6 +765,7 @@ pub mod runtime_snapshot_row_type; pub mod runtime_snapshot_table; pub mod runtime_snapshot_type; pub mod runtime_snapshot_upsert_input_type; +pub mod runtime_tracking_event_batch_procedure_result_type; pub mod runtime_tracking_event_input_type; pub mod runtime_tracking_event_procedure_result_type; pub mod runtime_tracking_scope_kind_type; @@ -728,30 +776,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 +889,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 +927,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 +1021,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 +1034,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 +1078,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 +1090,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 +1336,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 +1443,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 +1508,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; @@ -1433,6 +1550,7 @@ pub use record_custom_world_profile_play_procedure::record_custom_world_profile_ pub use record_daily_login_tracking_event_and_return_procedure::record_daily_login_tracking_event_and_return; pub use record_puzzle_work_like_procedure::record_puzzle_work_like; pub use record_tracking_event_and_return_procedure::record_tracking_event_and_return; +pub use record_tracking_events_and_return_procedure::record_tracking_events_and_return; pub use record_visual_novel_runtime_event_procedure::record_visual_novel_runtime_event; pub use redeem_profile_referral_invite_code_procedure::redeem_profile_referral_invite_code; pub use redeem_profile_reward_code_procedure::redeem_profile_reward_code; @@ -1573,6 +1691,7 @@ pub use runtime_snapshot_row_type::RuntimeSnapshotRow; pub use runtime_snapshot_table::*; pub use runtime_snapshot_type::RuntimeSnapshot; pub use runtime_snapshot_upsert_input_type::RuntimeSnapshotUpsertInput; +pub use runtime_tracking_event_batch_procedure_result_type::RuntimeTrackingEventBatchProcedureResult; pub use runtime_tracking_event_input_type::RuntimeTrackingEventInput; pub use runtime_tracking_event_procedure_result_type::RuntimeTrackingEventProcedureResult; pub use runtime_tracking_scope_kind_type::RuntimeTrackingScopeKind; @@ -1583,30 +1702,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 +1815,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 +1853,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 +2154,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 +2171,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 +2196,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 +2208,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 +2220,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 +2311,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 +2364,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 +2441,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 +2477,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 +2513,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 +3008,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 +3111,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 +3162,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 +3237,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 +3273,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 +3309,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 +3409,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 +3460,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 +3535,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 +3571,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 +3607,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 +3663,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 +3680,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 +3705,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 +3717,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 +3729,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 +3860,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 +3945,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 +4066,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 +4122,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 +4178,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 +4887,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 +4904,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 +4929,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 +4941,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 +4953,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 +4984,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 +5001,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 +5026,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 +5038,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 +5050,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/record_tracking_events_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/record_tracking_events_and_return_procedure.rs new file mode 100644 index 00000000..428e378f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/record_tracking_events_and_return_procedure.rs @@ -0,0 +1,59 @@ +// 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::runtime_tracking_event_batch_procedure_result_type::RuntimeTrackingEventBatchProcedureResult; +use super::runtime_tracking_event_input_type::RuntimeTrackingEventInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RecordTrackingEventsAndReturnArgs { + pub inputs: Vec, +} + +impl __sdk::InModule for RecordTrackingEventsAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `record_tracking_events_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait record_tracking_events_and_return { + fn record_tracking_events_and_return(&self, inputs: Vec) { + self.record_tracking_events_and_return_then(inputs, |_, _| {}); + } + + fn record_tracking_events_and_return_then( + &self, + inputs: Vec, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl record_tracking_events_and_return for super::RemoteProcedures { + fn record_tracking_events_and_return_then( + &self, + inputs: Vec, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeTrackingEventBatchProcedureResult>( + "record_tracking_events_and_return", + RecordTrackingEventsAndReturnArgs { inputs }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_tracking_event_batch_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_tracking_event_batch_procedure_result_type.rs new file mode 100644 index 00000000..1d4d72d2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_tracking_event_batch_procedure_result_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 RuntimeTrackingEventBatchProcedureResult { + pub ok: bool, + pub accepted_count: u32, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeTrackingEventBatchProcedureResult { + type Module = super::RemoteModule; +} 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..1b9429a7 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,48 @@ 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 + } + + pub async fn record_tracking_events( + &self, + events: Vec, + ) -> Result { + if events.is_empty() { + return Ok(0); + } + + let procedure_inputs = events + .into_iter() + .map(crate::module_bindings::RuntimeTrackingEventInput::from) + .collect::>(); + + self.call_after_connect( + "record_tracking_events_and_return", + move |connection, sender| { + connection + .procedures() + .record_tracking_events_and_return_then(procedure_inputs, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_tracking_event_batch_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } @@ -502,7 +622,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 +645,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 +676,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 +698,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 +746,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 +776,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 +801,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 +859,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 +906,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 +930,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 +960,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 +996,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 +1020,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 +1044,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 +1066,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 +1099,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 +1123,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 +1147,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 +1171,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 +1206,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 +1233,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 +1260,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 94% rename from server-rs/crates/spacetime-module/src/bark_battle/mod.rs rename to server-rs/crates/spacetime-module/src/bark_battle.rs index 04e166d4..be16ffcc 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), } } @@ -619,10 +618,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, } } @@ -630,7 +655,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), } } @@ -885,7 +912,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 7d56b6de..771fc093 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, @@ -129,7 +131,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, @@ -145,7 +147,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, @@ -161,7 +163,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..2703a355 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), }, } @@ -974,6 +1063,7 @@ fn save_puzzle_generated_images_tx( if let Some(levels) = deserialize_optional_levels_input(input.levels_json.as_deref())? { // 中文注释:结果页新增关卡可能还没等到自动保存,生成图时以本次 action 携带的关卡快照作为写回目标。 draft.levels = levels; + draft = normalize_completed_puzzle_level_generation_status(draft); module_puzzle::sync_primary_level_fields(&mut draft); // 中文注释:入口直创会在 api-server 生成首关名后随 levels_json 写入;作品名仍是旧首关名或空值时才跟随首关名,避免覆盖用户手动命名。 sync_generated_primary_level_name_as_default_work_title( @@ -1003,6 +1093,7 @@ fn save_puzzle_generated_images_tx( next_level.cover_asset_id = Some(selected.asset_id); } draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?; + draft = normalize_completed_puzzle_level_generation_status(draft); let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros); let next_stage = if build_result_preview(&draft, Some("陶泥儿主")).publish_ready { @@ -1054,6 +1145,7 @@ fn save_puzzle_ui_background_tx( if let Some(levels) = deserialize_optional_levels_input(input.levels_json.as_deref())? { // 中文注释:UI 背景可以在自动保存前立即生成,写回前优先使用本次 action 携带的关卡快照。 draft.levels = levels; + draft = normalize_completed_puzzle_level_generation_status(draft); module_puzzle::sync_primary_level_fields(&mut draft); } let target_level = selected_puzzle_level(&draft, input.level_id.as_deref()) @@ -1066,6 +1158,7 @@ fn save_puzzle_ui_background_tx( (!trimmed.is_empty()).then_some(trimmed) }); let draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?; + let draft = normalize_completed_puzzle_level_generation_status(draft); let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros); let next_stage = if build_result_preview(&draft, Some("陶泥儿主")).publish_ready { @@ -1119,6 +1212,52 @@ fn sync_generated_primary_level_name_as_default_work_title( } } +fn normalize_completed_puzzle_level_generation_status( + mut draft: PuzzleResultDraft, +) -> PuzzleResultDraft { + draft.levels = normalize_completed_puzzle_levels_generation_status(draft.levels); + module_puzzle::sync_primary_level_fields(&mut draft); + draft +} + +fn normalize_completed_puzzle_levels_generation_status( + mut levels: Vec, +) -> Vec { + for level in &mut levels { + if level.generation_status.trim() == "generating" && has_completed_puzzle_level_image(level) + { + level.generation_status = "ready".to_string(); + } + } + levels +} + +fn has_completed_puzzle_level_image(level: &module_puzzle::PuzzleDraftLevel) -> bool { + let has_cover = level + .cover_image_src + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()); + let has_selected_candidate = level + .selected_candidate_id + .as_deref() + .and_then(|candidate_id| { + level + .candidates + .iter() + .find(|candidate| candidate.candidate_id == candidate_id) + }) + .map(|candidate| candidate.image_src.trim()) + .is_some_and(|value| !value.is_empty()); + let has_fallback_candidate = level + .candidates + .last() + .map(|candidate| candidate.image_src.trim()) + .is_some_and(|value| !value.is_empty()); + + has_cover || has_selected_candidate || has_fallback_candidate +} + fn select_puzzle_cover_image_tx( ctx: &TxContext, input: PuzzleSelectCoverImageInput, @@ -1264,8 +1403,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)); @@ -1302,12 +1441,13 @@ fn update_puzzle_work_tx( if theme_tags.len() > PUZZLE_MAX_TAG_COUNT { return Err("拼图标签数量不合法".to_string()); } - let levels = deserialize_optional_levels_input(input.levels_json.as_deref())? + let mut levels = deserialize_optional_levels_input(input.levels_json.as_deref())? .map(|levels| { normalize_puzzle_levels(levels, &theme_tags).map_err(|error| error.to_string()) }) .transpose()? .unwrap_or_else(|| build_profile_levels_from_row(&row).unwrap_or_default()); + levels = normalize_completed_puzzle_levels_generation_status(levels); let preview_draft = PuzzleResultDraft { work_title: input.work_title.clone(), work_description: input.work_description.clone(), @@ -1446,8 +1586,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 +1599,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 +1620,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 +2555,72 @@ 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 { + if levels.iter().any(has_completed_puzzle_level_image) { + return Some("ready".to_string()); + } + + 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 +2747,8 @@ fn list_session_messages(ctx: &TxContext, session_id: &str) -> Vec Result<(), String> { + let levels = normalize_completed_puzzle_levels_generation_status(profile.levels); if let Some(existing) = ctx .db .puzzle_work_profile() @@ -2694,7 +2900,7 @@ fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Re theme_tags_json: serialize_json(&profile.theme_tags), cover_image_src: profile.cover_image_src, cover_asset_id: profile.cover_asset_id, - levels_json: serialize_json(&profile.levels), + levels_json: serialize_json(&levels), publication_status: profile.publication_status, // 二次编辑发布同一个 profile 时,作品内容可以覆盖,但历史游玩数属于 // 广场消费数据,不能因为重新发布被清零。 @@ -2732,7 +2938,7 @@ fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Re theme_tags_json: serialize_json(&profile.theme_tags), cover_image_src: profile.cover_image_src, cover_asset_id: profile.cover_asset_id, - levels_json: serialize_json(&profile.levels), + levels_json: serialize_json(&levels), publication_status: profile.publication_status, play_count: profile.play_count, remix_count: profile.remix_count, @@ -3152,8 +3358,8 @@ fn replace_generated_candidate( fn list_published_puzzle_profiles(ctx: &TxContext) -> 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 +3525,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 @@ -3382,7 +3588,9 @@ fn deserialize_levels_json(value: &str) -> Result>(); @@ -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..d1bbb3c3 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -558,6 +558,33 @@ pub fn record_tracking_event_and_return( } } +// 高频 route tracking 由 api-server 本机 outbox 批量写入,减少公开列表热路径上的 procedure 调用次数。 +#[spacetimedb::procedure] +pub fn record_tracking_events_and_return( + ctx: &mut ProcedureContext, + inputs: Vec, +) -> RuntimeTrackingEventBatchProcedureResult { + match ctx.try_with_tx(|tx| { + let mut accepted_count = 0u32; + for input in &inputs { + record_tracking_event(tx, input.clone())?; + accepted_count = accepted_count.saturating_add(1); + } + Ok(accepted_count) + }) { + Ok(accepted_count) => RuntimeTrackingEventBatchProcedureResult { + ok: true, + accepted_count, + error_message: None, + }, + Err(message) => RuntimeTrackingEventBatchProcedureResult { + ok: false, + accepted_count: 0, + error_message: Some(message), + }, + } +} + // 登录成功埋点由认证链路主动调用;任务中心只负责读取和刷新任务进度。 #[spacetimedb::procedure] pub fn record_daily_login_tracking_event_and_return( @@ -1079,8 +1106,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 +1131,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 @@ -1537,6 +1566,19 @@ mod tests { assert!(!should_skip_existing_tracking_event_id(false)); } + #[test] + fn tracking_batch_result_reports_accepted_count() { + let result = RuntimeTrackingEventBatchProcedureResult { + ok: true, + accepted_count: 2, + error_message: None, + }; + + assert!(result.ok); + assert_eq!(result.accepted_count, 2); + assert!(result.error_message.is_none()); + } + #[test] fn recent_public_work_play_counts_group_requested_profiles_in_window() { let now_micros = PUBLIC_WORK_PLAY_DAY_MICROS * 10; @@ -2052,8 +2094,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 +2126,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 +2156,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 +2769,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 +2959,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 +3464,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 +4069,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 +4111,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/custom-world-home/CustomWorldCreationHub.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx index 009f470a..8ece1b8b 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx @@ -221,6 +221,81 @@ test('creation hub marks generating and newly completed drafts', () => { expect(html).toContain('creation-work-card__spinner'); }); +test('creation hub does not mask completed puzzle drafts while a later level image is generating', () => { + const html = renderToStaticMarkup( + {}} + onCreateType={noopCreateType} + onOpenDraft={() => {}} + onEnterPublished={() => {}} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} + onOpenPuzzleDetail={() => {}} + />, + ); + + expect(html).not.toContain('生成中...'); + expect(html).not.toContain('creation-work-card__spinner'); + expect(html).toContain('继续创作《潮雾拼图草稿》'); +}); + test('creation hub published work uses unified list card layout', () => { const html = renderToStaticMarkup( { + 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..05a1d2a7 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) => @@ -635,7 +641,7 @@ function isCreationTypeReferenceCoverImageSrc(value?: string | null) { ); } -function resolvePuzzleWorkCoverImageSrc(item: PuzzleWorkSummary) { +export function resolvePuzzleWorkCoverImageSrc(item: PuzzleWorkSummary) { const directCoverImageSrc = normalizeCoverImageSrc(item.coverImageSrc); if ( directCoverImageSrc && @@ -645,33 +651,44 @@ function resolvePuzzleWorkCoverImageSrc(item: PuzzleWorkSummary) { } for (const level of item.levels ?? []) { - const levelCoverImageSrc = normalizeCoverImageSrc(level.coverImageSrc); - if ( - levelCoverImageSrc && - !isCreationTypeReferenceCoverImageSrc(levelCoverImageSrc) - ) { - return levelCoverImageSrc; + const levelImageSrc = resolvePuzzleLevelCoverImageSrc(level); + if (levelImageSrc) { + return levelImageSrc; } + } - const selectedCandidateImageSrc = - level.selectedCandidateId && level.candidates.length > 0 - ? normalizeCoverImageSrc( - level.candidates.find( - (candidate) => candidate.candidateId === level.selectedCandidateId, - )?.imageSrc, + return null; +} + +export function resolvePuzzleLevelCoverImageSrc( + level: NonNullable[number], +) { + const levelCoverImageSrc = normalizeCoverImageSrc(level.coverImageSrc); + if ( + levelCoverImageSrc && + !isCreationTypeReferenceCoverImageSrc(levelCoverImageSrc) + ) { + return levelCoverImageSrc; + } + + const selectedCandidateImageSrc = + level.selectedCandidateId && level.candidates.length > 0 + ? normalizeCoverImageSrc( + level.candidates.find( + (candidate) => candidate.candidateId === level.selectedCandidateId, + )?.imageSrc, ) - : null; - const fallbackCandidateImageSrc = normalizeCoverImageSrc( - level.candidates[level.candidates.length - 1]?.imageSrc, - ); - const candidateImageSrc = selectedCandidateImageSrc || fallbackCandidateImageSrc; + : null; + const fallbackCandidateImageSrc = normalizeCoverImageSrc( + level.candidates[level.candidates.length - 1]?.imageSrc, + ); + const candidateImageSrc = selectedCandidateImageSrc || fallbackCandidateImageSrc; - if ( - candidateImageSrc && - !isCreationTypeReferenceCoverImageSrc(candidateImageSrc) - ) { - return candidateImageSrc; - } + if ( + candidateImageSrc && + !isCreationTypeReferenceCoverImageSrc(candidateImageSrc) + ) { + return candidateImageSrc; } return null; @@ -793,6 +810,31 @@ function buildPuzzleWorkShelfActions( }; } +function isPersistedCreationWorkGenerating(item: CreationWorkShelfItem) { + switch (item.source.kind) { + case 'match3d': + return item.source.item.generationStatus === 'generating'; + case 'puzzle': + return isPersistedPuzzleDraftGenerating(item.source.item); + default: + return false; + } +} + +export function isPersistedPuzzleDraftGenerating(item: PuzzleWorkSummary) { + if (item.generationStatus !== 'generating') { + return false; + } + + const hasUsableCover = Boolean(resolvePuzzleWorkCoverImageSrc(item)); + const hasReadyLevel = (item.levels ?? []).some((level) => + Boolean(resolvePuzzleLevelCoverImageSrc(level)), + ); + + // 中文注释:作品架“生成中”只表示初始草稿还没有可查看结果;结果页追加关卡或重绘局部图片不能锁住整张草稿卡。 + return !hasUsableCover && !hasReadyLevel; +} + function buildRpgWorkShelfActions( item: CustomWorldWorkSummary, adapter: RpgWorkShelfAdapter, diff --git a/src/components/match3d-result/Match3DResultView.test.tsx b/src/components/match3d-result/Match3DResultView.test.tsx index 3ded600e..31ffebab 100644 --- a/src/components/match3d-result/Match3DResultView.test.tsx +++ b/src/components/match3d-result/Match3DResultView.test.tsx @@ -1359,7 +1359,7 @@ describe('Match3DResultView', () => { 'img[src="/match3d-background-references/pot-fused-reference.png"]', ); expect(containerImage).toBeTruthy(); - expect(containerImage?.className).toContain('w-[min(99vw,34rem)]'); + expect(containerImage?.className).toContain('w-[min(108vw,38rem)]'); expect(containerImage?.className).toContain('-translate-x-1/2'); expect( document.querySelector('.animate-spin, [class*="border-l-transparent"]'), diff --git a/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx b/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx index ed1a51fd..e80e1b3f 100644 --- a/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx +++ b/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx @@ -52,6 +52,19 @@ import { import { Match3DRuntimeShell } from './Match3DRuntimeShell'; import { resolveGeometryAsset } from './match3dVisualAssets'; +const runtimeAudioFeedback = vi.hoisted(() => ({ + playRuntimeMergeSound: vi.fn(), +})); + +vi.mock('../../services/runtimeAudioFeedback', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + playRuntimeMergeSound: runtimeAudioFeedback.playRuntimeMergeSound, + }; +}); + vi.mock('./Match3DPhysicsBoard', async (importOriginal) => { const actual = await importOriginal(); return { @@ -82,6 +95,7 @@ afterEach(() => { __MATCH3D_KEEP_3D_TEST_RENDER__?: boolean; } ).__MATCH3D_KEEP_3D_TEST_RENDER__; + runtimeAudioFeedback.playRuntimeMergeSound.mockReset(); vi.restoreAllMocks(); }); @@ -519,6 +533,318 @@ test('运行态按生成素材的相对尺寸缩放场内和托盘图片', () => ).toBe('scale(0.58)'); }); +test('点击物品乐观插入到物品栏同类后面并后移后续物品', async () => { + const baseRun = startLocalMatch3DRun(3); + const [appleBoard, pearTray, appleTray] = baseRun.items.slice(0, 3); + expect(appleBoard && pearTray && appleTray).toBeTruthy(); + const run: Match3DRunSnapshot = { + ...baseRun, + items: [ + { + ...appleBoard!, + itemInstanceId: 'apple-3', + itemTypeId: 'apple', + visualKey: 'block-red-2x4', + clickable: true, + state: 'InBoard', + x: 0.5, + y: 0.5, + layer: 10, + traySlotIndex: null, + }, + { + ...pearTray!, + itemInstanceId: 'apple-1', + itemTypeId: 'apple', + visualKey: 'block-red-2x4', + clickable: false, + state: 'InTray', + traySlotIndex: 0, + }, + { + ...appleTray!, + itemInstanceId: 'apple-2', + itemTypeId: 'apple', + visualKey: 'block-red-2x4', + clickable: false, + state: 'InTray', + traySlotIndex: 1, + }, + { + ...baseRun.items[3]!, + itemInstanceId: 'pear-1', + itemTypeId: 'pear', + visualKey: 'block-blue-1x2', + clickable: false, + state: 'InTray', + traySlotIndex: 2, + }, + ...baseRun.items.slice(4).map((item) => ({ + ...item, + clickable: false, + state: 'InBoard' as const, + })), + ], + traySlots: baseRun.traySlots.map((slot) => { + if (slot.slotIndex === 0) { + return { + slotIndex: 0, + itemInstanceId: 'apple-1', + itemTypeId: 'apple', + visualKey: 'block-red-2x4', + }; + } + if (slot.slotIndex === 1) { + return { + slotIndex: 1, + itemInstanceId: 'apple-2', + itemTypeId: 'apple', + visualKey: 'block-red-2x4', + }; + } + if (slot.slotIndex === 2) { + return { + slotIndex: 2, + itemInstanceId: 'pear-1', + itemTypeId: 'pear', + visualKey: 'block-blue-1x2', + }; + } + return { slotIndex: slot.slotIndex }; + }), + }; + const onClickItem = vi.fn(async (payload: Match3DClickItemRequest) => ({ + status: 'Accepted' as const, + acceptedItemInstanceId: payload.itemInstanceId, + clearedItemInstanceIds: [], + run: { + ...run, + snapshotVersion: run.snapshotVersion + 1, + }, + })); + const onOptimisticRunChange = vi.fn(); + render( + , + ); + const board = screen.getByTestId('match3d-board'); + mockMatch3DBoardRect(); + mockMatch3DPointerCapture(board); + Object.defineProperty(screen.getAllByTestId('match3d-tray-slot')[3]!, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + bottom: 530, + height: 56, + left: 220, + right: 276, + top: 474, + width: 56, + x: 220, + y: 474, + toJSON: () => ({}), + }), + }); + + const point = toMatch3DBoardClientPoint(run.items[0]!); + fireMatch3DBoardPointer(board, 'pointerdown', point, 14); + fireMatch3DBoardPointer(board, 'pointerup', point, 14); + + await waitFor(() => expect(onOptimisticRunChange).toHaveBeenCalled()); + const optimisticRun = onOptimisticRunChange.mock.calls[0]?.[0] as + | Match3DRunSnapshot + | undefined; + expect(optimisticRun?.traySlots.map((slot) => slot.itemInstanceId ?? null)).toEqual([ + 'apple-1', + 'apple-2', + 'apple-3', + 'pear-1', + null, + null, + null, + ]); + expect( + optimisticRun?.items.find((item) => item.itemInstanceId === 'apple-3') + ?.traySlotIndex, + ).toBe(2); +}); + +test('三消确认后物品栏播放合成动画并隐藏权威快照中已清除的槽位', async () => { + const baseRun = startLocalMatch3DRun(1); + const [first, second, third, fourth] = baseRun.items.slice(0, 4); + expect(first && second && third).toBeTruthy(); + const run: Match3DRunSnapshot = { + ...baseRun, + totalItemCount: 4, + items: [ + { + ...first!, + itemInstanceId: 'apple-1', + itemTypeId: 'apple', + visualKey: 'block-red-2x4', + state: 'InTray', + clickable: false, + traySlotIndex: 0, + }, + { + ...second!, + itemInstanceId: 'apple-2', + itemTypeId: 'apple', + visualKey: 'block-red-2x4', + state: 'InTray', + clickable: false, + traySlotIndex: 1, + }, + { + ...third!, + itemInstanceId: 'apple-3', + itemTypeId: 'apple', + visualKey: 'block-red-2x4', + state: 'InBoard', + clickable: true, + x: 0.5, + y: 0.5, + layer: 10, + traySlotIndex: null, + }, + { + ...(fourth ?? third!), + itemInstanceId: 'pear-1', + itemTypeId: 'pear', + visualKey: 'block-blue-1x2', + state: 'InTray', + clickable: false, + traySlotIndex: 2, + }, + ], + traySlots: baseRun.traySlots.map((slot) => { + if (slot.slotIndex === 0) { + return { + slotIndex: 0, + itemInstanceId: 'apple-1', + itemTypeId: 'apple', + visualKey: 'block-red-2x4', + }; + } + if (slot.slotIndex === 1) { + return { + slotIndex: 1, + itemInstanceId: 'apple-2', + itemTypeId: 'apple', + visualKey: 'block-red-2x4', + }; + } + if (slot.slotIndex === 2) { + return { + slotIndex: 2, + itemInstanceId: 'pear-1', + itemTypeId: 'pear', + visualKey: 'block-blue-1x2', + }; + } + return { slotIndex: slot.slotIndex }; + }), + }; + const acceptedRun: Match3DRunSnapshot = { + ...run, + snapshotVersion: run.snapshotVersion + 1, + clearedItemCount: 3, + items: run.items.map((item) => + item.itemTypeId === 'apple' + ? { + ...item, + state: 'Cleared' as const, + clickable: false, + traySlotIndex: null, + } + : { ...item, traySlotIndex: 0 }, + ), + traySlots: run.traySlots.map((slot) => + slot.slotIndex === 0 + ? { + slotIndex: 0, + itemInstanceId: 'pear-1', + itemTypeId: 'pear', + visualKey: 'block-blue-1x2', + } + : { slotIndex: slot.slotIndex }, + ), + }; + const onClickItem = vi.fn(async (payload: Match3DClickItemRequest) => ({ + status: 'Accepted' as const, + acceptedItemInstanceId: payload.itemInstanceId, + clearedItemInstanceIds: ['apple-1', 'apple-2', 'apple-3'], + run: acceptedRun, + })); + const onOptimisticRunChange = vi.fn((nextRun: Match3DRunSnapshot) => { + rerender( + , + ); + }); + const { rerender } = render( + , + ); + const board = screen.getByTestId('match3d-board'); + mockMatch3DBoardRect(); + mockMatch3DPointerCapture(board); + screen.getAllByTestId('match3d-tray-slot').forEach((slot, index) => { + Object.defineProperty(slot, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + bottom: 530, + height: 56, + left: 52 + index * 58, + right: 108 + index * 58, + top: 474, + width: 56, + x: 52 + index * 58, + y: 474, + toJSON: () => ({}), + }), + }); + }); + + const point = toMatch3DBoardClientPoint(run.items[2]!); + fireMatch3DBoardPointer(board, 'pointerdown', point, 15); + fireMatch3DBoardPointer(board, 'pointerup', point, 15); + + await waitFor(() => + expect(screen.getByTestId('match3d-tray-clear-animation')).toBeTruthy(), + ); + expect(screen.getAllByTestId('match3d-tray-clear-token')).toHaveLength(3); + expect(screen.getByTestId('match3d-merge-feedback')).toBeTruthy(); + expect(screen.queryByTestId('match3d-merge-feedback')?.querySelector('svg')).toBeNull(); + expect(runtimeAudioFeedback.playRuntimeMergeSound).toHaveBeenCalledTimes(1); + const latestRun = onOptimisticRunChange.mock.calls.at(-1)?.[0] as + | Match3DRunSnapshot + | undefined; + expect(latestRun?.traySlots.map((slot) => slot.itemInstanceId ?? null)).toEqual([ + 'pear-1', + null, + null, + null, + null, + null, + null, + ]); +}); + test('点击物品时播放飞入底部栏位动画并使用第一张物品视图', async () => { const run = startLocalMatch3DRun(1); const clickableItem = run.items.find((item) => item.clickable)!; @@ -1025,9 +1351,10 @@ test('运行态会换签并渲染抓大鹅中心容器 UI 图', async () => { const containerImage = screen.getByTestId( 'match3d-container-image', ) as HTMLImageElement; - expect(containerImage.className).toContain('w-[min(99vw,34rem)]'); + expect(containerImage.className).toContain('w-[min(116vw,42rem)]'); expect(containerImage.className).toContain('h-auto'); expect(containerImage.className).toContain('left-1/2'); + expect(containerImage.className).toContain('top-[54%]'); expect(containerImage.className).toContain('-translate-x-1/2'); expect(screen.getByTestId('match3d-board').className).toContain( 'bg-transparent', diff --git a/src/components/match3d-runtime/Match3DRuntimeShell.tsx b/src/components/match3d-runtime/Match3DRuntimeShell.tsx index c6745bea..839de016 100644 --- a/src/components/match3d-runtime/Match3DRuntimeShell.tsx +++ b/src/components/match3d-runtime/Match3DRuntimeShell.tsx @@ -4,7 +4,6 @@ import { Clock3, RotateCcw, Settings, - Sparkles, XCircle, } from 'lucide-react'; import { @@ -12,6 +11,7 @@ import { type PointerEvent, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -30,20 +30,35 @@ import type { } from '../../../packages/shared/src/contracts/match3dWorks'; import { isGeneratedLegacyPath, + readAssetBytes, resolveAssetReadUrl, } from '../../services/assetReadUrlService'; import { getMatch3DGeneratedImageViewSources, normalizeMatch3DGeneratedItemAssetsForRuntime, } from '../../services/match3dGeneratedModelCache'; +import { + buildMatch3DTrayInsertionPlan, + resolveMatch3DTrayItemIdToSlotIndexMap, + syncMatch3DItemTraySlotIndexes, +} from '../../services/match3d-runtime/match3dTrayLayout'; import { DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG, playRuntimeClickSound, playRuntimeCountdownSound, playRuntimeLevelClearSound, + playRuntimeMergeSound, resolveRuntimeCountdownSecondBucket, } from '../../services/runtimeAudioFeedback'; import { useAuthUi } from '../auth/AuthUiContext'; +import { + findMatch3DHitItem, + type Match3DAlphaHitMask, + type Match3DGeneratedItemRelativeSize, + type Match3DResolvedImageSourceEntry, + resolveMatch3DImageSourceEntryForItem, + resolveMatch3DItemSizeScale, +} from './match3dHotspot'; import { isItemState, isRunState, @@ -103,6 +118,19 @@ type Match3DBoardPoint = { y: number; }; +type Match3DTraySlotLayout = { + left: number; + top: number; + width: number; + height: number; +}; + +type Match3DTrayMovingItemAnimation = { + itemInstanceId: string; + offsetX: number; + offsetY: number; +}; + type Match3DFlyingTrayAnimation = { id: string; item: Match3DItemSnapshot; @@ -116,7 +144,24 @@ type Match3DFlyingTrayAnimation = { toSize: number; }; -type Match3DGeneratedItemRelativeSize = '大' | '中' | '小'; +type Match3DTrayClearAnimation = { + id: string; + items: Array<{ + itemInstanceId: string; + itemTypeId: string; + visualKey: string; + imageSrc: string; + itemSize: Match3DGeneratedItemRelativeSize; + fromX: number; + fromY: number; + toX: number; + toY: number; + width: number; + height: number; + }>; + centerX: number; + centerY: number; +}; function resolveTrayPreviewItem( run: Match3DRunSnapshot, @@ -168,26 +213,6 @@ function buildClientEventId(itemInstanceId: string) { )}`; } -function isPointInsideCircle( - pointX: number, - pointY: number, - item: Match3DItemSnapshot, -) { - const frame = resolveRenderableItemFrame(item); - return Math.hypot(pointX - frame.x, pointY - frame.y) <= frame.radius; -} - -function findHitItem(run: Match3DRunSnapshot, pointX: number, pointY: number) { - return run.items - .filter( - (item) => - isItemState(item.state, 'in_board') && - item.clickable && - isPointInsideCircle(pointX, pointY, item), - ) - .sort((left, right) => right.layer - left.layer)[0]; -} - function resolveBoardPointFromPointerEvent( event: Pick, 'clientX' | 'clientY'>, stage: HTMLElement | null, @@ -284,26 +309,47 @@ function resolveStaticMatch3DReadUrlMap(sources: readonly string[]) { ); } -function buildResolvedMatch3DImageSourcesByType( +function buildResolvedMatch3DImageSourceEntriesByType( imageSourcesByType: ReadonlyMap, resolvedImageSources: ReadonlyMap, ) { return new Map( [...imageSourcesByType.entries()].map(([typeId, sources]) => [ typeId, - sources - .map((source) => { - const resolvedSource = resolvedImageSources.get(source); - if (resolvedSource) { - return resolvedSource; - } - return isGeneratedLegacyPath(source) ? '' : source; - }) - .filter(Boolean), + sources.flatMap((rawSource) => { + const source = rawSource.trim(); + if (!source) { + return []; + } + const resolvedSource = resolvedImageSources.get(source); + if (resolvedSource) { + return [{ source, resolvedSource }]; + } + return isGeneratedLegacyPath(source) + ? [] + : [{ source, resolvedSource: source }]; + }), ]), ); } +function resolveMatch3DAlphaHitMaskCacheKey( + imageSourceEntriesByType: ReadonlyMap< + string, + readonly Match3DResolvedImageSourceEntry[] + >, +) { + return [ + ...new Set( + [...imageSourceEntriesByType.values()].flatMap((entries) => + entries.map((entry) => entry.source.trim()).filter(Boolean), + ), + ), + ] + .sort() + .join('|'); +} + function normalizeMatch3DGeneratedItemSize( itemSize: Match3DGeneratedItemAsset['itemSize'] | null | undefined, ): Match3DGeneratedItemRelativeSize { @@ -342,35 +388,17 @@ function buildMatch3DItemSizeByType( ); } -function resolveMatch3DItemSizeScale( - itemSize: Match3DGeneratedItemRelativeSize | undefined, -) { - if (itemSize === '小') { - return 0.58; - } - if (itemSize === '中') { - return 0.78; - } - return 1; -} - -function hashMatch3DString(value: string) { - let hash = 0; - for (let index = 0; index < value.length; index += 1) { - hash = (hash * 31 + value.charCodeAt(index)) >>> 0; - } - return hash; -} - -function resolveMatch3DImageForItem( +function resolveMatch3DResolvedImageForItem( item: Match3DItemSnapshot, - imageSourcesByType: ReadonlyMap, + imageSourceEntriesByType: ReadonlyMap< + string, + readonly Match3DResolvedImageSourceEntry[] + >, ) { - const sources = imageSourcesByType.get(item.itemTypeId); - if (!sources || sources.length <= 0) { - return ''; - } - return sources[hashMatch3DString(item.itemInstanceId) % sources.length] ?? ''; + return ( + resolveMatch3DImageSourceEntryForItem(item, imageSourceEntriesByType) + ?.resolvedSource ?? '' + ); } function hasPendingMatch3DGeneratedImageForItem( @@ -391,13 +419,6 @@ function hasPendingMatch3DGeneratedImageForItem( ); } -function resolveMatch3DFirstImageForItem( - item: Match3DItemSnapshot, - imageSourcesByType: ReadonlyMap, -) { - return imageSourcesByType.get(item.itemTypeId)?.[0] ?? ''; -} - function resolveMatch3DItemSizeForType( item: Pick, itemSizeByType: ReadonlyMap, @@ -405,38 +426,106 @@ function resolveMatch3DItemSizeForType( return itemSizeByType.get(item.itemTypeId) ?? '大'; } +function resolveMatch3DSlotLayout( + element: HTMLElement | null, +): Match3DTraySlotLayout | null { + const rect = element?.getBoundingClientRect(); + if (!rect || rect.width <= 0 || rect.height <= 0) { + return null; + } + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + }; +} + function buildOptimisticRun( run: Match3DRunSnapshot, item: Match3DItemSnapshot, ) { - const nextSlot = run.traySlots.find((slot) => !slot.itemInstanceId); - if (!nextSlot) { + const insertion = buildMatch3DTrayInsertionPlan(run.traySlots, item); + if (!insertion) { return run; } + const nextItems = run.items.map((entry) => + entry.itemInstanceId === item.itemInstanceId + ? { + ...entry, + state: 'Flying' as const, + clickable: false, + traySlotIndex: insertion.slotIndex, + } + : entry, + ); return { ...run, - items: run.items.map((entry) => - entry.itemInstanceId === item.itemInstanceId - ? { - ...entry, - state: 'Flying' as const, - clickable: false, - } - : entry, - ), - traySlots: run.traySlots.map((slot) => - slot.slotIndex === nextSlot.slotIndex - ? { - slotIndex: slot.slotIndex, - itemInstanceId: item.itemInstanceId, - itemTypeId: item.itemTypeId, - visualKey: item.visualKey, - } - : slot, - ), + items: syncMatch3DItemTraySlotIndexes(nextItems, insertion.traySlots), + traySlots: insertion.traySlots, }; } +function loadMatch3DAlphaHitMaskImage(source: string) { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(new Error('读取抓大鹅物品热区图片失败')); + image.src = source; + }); +} + +async function loadMatch3DAlphaHitMask( + source: string, + signal: AbortSignal, +): Promise { + const response = await readAssetBytes(source, { + signal, + expireSeconds: 300, + }); + const blob = await response.blob(); + const canCreateObjectUrl = + typeof URL.createObjectURL === 'function' && + typeof URL.revokeObjectURL === 'function'; + const imageSource = canCreateObjectUrl + ? URL.createObjectURL(blob) + : await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result ?? '')); + reader.onerror = () => reject(new Error('读取抓大鹅热区图片失败')); + reader.readAsDataURL(blob); + }); + try { + const image = await loadMatch3DAlphaHitMaskImage(imageSource); + if (signal.aborted) { + throw new DOMException('热区图片读取已取消', 'AbortError'); + } + const width = Math.max(1, image.naturalWidth || image.width || 1); + const height = Math.max(1, image.naturalHeight || image.height || 1); + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d', { + willReadFrequently: true, + }); + if (!context) { + throw new Error('浏览器不支持读取物品热区图片'); + } + context.clearRect(0, 0, width, height); + context.drawImage(image, 0, 0, width, height); + const pixels = context.getImageData(0, 0, width, height).data; + const alpha = new Uint8ClampedArray(width * height); + for (let index = 0; index < alpha.length; index += 1) { + alpha[index] = pixels[index * 4 + 3] ?? 0; + } + return { width, height, alpha }; + } finally { + if (canCreateObjectUrl) { + URL.revokeObjectURL(imageSource); + } + } +} + function Match3DToken({ item, imageSrc, @@ -514,11 +603,15 @@ function Match3DTrayToken({ imageSrc, itemSize, isArriving = false, + isClearing = false, + moveAnimation = null, }: { slot: Match3DTraySlot; imageSrc?: string; itemSize?: Match3DGeneratedItemRelativeSize; isArriving?: boolean; + isClearing?: boolean; + moveAnimation?: Match3DTrayMovingItemAnimation | null; }) { if (!slot.visualKey) { return ( @@ -526,11 +619,20 @@ function Match3DTrayToken({ ); } const visualSeed = resolveVisualSeed(slot.visualKey); + const style = moveAnimation + ? ({ + '--match3d-tray-shift-x': `${moveAnimation.offsetX}px`, + '--match3d-tray-shift-y': `${moveAnimation.offsetY}px`, + } as CSSProperties) + : undefined; return ( {imageSrc ? ( @@ -603,6 +705,65 @@ function Match3DFlyingTrayToken({ ); } +function Match3DTrayClearToken({ + animation, + onDone, +}: { + animation: Match3DTrayClearAnimation; + onDone: (id: string) => void; +}) { + return ( + @@ -1415,6 +1844,7 @@ export function Match3DRuntimeShell({ key={slot.slotIndex} className={MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS} data-testid="match3d-tray-slot" + data-slot-index={slot.slotIndex} ref={(element) => { traySlotRefs.current[slot.slotIndex] = element; }} @@ -1425,12 +1855,24 @@ export function Match3DRuntimeShell({ flyingTrayAnimation?.item.itemInstanceId === slot.itemInstanceId } + isClearing={ + Boolean(slot.itemInstanceId) && + (trayClearAnimation?.items.some( + (clearItem) => + clearItem.itemInstanceId === slot.itemInstanceId, + ) ?? + false) + } + moveAnimation={ + slot.itemInstanceId + ? (trayMovingItemAnimationById.get( + slot.itemInstanceId, + ) ?? null) + : null + } imageSrc={ trayItem - ? resolveMatch3DFirstImageForItem( - trayItem, - resolvedImageSourcesByType, - ) + ? resolveFirstResolvedImageForItem(trayItem) : '' } itemSize={ @@ -1466,6 +1908,17 @@ export function Match3DRuntimeShell({ /> ) : null} + {trayClearAnimation ? ( + + setTrayClearAnimation((current) => + current?.id === id ? null : current, + ) + } + /> + ) : null} + ({ slotIndex })), + items: [ + { + clickable: true, + itemInstanceId: 'alpha-hotspot-item', + itemTypeId: 'match3d-type-01', + visualKey: 'block-red-2x2', + x: 0.5, + y: 0.5, + radius: 0.1, + layer: 1, + state: 'InBoard', + }, + ], + }; +} + +test('透明像素不作为抓大鹅物品点击热区', () => { + const run = buildMatch3DHotspotRun(); + const item = run.items[0]!; + const mask = { + width: 4, + height: 4, + alpha: new Uint8ClampedArray([ + 0, 0, 0, 0, + 0, 255, 255, 0, + 0, 255, 255, 0, + 0, 0, 0, 0, + ]), + }; + const imageSourceEntriesByType = new Map([ + [ + 'match3d-type-01', + [ + { + source: '/generated-match3d-assets/item-01.png', + resolvedSource: 'https://oss.example.com/item-01.png', + }, + ], + ], + ]); + const alphaHitMasks = new Map([ + ['/generated-match3d-assets/item-01.png', mask], + ]); + const itemSizeByType = new Map([['match3d-type-01', '大' as const]]); + const frame = resolveRenderableItemFrame(item); + + expect( + findMatch3DHitItem(run, frame.x - frame.radius * 0.6, 0.5, { + alphaHitMasks, + imageSourceEntriesByType, + itemSizeByType, + }), + ).toBeUndefined(); + expect( + findMatch3DHitItem(run, 0.5, 0.5, { + alphaHitMasks, + imageSourceEntriesByType, + itemSizeByType, + })?.itemInstanceId, + ).toBe('alpha-hotspot-item'); +}); + +test('小尺寸物品只在缩放后的非透明主体内命中', () => { + const item = buildMatch3DHotspotRun().items[0]!; + const mask = { + width: 2, + height: 2, + alpha: new Uint8ClampedArray([255, 255, 255, 255]), + }; + + expect( + isPointInsideMatch3DItemAlphaHotspot({ + item, + pointX: 0.5, + pointY: 0.5, + mask, + itemSize: '小', + }), + ).toBe(true); + expect( + isPointInsideMatch3DItemAlphaHotspot({ + item, + pointX: 0.38, + pointY: 0.5, + mask, + itemSize: '小', + }), + ).toBe(false); +}); + +test('抓大鹅生成物品大和中也会做一定程度缩小', () => { + expect(resolveMatch3DItemSizeScale('大')).toBeLessThan(1); + expect(resolveMatch3DItemSizeScale('中')).toBeLessThan(0.78); + expect(resolveMatch3DItemSizeScale('大')).toBeGreaterThan( + resolveMatch3DItemSizeScale('中'), + ); + expect(resolveMatch3DItemSizeScale('中')).toBeGreaterThan( + resolveMatch3DItemSizeScale('小'), + ); +}); diff --git a/src/components/match3d-runtime/match3dHotspot.ts b/src/components/match3d-runtime/match3dHotspot.ts new file mode 100644 index 00000000..44774141 --- /dev/null +++ b/src/components/match3d-runtime/match3dHotspot.ts @@ -0,0 +1,194 @@ +import type { + Match3DItemSnapshot, + Match3DRunSnapshot, +} from '../../../packages/shared/src/contracts/match3dRuntime'; +import { isGeneratedLegacyPath } from '../../services/assetReadUrlService'; +import { + isItemState, + resolveRenderableItemFrame, +} from './match3dRuntimePresentation'; + +export type Match3DGeneratedItemRelativeSize = '大' | '中' | '小'; + +export type Match3DAlphaHitMask = { + width: number; + height: number; + alpha: Uint8ClampedArray; +}; + +export type Match3DResolvedImageSourceEntry = { + source: string; + resolvedSource: string; +}; + +const MATCH3D_HIT_ALPHA_THRESHOLD = 8; + +function isPointInsideCircle( + pointX: number, + pointY: number, + item: Match3DItemSnapshot, +) { + const frame = resolveRenderableItemFrame(item); + return Math.hypot(pointX - frame.x, pointY - frame.y) <= frame.radius; +} + +function clampMatch3DHitPixelIndex(value: number, size: number) { + return Math.min(size - 1, Math.max(0, Math.floor(value * size))); +} + +export function resolveMatch3DItemSizeScale( + itemSize: Match3DGeneratedItemRelativeSize | undefined, +) { + if (itemSize === '小') { + return 0.58; + } + if (itemSize === '中') { + return 0.68; + } + return 0.88; +} + +function isPointInsideAlphaHitMask( + localX: number, + localY: number, + mask: Match3DAlphaHitMask, + itemSize: Match3DGeneratedItemRelativeSize, +) { + if ( + mask.width <= 0 || + mask.height <= 0 || + mask.alpha.length < mask.width * mask.height || + localX < 0 || + localX > 1 || + localY < 0 || + localY > 1 + ) { + return false; + } + + const aspectRatio = mask.width / mask.height; + const containWidth = aspectRatio >= 1 ? 1 : aspectRatio; + const containHeight = aspectRatio >= 1 ? 1 / aspectRatio : 1; + const imageScale = resolveMatch3DItemSizeScale(itemSize); + const renderedWidth = containWidth * imageScale; + const renderedHeight = containHeight * imageScale; + const imageLeft = (1 - renderedWidth) / 2; + const imageTop = (1 - renderedHeight) / 2; + + if ( + localX < imageLeft || + localX > imageLeft + renderedWidth || + localY < imageTop || + localY > imageTop + renderedHeight + ) { + return false; + } + + const imageX = (localX - imageLeft) / renderedWidth; + const imageY = (localY - imageTop) / renderedHeight; + const pixelX = clampMatch3DHitPixelIndex(imageX, mask.width); + const pixelY = clampMatch3DHitPixelIndex(imageY, mask.height); + return ( + (mask.alpha[pixelY * mask.width + pixelX] ?? 0) > + MATCH3D_HIT_ALPHA_THRESHOLD + ); +} + +export function isPointInsideMatch3DItemAlphaHotspot({ + item, + pointX, + pointY, + mask, + itemSize, +}: { + item: Match3DItemSnapshot; + pointX: number; + pointY: number; + mask: Match3DAlphaHitMask; + itemSize: Match3DGeneratedItemRelativeSize; +}) { + const frame = resolveRenderableItemFrame(item); + const diameter = frame.radius * 2; + if (diameter <= 0) { + return false; + } + return isPointInsideAlphaHitMask( + (pointX - (frame.x - frame.radius)) / diameter, + (pointY - (frame.y - frame.radius)) / diameter, + mask, + itemSize, + ); +} + +export function hashMatch3DString(value: string) { + let hash = 0; + for (let index = 0; index < value.length; index += 1) { + hash = (hash * 31 + value.charCodeAt(index)) >>> 0; + } + return hash; +} + +export function resolveMatch3DImageSourceEntryForItem( + item: Match3DItemSnapshot, + imageSourceEntriesByType: ReadonlyMap< + string, + readonly Match3DResolvedImageSourceEntry[] + >, +) { + const sources = imageSourceEntriesByType.get(item.itemTypeId); + if (!sources || sources.length <= 0) { + return null; + } + return sources[hashMatch3DString(item.itemInstanceId) % sources.length] ?? null; +} + +export function findMatch3DHitItem( + run: Match3DRunSnapshot, + pointX: number, + pointY: number, + options: { + imageSourceEntriesByType?: ReadonlyMap< + string, + readonly Match3DResolvedImageSourceEntry[] + >; + alphaHitMasks?: ReadonlyMap; + failedAlphaHitMaskSources?: ReadonlySet; + itemSizeByType?: ReadonlyMap; + } = {}, +) { + return run.items + .filter((item) => { + if ( + !isItemState(item.state, 'in_board') || + !item.clickable || + !isPointInsideCircle(pointX, pointY, item) + ) { + return false; + } + + const imageSourceEntry = resolveMatch3DImageSourceEntryForItem( + item, + options.imageSourceEntriesByType ?? new Map(), + ); + const mask = imageSourceEntry + ? options.alphaHitMasks?.get(imageSourceEntry.source) + : null; + if (!mask) { + return ( + !imageSourceEntry || + !isGeneratedLegacyPath(imageSourceEntry.source) || + options.failedAlphaHitMaskSources?.has(imageSourceEntry.source) === + true + ); + } + + return isPointInsideMatch3DItemAlphaHotspot({ + item, + pointX, + pointY, + mask, + itemSize: options.itemSizeByType?.get(item.itemTypeId) ?? '大', + }); + }) + .sort((left, right) => right.layer - left.layer)[0]; +} diff --git a/src/components/match3d-runtime/match3dRuntimeUiStyles.ts b/src/components/match3d-runtime/match3dRuntimeUiStyles.ts index 1420923b..6d595319 100644 --- a/src/components/match3d-runtime/match3dRuntimeUiStyles.ts +++ b/src/components/match3d-runtime/match3dRuntimeUiStyles.ts @@ -27,7 +27,7 @@ export const MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS = 'relative z-0 h-14 min-w-0 rounded-xl border border-white/52 bg-white/56 p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.44)] sm:h-16'; export const MATCH3D_RUNTIME_STAGE_CLASS = - 'relative mt-3 flex min-h-0 flex-1 items-center justify-center'; + 'relative mt-5 flex min-h-0 flex-1 items-center justify-center'; export const MATCH3D_RUNTIME_BOARD_BASE_CLASS = 'relative aspect-square max-w-full'; @@ -41,7 +41,7 @@ export const MATCH3D_RUNTIME_BOARD_FALLBACK_CLASS = 'overflow-hidden rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)]'; export const MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS = - 'pointer-events-none absolute left-1/2 top-1/2 z-0 h-auto w-[min(99vw,34rem)] max-w-none -translate-x-1/2 -translate-y-1/2 object-contain drop-shadow-[0_22px_42px_rgba(15,23,42,0.28)]'; + 'pointer-events-none absolute left-1/2 top-[54%] z-0 h-auto w-[min(116vw,42rem)] max-w-none -translate-x-1/2 -translate-y-1/2 object-contain drop-shadow-[0_22px_42px_rgba(15,23,42,0.28)]'; export const MATCH3D_RUNTIME_CONTAINER_PLACEHOLDER_CLASS = 'pointer-events-none absolute inset-[7%] z-0 rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]'; diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index e1738f11..2033a1f3 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -1,4 +1,4 @@ -import { ArrowRight, Loader2, Sparkles } from 'lucide-react'; +import { ArrowRight, Loader2 } from 'lucide-react'; import { AnimatePresence, motion } from 'motion/react'; import { type Dispatch, @@ -189,6 +189,7 @@ import { buildPuzzleGenerationAnchorEntries, buildSquareHoleGenerationAnchorEntries, createMiniGameDraftGenerationState, + type MiniGameDraftGenerationKind, type MiniGameDraftGenerationState, } from '../../services/miniGameDraftGenerationProgress'; import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient'; @@ -300,7 +301,11 @@ import { PublishShareModal } from '../common/PublishShareModal'; import type { PublishShareModalPayload } from '../common/publishShareModalModel'; import { UnifiedModal } from '../common/UnifiedModal'; import { resolveCreativeAgentTargetSelectionStage } from '../creative-agent/creativeAgentViewModel'; -import type { CreationWorkShelfItem } from '../custom-world-home/creationWorkShelf'; +import { + isPersistedPuzzleDraftGenerating, + resolvePuzzleWorkCoverImageSrc, + type CreationWorkShelfItem, +} from '../custom-world-home/creationWorkShelf'; import { isBigFishGalleryEntry, isEdutainmentGalleryEntry, @@ -332,6 +337,11 @@ import { filterGeneralPublicWorks, } from './platformEdutainmentVisibility'; import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal'; +import { + PuzzleOnboardingLoginOverlay, + type PuzzleOnboardingPhase, + PuzzleOnboardingView, +} from './PlatformEntryFlowShellImpl/PuzzleOnboardingView'; import type { PlatformCreationTypeId } from './platformEntryCreationTypes'; import { derivePlatformCreationTypes, @@ -402,8 +412,6 @@ type PuzzleRuntimeReturnStage = | 'platform'; type PuzzleRuntimeAuthMode = 'default' | 'isolated'; -type PuzzleOnboardingPhase = 'input' | 'generating' | 'generated'; - type PuzzleOnboardingDraft = { promptText: string; item: PuzzleWorkSummary; @@ -631,7 +639,9 @@ function resolveVisiblePuzzleDetailCoverCount( function mapMatch3DWorkToPublicWorkDetail( item: Match3DWorkSummary, ): PlatformPublicGalleryCard { - return mapMatch3DWorkToPlatformGalleryCard(item); + return mapMatch3DWorkToPlatformGalleryCard( + normalizeMatch3DWorkForRuntimeUi(item), + ); } function mapSquareHoleWorkToPublicWorkDetail( @@ -755,6 +765,23 @@ function promoteMatch3DGeneratedBackgroundAsset< }; } +function normalizeMatch3DWorkForRuntimeUi( + profile: T, +): T { + return promoteMatch3DGeneratedBackgroundAsset({ + ...profile, + generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime( + profile.generatedItemAssets, + ), + }); +} + +function mapMatch3DWorksForRuntimeUi( + profiles: readonly T[], +): T[] { + return profiles.map(normalizeMatch3DWorkForRuntimeUi); +} + function buildMatch3DProfileFromSession( session: Match3DAgentSessionSnapshot | null, ): Match3DWorkProfile | null { @@ -1322,134 +1349,6 @@ function markPuzzleOnboardingSeen() { } } -function PuzzleOnboardingView({ - prompt, - phase, - error, - onPromptChange, - onSubmit, - onSkip, -}: { - prompt: string; - phase: PuzzleOnboardingPhase; - error: string | null; - onPromptChange: (value: string) => void; - onSubmit: () => void; - onSkip: () => void; -}) { - const isGenerating = phase === 'generating'; - const isGenerated = phase === 'generated'; - const canSubmit = Boolean(prompt.trim()) && !isGenerating && !isGenerated; - - return ( -
-
- -
-
- {isGenerating ? ( - - ) : ( - - )} -
-

- {PUZZLE_ONBOARDING_COPY} -

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