diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 7778e0a7..3fa7912a 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -111,6 +111,22 @@ - 验证方式:新增玩法 PRD 必须显式声明单图资产槽位和系列素材槽位;新增工作台测试确认没有默认聊天式 Agent 输入;skill 通过 `quick_validate.py`。 - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`.codex/skills/genarrative-play-type-integration/SKILL.md`、`.hermes/skills/genarrative-play-type-integration/SKILL.md`。 +## 2026-05-19 系列素材 n*n 图集抽为 api-server 通用模块 + +- 背景:抓大鹅物品 sheet 已包含 prompt 组装、固定网格切图、绿幕 / 近白底透明化、切片 PNG 持久化和 prompt 追踪;继续留在 Match3D 私有模块会让跳一跳、后续地块 / 道具类玩法重复复制同一套算法和 OSS 元数据口径。 +- 决策:`server-rs/crates/api-server/src/generated_asset_sheets.rs` 作为通用系列素材图集模块,`n` 作为必选 `grid_size` 参数;物品名称 prompt 模板与特殊设定 prompt 作为可选输入;模块负责 sheet prompt、`n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造,以及 sheet / item / special prompt 的 base64 元数据持久化。玩法只负责生图 provider、计费、slot 规划、失败回写和把通用切片结果映射回自身 DTO / 草稿 / runtime 字段。 +- 影响范围:`api-server` 系列素材生成、Match3D 物品五视角素材、后续新增玩法的地块 / 物品 / 障碍 / 装饰图集生成。 +- 验证方式:`cargo test -p api-server generated_asset_sheets --manifest-path server-rs\Cargo.toml -- --nocapture` 覆盖通用 prompt、切片、`n` 校验和 prompt 元数据;玩法侧执行对应素材流水线定向测试。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + +## 2026-05-19 跳一跳玩法采用正式 scoring DTO 与 public view 投影 + +- 背景:跳一跳玩法新增后,前端、shared-contracts、SpacetimeDB 生成绑定和后端 mapper 对 scoring 字段口径不一致,schema guard 也要求 table / view 目录与 `migration.rs` 同步。 +- 决策:跳一跳的 `JumpHopScoring` 统一采用 `chargeToDistanceRatio/maxChargeMs/hitBonus/perfectBonus`,公开广场优先使用 `jump_hop_gallery_card_view`,详情兼容投影保留 `jump_hop_gallery_view`。`spacetime-module` 新增的 `jump_hop_*` table 必须同步进入 `migration.rs` 和后端架构文档。 +- 影响范围:`packages/shared/src/contracts/jumpHop.ts`、`server-rs/crates/shared-contracts/src/jump_hop.rs`、`server-rs/crates/spacetime-client/src/mapper/jump_hop.rs`、`server-rs/crates/spacetime-module/src/migration.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 +- 验证方式:`cargo check -p shared-contracts --manifest-path server-rs/Cargo.toml`、`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`。 +- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + ## 2026-05-16 公开作品列表短期由 BFF 订阅读模型缓存 - 背景:作品列表压测和实时性讨论中,曾考虑让浏览器前端直接订阅公开作品列表,减少 HTTP 拉取和 BFF 压力。 @@ -644,3 +660,11 @@ - 默认阈值:每批 500 条或 1 秒 flush 一次;outbox 磁盘上限 256 MiB,超过后丢弃低价值 route 事件并记录指标 / 日志。 - 影响范围:`api-server` tracking 中间件、SpacetimeDB tracking procedure、部署数据目录、OTLP 指标和运维排障。 - 验证方式:数据库不可用时公开 route 请求不失败且 outbox 文件保留;恢复后批量写入成功并删除本地 sealed 文件;关键事件仍立即影响任务 / 统计。 + +## 2026-05-19 跳一跳平台公开链路采用独立玩法路由 + +- 背景:跳一跳玩法已接入平台入口、推荐、公开详情、试玩和运行态,后续继续扩展公开广场或推荐流时需要避免把它当成拼图兼容分支。 +- 决策:跳一跳公开路由统一依赖 `sourceType='jump-hop'` 和 `JH-*` public code;平台首页、推荐、公开作品列表/详情、试玩和运行态都按 `jump-hop` 独立玩法分发。后端仍是作品、运行和发布状态的业务真相,前端只做展示、交互和临时 UI 状态,不在页面层补业务规则或权限判断。 +- 影响范围:平台入口、推荐流、公开详情、试玩启动、跳一跳运行态、`api-server` / SpacetimeDB 公开投影和 shared contracts。 +- 验证方式:从平台推荐或公开详情进入跳一跳作品时,路由 source type 为 `jump-hop`、public code 为 `JH-*`,运行态启动消费后端返回的完整 profile / run 数据;后端 smoke 统一使用 `npm run dev:api-server` 启动并检查 `/healthz`。 +- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 89fb2509..355dae4c 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -22,6 +22,14 @@ - 验证:拼图入口测试仍可通过,且新组件可通过不同页面复用而不需要复制上传卡实现。 - 关联:`src/components/common/CreativeImageInputPanel.tsx`、`src/components/puzzle-agent/PuzzleAgentWorkspace.tsx`。 +## 通用图片面板的展示图不能自动等于 AI 重绘参考图 + +- 现象:结果页关卡详情把正式关卡图传给 `CreativeImageInputPanel` 后,面板会按“有图”默认显示 AI 重绘开关,容易让用户误以为正式图也能直接作为重绘参考图。 +- 原因:展示图和 AI 重绘参考图是两种不同语义;前者只是预览当前图片,后者决定是否向后端提交可编辑参考图和重绘动作。 +- 处理:给通用面板补独立控制位,只有外层明确允许时才显示 AI 重绘开关;结果页关卡详情在存在独立 `pictureReference` 时才开启重绘控制,UI 背景预览始终只走展示模式。 +- 验证:结果页测试能区分“只有正式图”与“有独立参考图”两种情况,入口页的上传/历史图/AI 重绘行为不受影响。 +- 关联:`src/components/common/CreativeImageInputPanel.tsx`、`src/components/puzzle-result/PuzzleResultView.tsx`。 + ## 新增玩法不要直接复制旧玩法创作工具 - 现象:新玩法一开始就复制既有玩法的聊天式 Agent、轻输入 Agent、专属素材 DTO 或生成流程,后续在结果页、作品架和 runtime 上不断补兼容层。 @@ -115,6 +123,14 @@ - 验证:执行 `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`。 +## SpacetimeDB schema guard 还要同步 migration.rs 和表目录 + +- 现象:`npm run check:spacetime-schema` 报 schema 已变化,但只指出 `server-rs/crates/spacetime-module/src/migration.rs` 和后端架构文档没有同步。 +- 原因:新增 table / view / row shape 后,代码生成绑定可以先通过,但 migration 白名单和文档中的机器可读表目录仍然落后,schema guard 会把它判定为不完整变更。 +- 处理:新增 `spacetime-module` table 时同步把表名加入 `migration.rs` 的迁移表宏和 `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` 的表目录;如果新增 view,还要补长期订阅列表和 view 描述。 +- 验证:重新运行 `npm run check:spacetime-schema` 应通过;再跑相关 `cargo check` 和 `npm run check:encoding`。 +- 关联:`server-rs/crates/spacetime-module/src/migration.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/README.md`。 + ## 拼图广场列表不要每次 HTTP 请求调用 SpacetimeDB procedure - 现象:`/api/runtime/puzzle/gallery` 每个请求都走 `spacetime-client.list_puzzle_gallery()` 调用 SpacetimeDB procedure,导致 SpacetimeDB WASM 侧重复组装全量列表,客户端再映射一遍;历史实现还出现过 procedure JSON 字符串往返。 @@ -1039,3 +1055,11 @@ - 处理:打开草稿时把持久化 `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`。 + +## 跳一跳公开作品排障先查 sourceType 与后端运行态 + +- 现象:平台推荐、公开详情、试玩或运行态里跳一跳作品打不开、走错玩法、详情缺字段,或 `JH-*` 作品号搜索不到对应作品。 +- 原因:跳一跳已是独立玩法链路,公开入口必须按 `sourceType='jump-hop'` 和 `JH-*` public code 分发;如果前端把它当作拼图/旧公开作品兼容分支,或在页面层自行补作品状态、权限、运行规则,就会和后端业务真相漂移。 +- 处理:排查时先确认公开卡片、推荐项、详情页和试玩启动都保留 `sourceType='jump-hop'`,`publicCode` 使用 `JH-*`;运行态只消费后端返回的 profile / run / scoring / path 数据,前端只做展示和交互。后端 smoke 统一用 `npm run dev:api-server` 拉起,再检查 `/healthz`,不要回到旧的本地后端启动口径。 +- 验证:从推荐或公开详情启动跳一跳试玩能进入跳一跳运行态;搜索 `JH-*` 能打开公开详情;`npm run dev:api-server` 启动后 `/healthz` 返回健康。 +- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/services/jump-hop/jumpHopClient.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 diff --git a/docs/README.md b/docs/README.md index 2ae24fda..36689c78 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,7 +13,7 @@ 重点补充:RPG 创作与运行时脚本职责地图见 [RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md](./reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md)。 - [埋点查询](./tracking/README.md):埋点原始事件与聚合投影的本地 SQL 查询。 - [运营查询](./operations/README.md):任务、领奖、钱包对账等后台核查查询。 -- [PRD](./prd/README.md):产品需求与阶段计划;参考 MOKU / 幕间类 AI 文游的陶泥儿 `text-game` 模板口径见 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md](./prd/AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md),视觉小说模板 TXT 玩法平台化接入口径见 [AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md](./prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md),创意互动内容 Agent Phase 1 的 LangChain-Rust PoC、拼图闭环和并行任务拆分见 [CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md](./prd/CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md),幸存者类模板闭环见 [AI_NATIVE_SURVIVOR_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-05.md](./prd/AI_NATIVE_SURVIVOR_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-05.md),后台管理独立前端工程见 [ADMIN_WEB_CONSOLE_PRD_2026-04-30.md](./prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md),新增 RPG 开场动画方案见 [AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md](./prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md),新增抓大鹅 Match3D 玩法方案见 [AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md](./prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md),方洞挑战创作、发布与试玩闭环见 [AI_NATIVE_SQUARE_HOLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md](./prd/AI_NATIVE_SQUARE_HOLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md)。 +- [PRD](./prd/README.md):产品需求与阶段计划;参考 MOKU / 幕间类 AI 文游的陶泥儿 `text-game` 模板口径见 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md](./prd/AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md),视觉小说模板 TXT 玩法平台化接入口径见 [AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md](./prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md),创意互动内容 Agent Phase 1 的 LangChain-Rust PoC、拼图闭环和并行任务拆分见 [CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md](./prd/CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md),幸存者类模板闭环见 [AI_NATIVE_SURVIVOR_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-05.md](./prd/AI_NATIVE_SURVIVOR_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-05.md),后台管理独立前端工程见 [ADMIN_WEB_CONSOLE_PRD_2026-04-30.md](./prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md),新增 RPG 开场动画方案见 [AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md](./prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md),新增抓大鹅 Match3D 玩法方案见 [AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md](./prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md),方洞挑战创作、发布与试玩闭环见 [AI_NATIVE_SQUARE_HOLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md](./prd/AI_NATIVE_SQUARE_HOLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md),跳一跳俯视角玩法模板 PRD 见 [【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md](./prd/%E3%80%90%E7%8E%A9%E6%B3%95%E5%88%9B%E4%BD%9C%E3%80%91%E8%B7%B3%E4%B8%80%E8%B7%B3%E4%BF%AF%E8%A7%86%E8%A7%92%E7%8E%A9%E6%B3%95%E6%A8%A1%E6%9D%BFPRD-2026-05-19.md)。 生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md);private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。 diff --git a/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md b/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md new file mode 100644 index 00000000..63af3568 --- /dev/null +++ b/docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md @@ -0,0 +1,485 @@ +# 跳一跳俯视角玩法模板 PRD 2026-05-19 + +## 1. 目标 + +新增一个可创作、可试玩、可发布的玩法模板: + +```text +跳一跳 +``` + +本模板参考拼图模板的创作闭环,沿用“创作入口 -> 生成过程页 -> 结果页 -> 试玩 -> 发布”的平台链路,但玩法本体改为俯视角 / 等距视角的跳跃闯关。 + +首版要求: + +1. 初始草稿生成时,角色形象单独调用一次生图; +2. 初始草稿生成时,地块只调用一次生图,输出 3D 视图的 2D 图片图集; +3. 运行态不接真实 3D 网格,不生成 GLB / glTF; +4. 作品可以直接进入试玩和发布。 + +## 2. 模板定位 + +模板 ID: + +```text +jump-hop +``` + +用户展示名: + +```text +跳一跳 +``` + +体验关键词: + +1. 俯视角; +2. 等距感地块; +3. 单局闯关; +4. 长按蓄力,松手起跳; +5. 轻量休闲。 + +首版采用竖屏优先的移动端体验,桌面端保持居中展示,画面比例以 `9:16` 为主。参考图的核心视觉要点是: + +1. 大面积留白或浅色渐变背景; +2. 角色站在单个地块上; +3. 地块有明显顶面、侧面和投影; +4. 整体是俯视角 / 等距视角,而不是横版平台跳跃; +5. UI 克制,只保留必要控制,不堆说明文案。 + +## 3. 与拼图模板的复用边界 + +可以复用: + +1. 创作入口和模板分流; +2. 生成过程页; +3. 结果页的草稿保存、返回编辑、试玩、发布、分享链路; +4. 作品架展示和草稿恢复口径; +5. 平台统一的发布与公开展示流程。 + +不复用: + +1. 拼图关卡切片逻辑; +2. 拼图拖拽拼块逻辑; +3. 拼图 UI 背景和多关卡编辑结构; +4. 任何方格拼合语义。 + +## 4. 工程接入范围 + +首版需要做到完整玩法闭环,不只做入口占位。 + +新增前端阶段: + +```text +jump-hop-workspace +jump-hop-generating +jump-hop-result +jump-hop-runtime +jump-hop-gallery-detail +``` + +新增前端组件建议: + +1. `src/components/jump-hop-creation/JumpHopWorkspace.tsx`; +2. `src/components/jump-hop-result/JumpHopResultView.tsx`; +3. `src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx`; +4. `src/services/jump-hop/jumpHopClient.ts`。 + +新增共享契约建议: + +1. `packages/shared/src/contracts/jumpHop.ts`; +2. `server-rs/crates/shared-contracts/src/jump_hop.rs`。 + +新增后端模块建议: + +1. `server-rs/crates/module-jump-hop`:纯领域规则,包含路径生成、蓄力换算、落点判定、通关 / 失败状态机; +2. `server-rs/crates/api-server/src/jump_hop.rs` 和 `src/jump_hop/` 子模块:HTTP handler、生成编排、资产保存和 DTO 映射; +3. `server-rs/crates/spacetime-module/src/jump_hop.rs`:session、work profile、runtime run、公开 view 和 reducer / procedure; +4. `server-rs/crates/spacetime-client/src/jump_hop.rs`:api-server 访问 SpacetimeDB 的 facade; +5. `server-rs/crates/api-server/src/modules/jump_hop.rs`:路由挂载。 + +入口配置事实源必须走 SpacetimeDB `creation_entry_type_config` 默认种子和后台配置接口,不新增前端硬编码入口配置。 + +## 5. 创作输入 + +创作者需要填写以下内容: + +1. 作品主题描述,必填; +2. 角色形象描述,必填; +3. 地块风格卡,必选; +4. 难度,必选; +5. 可选的终点氛围或节奏偏好。 + +推荐的最小输入形态是: + +1. 一句话主题; +2. 角色一句话描述; +3. 风格卡; +4. 难度卡。 + +不在首版开放手工拖拽平台编辑器。平台路径、地块间距和终点位置由系统自动生成,创作者只负责风格与难度选择。 + +### 5.1 地块风格卡 + +建议提供以下风格: + +1. 极简积木; +2. 纸模玩具; +3. 霓虹玻璃; +4. 森林石块; +5. 未来金属; +6. 自定义。 + +### 5.2 难度 + +建议提供以下离散档位: + +1. 轻松; +2. 标准; +3. 进阶; +4. 挑战。 + +难度主要影响: + +1. 平台路径长度; +2. 平台间距; +3. 可落点容差; +4. 完美落点窗口; +5. 终点前的节奏变化。 + +## 6. 生成规则 + +本模板必须把生图责任拆成两条独立链路: + +### 6.1 角色形象只生一次 + +角色形象必须只调用一次生图,输出一张可直接进入运行态的主角色图。 + +角色图要求: + +1. 单人主角; +2. 全身可见; +3. 透明背景; +4. 角色站姿或轻微前倾姿态; +5. 镜头和透视必须匹配俯视角场景; +6. 不要求多视角,不要求多帧动画图集。 + +角色图生成后作为作品级锚点资产使用,结果页、封面合成、试玩和发布都复用同一张图。后续如果只修改标题、标签、难度或路径,不应默认重新生角色。只有用户在结果页明确点击“重生成角色”时,才允许再调用一次角色生图。 + +### 6.2 地块只生一次图集 + +地块必须只调用一次生图,输出一张 3D 视图的 2D 图片图集,再由后端切成运行态可用的地块资产。 + +地块图集要求: + +1. 统一使用等距 / 俯视角; +2. 必须表现出顶面、侧面和投影; +3. 必须与角色图保持同一光向; +4. 必须有清晰的立体层次,但仍然是 2D 图片; +5. 必须包含至少以下地块类型: + - 起点地块; + - 普通地块; + - 目标地块; + - 终点地块。 + +建议额外包含: + +1. 奖励地块; +2. 视觉强调地块; +3. 风格化变体地块。 + +图集生成后按地块类型切分并去掉背景,运行态直接消费切好的 PNG,不在前端做复杂拼接。只有用户在结果页明确点击“重生成地块”时,才允许再调用一次地块图集生图。 + +### 6.3 不新增第三次生成 + +首版不把封面、分享海报、路径预览再拆成第三次图像生成。封面和分享图必须由角色图 + 地块图集在本地或后端轻量合成,不额外增加新的角色生图次数。 + +### 6.4 路径元数据 + +除图片资产外,系统还必须生成跳跃路径元数据: + +1. 平台序列; +2. 平台中心点; +3. 平台宽度; +4. 平台间距; +5. 终点索引; +6. 评分和容差参数。 + +路径由领域规则自动生成,创作者不直接编辑坐标。路径元数据不依赖 LLM 或图片生成。 + +### 6.5 推荐的难度区间 + +| 难度 | 平台数量 | 平台间距 | 节奏 | +| --- | ---: | --- | --- | +| 轻松 | 12 - 14 | 短 | 宽容 | +| 标准 | 16 - 18 | 中 | 稳定 | +| 进阶 | 20 - 24 | 中长 | 紧凑 | +| 挑战 | 26 - 32 | 长 | 高压 | + +平台宽度和容差由系统按难度自动缩放,不要求创作者手工填写。 + +## 7. 契约草案 + +### 7.1 草稿结构 + +`JumpHopDraft` 至少包含: + +1. `templateId = "jump-hop"`; +2. `templateName = "跳一跳"`; +3. `profileId`; +4. `workTitle`; +5. `workDescription`; +6. `themeTags`; +7. `difficulty`; +8. `stylePreset`; +9. `characterPrompt`; +10. `tilePrompt`; +11. `characterAsset`; +12. `tileAtlasAsset`; +13. `tileAssets[]`; +14. `path`; +15. `coverComposite`; +16. `generationStatus`。 + +### 7.2 资产结构 + +`JumpHopCharacterAsset` 至少包含: + +1. `assetId`; +2. `imageSrc`; +3. `imageObjectKey`; +4. `assetObjectId`; +5. `generationProvider`; +6. `prompt`; +7. `width`; +8. `height`。 + +`JumpHopTileAsset` 至少包含: + +1. `tileType`; +2. `imageSrc`; +3. `imageObjectKey`; +4. `assetObjectId`; +5. `sourceAtlasCell`; +6. `visualWidth`; +7. `visualHeight`; +8. `topSurfaceRadius`; +9. `landingRadius`。 + +`tileType` 首版限定: + +```text +start | normal | target | finish | bonus | accent +``` + +### 7.3 路径结构 + +`JumpHopPath` 至少包含: + +1. `seed`; +2. `difficulty`; +3. `platforms[]`; +4. `finishIndex`; +5. `cameraPreset`; +6. `scoring`。 + +`JumpHopPlatform` 至少包含: + +1. `platformId`; +2. `tileType`; +3. `x`; +4. `y`; +5. `width`; +6. `height`; +7. `landingRadius`; +8. `perfectRadius`; +9. `scoreValue`。 + +### 7.4 运行态快照 + +`JumpHopRunSnapshot` 至少包含: + +1. `runId`; +2. `profileId`; +3. `status = playing | failed | cleared`; +4. `currentPlatformIndex`; +5. `score`; +6. `combo`; +7. `lastJump`; +8. `startedAtMs`; +9. `finishedAtMs`。 + +`lastJump` 至少包含: + +1. `chargeMs`; +2. `jumpDistance`; +3. `targetPlatformIndex`; +4. `landedX`; +5. `landedY`; +6. `result = miss | hit | perfect | finish`。 + +## 8. API 草案 + +HTTP 路由建议: + +```text +POST /api/creation/jump-hop/sessions +GET /api/creation/jump-hop/sessions/{sessionId} +POST /api/creation/jump-hop/sessions/{sessionId}/actions +POST /api/creation/jump-hop/works/{profileId}/publish +GET /api/runtime/jump-hop/works/{profileId} +POST /api/runtime/jump-hop/runs +POST /api/runtime/jump-hop/runs/{runId}/jump +POST /api/runtime/jump-hop/runs/{runId}/restart +GET /api/runtime/jump-hop/gallery +GET /api/runtime/jump-hop/gallery/{publicWorkCode} +``` + +动作类型建议: + +```text +compile-draft +regenerate-character +regenerate-tiles +update-work-meta +update-difficulty +``` + +`compile-draft` 是长耗时动作。前端进入生成页后必须持久化 `generationStatus=generating`,刷新后能从作品架恢复生成页。失败前需要复读 session;如果后端已经完成草稿并写回资产,前端按成功收尾。 + +## 9. SpacetimeDB 表和 view + +建议新增表: + +1. `jump_hop_agent_session`; +2. `jump_hop_work_profile`; +3. `jump_hop_runtime_run`; +4. `jump_hop_event`; +5. `jump_hop_leaderboard_entry`,首版可暂不对外展示; +6. `jump_hop_gallery_view`; +7. `jump_hop_gallery_card_view`。 + +表结构新增字段必须按 SpacetimeDB 迁移规则放在结构体末尾并设置明确默认值。新增或调整表、reducer、procedure、view 后必须同步 `migration.rs`、表目录、生成 bindings,并执行 `npm run check:spacetime-schema`。 + +公开列表主路径应优先订阅 `jump_hop_gallery_card_view` 后在 `api-server` 本地 cache 构造列表响应,不要让每个 HTTP 请求都调用 SpacetimeDB procedure 组装全量列表。 + +## 10. 结果页能力 + +结果页必须展示: + +1. 作品标题; +2. 作品简介; +3. 角色形象; +4. 地块图集; +5. 路径预览; +6. 标签; +7. 试玩; +8. 发布; +9. 返回编辑。 + +结果页还必须支持: + +1. 单独重生成角色; +2. 单独重生成地块图集; +3. 单独修改标题和简介; +4. 单独调整标签和难度。 + +结果页不应强制再走一次封面生图。封面只做合成,不新增图像生成调用。 + +## 11. 运行态规则 + +运行态采用 2D 表现,但画面视觉上必须保留参考图那种俯视角 / 等距感。 + +### 11.1 核心玩法 + +1. 玩家长按蓄力; +2. 松手后角色按蓄力长度起跳; +3. 跳跃距离决定是否落到下一个地块; +4. 落在目标区域内判定成功; +5. 落在地块外或越界判定失败; +6. 到达终点地块判定通关。 + +### 11.2 判定规则 + +1. 只做一个当前局面的起跳判定; +2. 不做复杂连招动作树; +3. 不新增生命数、体力、回合数; +4. 不新增计时赛作为首版核心规则; +5. 不把前端动画结果当成最终真相,通关与失败必须能回写运行态状态。 + +### 11.3 角色动画 + +角色不需要多帧生图,运行态只通过位移、缩放、轻微旋转和投影变化表达: + +1. 蓄力时轻微压缩; +2. 起跳时向上抬升; +3. 空中保持可读轮廓; +4. 落地时轻微弹性回弹; +5. 失败时从地块边缘跌落。 + +### 11.4 摄像机与构图 + +1. 相机以当前角色和下一地块为中心; +2. 至少保证下一个落点一直可见; +3. 画面要留出顶部和底部的 UI 安全区; +4. 不要把地块做得太满,保留参考图那种疏朗感。 + +### 11.5 UI + +运行态 UI 只保留必要元素: + +1. 分数; +2. 暂停; +3. 重新开始; +4. 分享; +5. 结算按钮。 + +不默认展示大段规则说明。首进如果需要引导,只能用一次轻量提示,不允许常驻一屏的说明文案。 + +## 12. 视觉规范 + +本模板的视觉目标是“像 3D,但仍是 2D 图片”。 + +必须遵守: + +1. 平台有明确厚度; +2. 侧面可见分层或材质变化; +3. 投影统一且方向一致; +4. 背景干净,颜色克制; +5. 角色尺寸在小屏上依然可读; +6. 地块不能出现过多文字、按钮或装饰信息; +7. 不能把运行态做成重 UI 面板。 + +建议的背景策略: + +1. 以静态浅色渐变或纯色背景为主; +2. 不把背景也做成每次都生成的重资产; +3. 让地块和角色成为画面的第一视觉焦点。 + +## 13. 发布后体验 + +发布后的作品必须支持: + +1. 进入作品架和公开展示; +2. 分享; +3. 试玩; +4. 重新进入结果页编辑。 + +发布后的卡片封面应优先由角色图和地块图合成,不要求单独再生成封面图。 + +首版不新增排行榜、回放和对局对抗。后续如要扩展排行,可另起版本,不要塞进首版模板范围。 + +## 14. 验收 + +1. 创作入口能看到 `跳一跳` 模板; +2. 创作者可以填写主题、角色描述、风格和难度; +3. 提交后只生成一次角色图和一次地块图集; +4. 结果页能看到角色图、地块图集和路径预览; +5. 结果页可单独重生成角色或地块; +6. 试玩进入跳一跳运行态; +7. 长按蓄力、松手起跳、落点判定、失败和通关都可用; +8. 作品可以保存、发布和分享; +9. 前端不直接读取或暴露生图密钥; +10. 发布后的封面不依赖第三次额外生图。 +11. `npm run check:spacetime-schema` 在 schema 变更后通过; +12. `npm run check:encoding` 通过。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index d6a26702..5aace093 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -101,7 +101,7 @@ npm run check:server-rs-ddd - `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/item_assets.rs` 承接物品生成批次编排、append / replace / delete / sort / merge、计费外层和草稿素材映射;sheet prompt、绿幕 / 近白底透明化、切图和切片持久化复用 `generated_asset_sheets` 通用模块。 - `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 和原有单测。 @@ -114,6 +114,7 @@ npm run check:server-rs-ddd 3. Adapter 输出应保留 legacy public path、object key、asset object id、MIME、extension、task id 和实际 prompt。 4. Adapter 不负责扣费、退款或钱包读取;计费仍由调用方显式包裹。 5. Puzzle、Match3D、音频、GLB、视频等复杂媒体可以复用 OSS + asset object + binding 的底层持久化能力,但玩法专属处理规则留在各自编排层,不塞进公共接口。 +6. 系列素材图集使用 `server-rs/crates/api-server/src/generated_asset_sheets.rs`:调用方必须传入 `grid_size` 作为 `n*n` 的 `n`,可选传入物品名称 prompt 模板和特殊设定 prompt;模块负责 sheet prompt 组装、按 `n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造和 sheet / item / special prompt 元数据持久化。玩法只负责规划 slot、调用具体生图 provider、计费、失败回写,以及把通用切片结果映射回自己的 DTO / 草稿 / runtime 字段。 ## SpacetimeDB schema 变更规则 @@ -151,7 +152,7 @@ npm run check:server-rs-ddd - LLM:`GENARRATIVE_LLM_*`,创意 Agent 另用 `APIMART_BASE_URL` / `APIMART_API_KEY`。 - 图片生成:VectorEngine / APIMart / DashScope,密钥只在后端环境变量中。 -- Match3D 物品 sheet:VectorEngine Gemini `gemini-3-pro-image-preview` 原生 `generateContent`。 +- Match3D 物品 sheet:VectorEngine Gemini `gemini-3-pro-image-preview` 原生 `generateContent`;图集 prompt、切图、透明化和切片持久化走 `generated_asset_sheets` 通用模块,Match3D 只补题材 / 风格 / 五视角设定和字段映射。 - Match3D 封面和 9:16 纯背景:VectorEngine `/v1/images/generations`。 - Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。 - Hyper3D / Rodin:只保留后端安全代理和旧数据兼容;新 Match3D 草稿和批量新增不再生成 GLB。 @@ -361,6 +362,40 @@ npm run check:server-rs-ddd - Rust 结构体:`InventorySlot` - 源码:`server-rs/crates/spacetime-module/src/gameplay.rs` +### `jump_hop_agent_session` + +- Rust 结构体:`JumpHopAgentSessionRow` +- 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs` + +### `jump_hop_event` + +- Rust 结构体:`JumpHopEventRow` +- 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs` + +### `jump_hop_runtime_run` + +- Rust 结构体:`JumpHopRuntimeRunRow` +- 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs` + +### `jump_hop_work_profile` + +- Rust 结构体:`JumpHopWorkProfileRow` +- 源码:`server-rs/crates/spacetime-module/src/jump_hop/tables.rs` + +### SpacetimeDB view:`jump_hop_gallery_card_view` + +- Rust view:`jump_hop_gallery_card_view` +- 返回类型:`Vec` +- 源码:`server-rs/crates/spacetime-module/src/jump_hop.rs` +- 说明:跳一跳公开广场列表卡片投影,只暴露 `publication_status = Published` 的作品卡片字段;`api-server` 的 `spacetime-client` 长期订阅 `SELECT * FROM jump_hop_gallery_card_view` 后,从本地 cache 构造跳一跳公开列表响应。个人作品列表、详情、发布和运行态仍按 procedure 路径处理。 + +### SpacetimeDB view:`jump_hop_gallery_view` + +- Rust view:`jump_hop_gallery_view` +- 返回类型:`Vec` +- 源码:`server-rs/crates/spacetime-module/src/jump_hop.rs` +- 说明:跳一跳公开详情兼容投影,包含作品、路径和素材字段;公开列表主路径优先使用 `jump_hop_gallery_card_view`。 + ### `match3d_agent_message` - Rust 结构体:`Match3DAgentMessageRow` @@ -545,6 +580,7 @@ npm run check:server-rs-ddd - `SELECT * FROM square_hole_gallery_view` - `SELECT * FROM visual_novel_gallery_view` - `SELECT * FROM big_fish_gallery_view` +- `SELECT * FROM jump_hop_gallery_card_view` 下列订阅用于统计或配置缓存,订阅失败不会让公开列表连接整体不可用,调用方保留兼容兜底: diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 6a67d84d..d50a8a60 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -40,10 +40,10 @@ npm run dev:web 单独启动 Rust API server: ```bash -npm run api-server +npm run dev:api-server ``` -后端日志默认写入 `logs/api-server/`。后端 API smoke 使用 `npm run api-server` 并检查 `/healthz`;不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。 +后端日志默认写入 `logs/api-server/`。后端 API smoke 使用 `npm run dev:api-server` 并检查 `/healthz`;不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。 查看本地 Rust / SpacetimeDB 日志: @@ -103,7 +103,7 @@ npm run spacetime:generate - `cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml` - `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` - `npm run check:server-rs-ddd` -- `npm run api-server` 后请求 `/healthz` +- `npm run dev:api-server` 后请求 `/healthz` 涉及 SpacetimeDB schema 时必须补: diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 44b8d7ce..fb67f6eb 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -10,6 +10,20 @@ `PlatformEntryFlowShellImpl.tsx` 仍是平台入口编排壳,后续维护时应优先把独立 UI 片段、公开作品映射、草稿生成 notice 和运行态状态 helper 拆到 `src/components/platform-entry/PlatformEntryFlowShellImpl/` 或同目录紧邻 helper 文件。拆分只允许改变文件组织,不改变入口配置事实源、默认导出、props、页面阶段、UI 文案或现有交互;其中拼图首访 onboarding 已拆为 `PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx`。 +## 新增玩法创作工具平台 SOP + +新增玩法默认采用表单/图片输入创作工作台,链路为: + +```text +创作入口 -> 工作台 -> 生成页 -> 结果页 -> 试玩 -> 发布 -> 运行态 +``` + +默认工作台只提交结构化表单、图片槽位和配置 payload,不默认增加聊天输入区、流式消息区或轻输入 Agent。确需偏离该模式时,必须先在 PRD 和本文档写明例外原因、影响范围和回退方式,再进入编码。 + +单图资产编辑统一通过 `CreativeImageInputPanel` 承载上传、AI 重绘、参考图、历史图和删除确认;新玩法页面不得重复手写这些交互。系列素材图集生成统一走“批量规划 -> sheet 生图 -> 后端切图 -> 透明化 -> OSS 持久化 -> 状态回写 -> 局部重生成”流程,玩法只提供 `sheetSpec`、`slotSpecs`、提示词和字段映射,不把任一玩法专属素材 DTO 当作平台通用模型。 + +`api-server` 的 `generated_asset_sheets` 是当前通用系列素材图集模块:`n` 是必选参数,模块负责组装 `n*n` sheet prompt、按 `n*n` 切片、绿幕 / 近白底透明化、导出 PNG 和 OSS 持久化请求。物品名称 prompt 和特殊设定 prompt 是可选输入;调用方可传入类似“每个物品生成五个不同视图”的视角约束,通用模块会把 sheet prompt、物品行 prompt、特殊设定 prompt 编码写入 OSS 元数据。玩法仍负责计费、物品规划、slot 映射、失败回写和把通用切片结果映射回自己的草稿 / profile / runtime 字段。 + ## 草稿与作品架 1. 草稿页作品卡对齐发现页列表卡风格:左侧信息,右侧封面图,移动端单列,桌面两到三列。 @@ -31,6 +45,7 @@ 当前口径: - 图像输入复用 `CreativeImageInputPanel`。 +- 结果页每关画面编辑和素材配置里的 UI 背景生成也复用 `CreativeImageInputPanel`;三处只共享受控 UI 模块,不共享数据源、状态、action 或存储位置:入口页继续写 `formDraft` 与草稿编译 payload,关卡画面写 `levels[].pictureReference/pictureDescription` 并触发 `generate_puzzle_images`,UI 背景写 `levels[0].uiBackgroundPrompt/uiBackgroundImage*` 并触发 `generate_puzzle_ui_background`。通用图片面板的展示图和 AI 重绘参考图能力必须分开控制:结果页正式关卡图只作为预览图,不因存在 `displayImageSrc` 自动暴露 AI 重绘开关;只有本地上传、历史选择或已保存 `pictureReference` 可作为重绘参考图时,才显示 AI 重绘开关并把状态带入 `generate_puzzle_images`。 - 支持画面描述生图、多参考图生图、上传或历史生成主图后 AI 重绘、上传或历史生成主图后不重绘;关闭 AI 重绘时,前端可提交本地上传 Data URL 或历史 `/generated-*` 图片路径,后端统一解析为首关正式图后再持久化。 - 草稿生成会先持久化 `generationStatus=generating` 的作品摘要,生成完成并回写关卡图、UI 背景后再变为 `ready`;首关关卡图和 UI 背景在命名稳定后并行启动,当前不自动生成背景音乐。 - 作品架拼图草稿的“生成中”遮罩只表示初始草稿还没有可查看结果;只要作品摘要、首关封面或任一关卡候选图已经可用,后续 UI 背景重生成和追加关卡生图都必须作为结果页局部生成态处理,不能阻止打开草稿结果页。 @@ -50,6 +65,28 @@ - 推荐页里的拼图作品如果从运行态进入“改造”结果页,返回平台后要清掉推荐嵌入态的 `activeRecommendEntryKey` / `activeRecommendRuntimeKind` / `isStartingRecommendEntry`,再重新按推荐页自动启动逻辑进入作品,不能复用已经被清空的旧 `puzzleRun`。 - 拼图运行态允许前端低延迟交互表现,但通关、排行榜、奖励和作品状态仍以后端确认为准。 +## 跳一跳 + +对外名称:`跳一跳`。工程域:`jump-hop`。PRD 见 `docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`。 + +首版定位为俯视角 / 等距视角 2D 休闲跳跃模板,链路对齐拼图的创作闭环: + +```text +创作入口 -> 模板输入 -> 生成过程页 -> 结果页 -> 试玩 -> 发布 -> 运行态 +``` + +素材生成规则固定为: + +1. 初始草稿生成时,角色形象单独调用一次生图; +2. 初始草稿生成时,地块单独调用一次生图,输出 3D 视图的 2D 图片图集; +3. 地块图集由后端切分为起点、普通、目标、终点等透明 PNG; +4. 封面和分享图由角色图与地块图轻量合成,不再额外调用第三次生图; +5. 显式重生成角色或地块时,只重生成对应资产槽位。 + +运行态规则真相必须沉到 `module-jump-hop`,前端只做蓄力表现、角色位移、投影和落地反馈。通关、失败、分数、combo、运行态快照和发布作品状态以后端为准。公开列表应走 `jump_hop_gallery_card_view` 订阅缓存,不要每次 HTTP 请求调用 procedure 组装全量列表。 + +平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `sourceType='jump-hop'` 与 `JH-*` 公开作品号识别跳一跳作品;从公开详情或推荐流启动运行态时,若卡片摘要不足以携带角色图、地块图集和路径配置,必须先补读完整 work profile 再传入运行态。 + ## 抓大鹅 Match3D 对外名称:`抓大鹅`。工程域:`match3d`。 @@ -75,12 +112,13 @@ 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 边界带为种子,把连通的浅绿 / 近白抗锯齿边直接改为透明,并对贴透明背景的弱绿 / 暗绿轮廓像素做去绿污染处理,最后按剩余可见主体二次收紧;不要先裁剪单格再各自去绿。 -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` 读取。草稿编译后的 `draftJson` 自身也必须携带 `generatedItemAssets` 快照;HTTP facade 不能只依赖 work detail 回读补齐 UI 资产,外部回读为空时也不得清空草稿内已有的背景 / 容器图。平台壳层从作品架、广场、生成完成回调、结果页保存 / 发布 / 试玩回调进入 Match3D profile 时也要先归一化并提升,避免首次试玩、手动试玩、推荐流或公开详情运行态退回默认背景 / 默认容器。 +5. 物品 sheet prompt、切图、透明化和五视角切片持久化复用 `generated_asset_sheets` 通用模块;Match3D 只传入题材 / 风格 subject、物品行 prompt 模板和“同一行五格必须是同一物品五个不同视角”的特殊设定,并把通用切片结果映射回 `generatedItemAssets[].imageViews[]`。 +6. 切图前先在整张 sheet 上做绿幕 / 近白底透明化和边缘去污染,再按格子导出独立 PNG;每个视角图再以扩大的 PNG 边界带为种子,把连通的浅绿 / 近白抗锯齿边直接改为透明,并对贴透明背景的弱绿 / 暗绿轮廓像素做去绿污染处理,最后按剩余可见主体二次收紧;不要先裁剪单格再各自去绿。 +7. `generatedItemAssets[].imageViews[]` 是新素材主字段,`imageSrc/imageObjectKey` 只兼容首张视角。 +8. 文本生成物品名称时必须同时生成 `itemSize`,只允许 `大`、`中`、`小`。该字段随 `generatedItemAssets[].itemSize` 持久化并下发;历史缺失字段的素材按 `大` 兼容,模型缺失或非法值按物品名本地推断。 +9. 局内 `9:16` 纯背景图和 `1:1` 中心容器 UI 图分开生成。纯背景不得包含锅、盘、托盘、HUD、按钮、文字或物品,且入库前必须合成为全画幅不透明图片,不允许出现透明区域;容器图走 `/v1/images/edits` 参考透明容器图。 +10. 当前抓大鹅音频生成关闭:入口无 `生成音效`,草稿不生成背景音乐或点击音效,结果页不展示背景音乐 Tab 或点击音效生成入口。历史 `backgroundMusic` / `clickSound` 字段继续兼容传递。 +11. UI 背景和容器资产的持久化真相仍在 `generatedItemAssets[].backgroundAsset`;Agent session、work summary/detail、结果页和运行态入口都必须把该字段提升为 `backgroundImageSrc/backgroundImageObjectKey/generatedBackgroundAsset` 读取。草稿编译后的 `draftJson` 自身也必须携带 `generatedItemAssets` 快照;HTTP facade 不能只依赖 work detail 回读补齐 UI 资产,外部回读为空时也不得清空草稿内已有的背景 / 容器图。平台壳层从作品架、广场、生成完成回调、结果页保存 / 发布 / 试玩回调进入 Match3D profile 时也要先归一化并提升,避免首次试玩、手动试玩、推荐流或公开详情运行态退回默认背景 / 默认容器。 结果页当前结构: diff --git a/packages/shared/src/contracts/index.ts b/packages/shared/src/contracts/index.ts index 105b187b..8f8b20e8 100644 --- a/packages/shared/src/contracts/index.ts +++ b/packages/shared/src/contracts/index.ts @@ -1,6 +1,7 @@ export type * from './creativeAgent'; export type * from './creationAudio'; export type * from './hyper3d'; +export type * from './jumpHop'; export type * from './puzzleCreativeTemplate'; export type * from './visualNovel'; export type * from './barkBattle'; diff --git a/packages/shared/src/contracts/jumpHop.ts b/packages/shared/src/contracts/jumpHop.ts new file mode 100644 index 00000000..856e04bf --- /dev/null +++ b/packages/shared/src/contracts/jumpHop.ts @@ -0,0 +1,262 @@ +export type JumpHopDifficulty = 'easy' | 'standard' | 'advanced' | 'challenge'; + +export type JumpHopStylePreset = + | 'minimal-blocks' + | 'paper-toy' + | 'neon-glass' + | 'forest-stone' + | 'future-metal' + | 'custom'; + +export type JumpHopGenerationStatus = + | 'draft' + | 'generating' + | 'ready' + | 'failed'; + +export type JumpHopTileType = + | 'start' + | 'normal' + | 'target' + | 'finish' + | 'bonus' + | 'accent'; + +export type JumpHopActionType = + | 'compile-draft' + | 'regenerate-character' + | 'regenerate-tiles' + | 'update-work-meta' + | 'update-difficulty'; + +export type JumpHopRunStatus = 'playing' | 'failed' | 'cleared'; + +export type JumpHopJumpResult = 'miss' | 'hit' | 'perfect' | 'finish'; + +export interface JumpHopWorkspaceCreateRequest { + templateId: string; + workTitle: string; + workDescription: string; + themeTags: string[]; + difficulty: JumpHopDifficulty; + stylePreset: JumpHopStylePreset; + characterPrompt: string; + tilePrompt: string; + endMoodPrompt?: string | null; +} + +export interface JumpHopActionRequest { + actionType: JumpHopActionType; + workTitle?: string | null; + workDescription?: string | null; + themeTags?: string[] | null; + difficulty?: JumpHopDifficulty | null; + stylePreset?: JumpHopStylePreset | null; + characterPrompt?: string | null; + tilePrompt?: string | null; + endMoodPrompt?: string | null; +} + +export interface JumpHopCharacterAsset { + assetId: string; + imageSrc: string; + imageObjectKey: string; + assetObjectId: string; + generationProvider: string; + prompt: string; + width: number; + height: number; +} + +export interface JumpHopTileAsset { + tileType: JumpHopTileType; + imageSrc: string; + imageObjectKey: string; + assetObjectId: string; + sourceAtlasCell: string; + visualWidth: number; + visualHeight: number; + topSurfaceRadius: number; + landingRadius: number; +} + +export interface JumpHopScoring { + chargeToDistanceRatio: number; + maxChargeMs: number; + hitBonus: number; + perfectBonus: number; +} + +export interface JumpHopPlatform { + platformId: string; + tileType: JumpHopTileType; + x: number; + y: number; + width: number; + height: number; + landingRadius: number; + perfectRadius: number; + scoreValue: number; +} + +export interface JumpHopPath { + seed: string; + difficulty: JumpHopDifficulty; + platforms: JumpHopPlatform[]; + finishIndex: number; + cameraPreset: string; + scoring: JumpHopScoring; +} + +export interface JumpHopLastJump { + chargeMs: number; + jumpDistance: number; + targetPlatformIndex: number; + landedX: number; + landedY: number; + result: JumpHopJumpResult; +} + +export interface JumpHopDraftResponse { + templateId: string; + templateName: string; + profileId: string | null; + workTitle: string; + workDescription: string; + themeTags: string[]; + difficulty: JumpHopDifficulty; + stylePreset: JumpHopStylePreset; + characterPrompt: string; + tilePrompt: string; + endMoodPrompt: string | null; + characterAsset: JumpHopCharacterAsset | null; + tileAtlasAsset: JumpHopCharacterAsset | null; + tileAssets: JumpHopTileAsset[]; + path: JumpHopPath | null; + coverComposite: string | null; + generationStatus: JumpHopGenerationStatus; +} + +export interface JumpHopSessionSnapshotResponse { + sessionId: string; + ownerUserId: string; + status: JumpHopGenerationStatus; + draft: JumpHopDraftResponse | null; + createdAt: string; + updatedAt: string; +} + +export interface JumpHopSessionResponse { + session: JumpHopSessionSnapshotResponse; +} + +export interface JumpHopActionResponse { + actionType: JumpHopActionType; + session: JumpHopSessionSnapshotResponse; + work: JumpHopWorkProfileResponse | null; +} + +export interface JumpHopWorkSummaryResponse { + runtimeKind: 'jump-hop'; + workId: string; + profileId: string; + ownerUserId: string; + sourceSessionId: string | null; + workTitle: string; + workDescription: string; + themeTags: string[]; + difficulty: JumpHopDifficulty; + stylePreset: JumpHopStylePreset; + coverImageSrc: string | null; + publicationStatus: string; + playCount: number; + updatedAt: string; + publishedAt: string | null; + publishReady: boolean; + generationStatus: JumpHopGenerationStatus; +} + +export interface JumpHopWorkProfileResponse { + summary: JumpHopWorkSummaryResponse; + draft: JumpHopDraftResponse; + path: JumpHopPath; + characterAsset: JumpHopCharacterAsset; + tileAtlasAsset: JumpHopCharacterAsset; + tileAssets: JumpHopTileAsset[]; +} + +export interface JumpHopWorksResponse { + items: JumpHopWorkSummaryResponse[]; +} + +export interface JumpHopWorkDetailResponse { + item: JumpHopWorkProfileResponse; +} + +export interface JumpHopWorkMutationResponse { + item: JumpHopWorkProfileResponse; +} + +export interface JumpHopGalleryCardResponse { + publicWorkCode: string; + workId: string; + profileId: string; + ownerUserId: string; + authorDisplayName: string; + workTitle: string; + workDescription: string; + coverImageSrc: string | null; + themeTags: string[]; + difficulty: JumpHopDifficulty; + stylePreset: JumpHopStylePreset; + publicationStatus: string; + playCount: number; + updatedAt: string; + publishedAt: string | null; + generationStatus: JumpHopGenerationStatus; +} + +export interface JumpHopGalleryResponse { + items: JumpHopGalleryCardResponse[]; + hasMore: boolean; + nextCursor: string | null; +} + +export interface JumpHopGalleryDetailResponse { + item: JumpHopWorkProfileResponse; +} + +export interface JumpHopRuntimeRunSnapshotResponse { + runId: string; + profileId: string; + ownerUserId: string; + status: JumpHopRunStatus; + currentPlatformIndex: number; + score: number; + combo: number; + path: JumpHopPath; + lastJump: JumpHopLastJump | null; + startedAtMs: number; + finishedAtMs: number | null; +} + +export interface JumpHopRunResponse { + run: JumpHopRuntimeRunSnapshotResponse; +} + +export interface JumpHopStartRunRequest { + profileId: string; +} + +export interface JumpHopJumpRequest { + chargeMs: number; + clientEventId: string; +} + +export interface JumpHopRestartRunRequest { + clientActionId: string; +} + +export interface JumpHopJumpResponse { + run: JumpHopRuntimeRunSnapshotResponse; +} diff --git a/scripts/check-spacetime-schema-guard.mjs b/scripts/check-spacetime-schema-guard.mjs index 72cdfc34..6f72ac8d 100644 --- a/scripts/check-spacetime-schema-guard.mjs +++ b/scripts/check-spacetime-schema-guard.mjs @@ -474,11 +474,11 @@ function loadBaseSources(baseRef) { } function getChangedFiles(baseRef) { - const diffOutput = tryGit(['diff', '--name-only', baseRef, '--']) ?? ''; + const diffOutput = tryGit(['diff', '--name-only', '-z', baseRef, '--']) ?? ''; const untrackedOutput = - tryGit(['ls-files', '--others', '--exclude-standard', moduleSrcRoot]) ?? ''; + tryGit(['ls-files', '--others', '--exclude-standard', '-z', moduleSrcRoot]) ?? ''; return new Set( - [...diffOutput.split(/\r?\n/u), ...untrackedOutput.split(/\r?\n/u)] + [...diffOutput.split(/\u0000/u), ...untrackedOutput.split(/\u0000/u)] .map(normalizePath) .filter(Boolean), ); diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index a74d29db..6cb28534 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -1827,6 +1827,15 @@ dependencies = [ "spacetimedb", ] +[[package]] +name = "module-jump-hop" +version = "0.1.0" +dependencies = [ + "serde", + "shared-kernel", + "spacetimedb", +] + [[package]] name = "module-match3d" version = "0.1.0" @@ -3259,6 +3268,7 @@ dependencies = [ "module-combat", "module-custom-world", "module-inventory", + "module-jump-hop", "module-match3d", "module-npc", "module-puzzle", @@ -3291,6 +3301,7 @@ dependencies = [ "module-combat", "module-custom-world", "module-inventory", + "module-jump-hop", "module-match3d", "module-npc", "module-progression", diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index bddf6c17..1531dfc3 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -17,6 +17,7 @@ members = [ "crates/module-creative-agent", "crates/module-inventory", "crates/module-custom-world", + "crates/module-jump-hop", "crates/module-match3d", "crates/module-npc", "crates/module-puzzle", @@ -57,6 +58,7 @@ module-combat = { path = "crates/module-combat", default-features = false } module-creative-agent = { path = "crates/module-creative-agent", default-features = false } module-custom-world = { path = "crates/module-custom-world", default-features = false } module-inventory = { path = "crates/module-inventory", default-features = false } +module-jump-hop = { path = "crates/module-jump-hop", default-features = false } module-match3d = { path = "crates/module-match3d", default-features = false } module-npc = { path = "crates/module-npc", default-features = false } module-progression = { path = "crates/module-progression", default-features = false } diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index e5e4f27c..d540f418 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -59,6 +59,7 @@ pub fn build_router(state: AppState) -> Router { .merge(modules::bark_battle::router(state.clone())) .merge(modules::match3d::router(state.clone())) .merge(modules::square_hole::router(state.clone())) + .merge(modules::jump_hop::router(state.clone())) .merge(modules::puzzle::router(state.clone())) .merge(visual_novel_router(state.clone())) .route( diff --git a/server-rs/crates/api-server/src/creation_entry_config.rs b/server-rs/crates/api-server/src/creation_entry_config.rs index eba4531a..c470beba 100644 --- a/server-rs/crates/api-server/src/creation_entry_config.rs +++ b/server-rs/crates/api-server/src/creation_entry_config.rs @@ -87,6 +87,12 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> { if normalized.starts_with("/api/runtime/square-hole") { return Some("square-hole"); } + if normalized.starts_with("/api/runtime/jump-hop") { + return Some("jump-hop"); + } + if normalized.starts_with("/api/creation/jump-hop") { + return Some("jump-hop"); + } if normalized.starts_with("/api/runtime/big-fish") { return Some("big-fish"); } diff --git a/server-rs/crates/api-server/src/generated_asset_sheets.rs b/server-rs/crates/api-server/src/generated_asset_sheets.rs new file mode 100644 index 00000000..a5308897 --- /dev/null +++ b/server-rs/crates/api-server/src/generated_asset_sheets.rs @@ -0,0 +1,1665 @@ +#![allow(dead_code)] + +use std::{collections::BTreeMap, time::Duration}; + +use axum::http::StatusCode; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use image::{GenericImageView, ImageFormat}; +use platform_oss::{LegacyAssetPrefix, OssObjectAccess, OssPutObjectRequest}; +use serde_json::json; + +use crate::{ + http_error::AppError, openai_image_generation::DownloadedOpenAiImage, + platform_errors::map_oss_error, state::AppState, +}; + +const GENERATED_ASSET_SHEET_PROVIDER: &str = "generated-asset-sheets"; +const GENERATED_ASSET_SHEET_OSS_PUT_TIMEOUT_MS: u64 = 3 * 60_000; +const GENERATED_ASSET_SHEET_FOREGROUND_DIFF_THRESHOLD: i32 = 36; +const GENERATED_ASSET_SHEET_FOREGROUND_ALPHA_THRESHOLD: i32 = 36; +const GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE: f32 = 0.34; +const GENERATED_ASSET_SHEET_GREEN_SCREEN_SOFT_SCORE: f32 = 0.18; +const GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE: f32 = 0.82; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct GeneratedAssetSheetPromptInput<'a> { + pub(crate) subject_text: &'a str, + pub(crate) item_names: &'a [String], + pub(crate) grid_size: usize, + pub(crate) item_name_prompt_template: Option<&'a str>, + pub(crate) special_prompt: Option<&'a str>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct GeneratedAssetSheetSliceImage { + pub(crate) bytes: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct GeneratedAssetSheetUpload { + pub(crate) src: String, + pub(crate) object_key: String, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct GeneratedAssetSheetPersistPrompt { + pub(crate) sheet_prompt: Option, + pub(crate) item_name_prompt: Option, + pub(crate) special_prompt: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct GeneratedAssetSheetPersistInput { + pub(crate) prefix: LegacyAssetPrefix, + pub(crate) owner_user_id: String, + pub(crate) session_id: String, + pub(crate) profile_id: String, + pub(crate) path_segments: Vec, + pub(crate) file_name: String, + pub(crate) content_type: String, + pub(crate) bytes: Vec, + pub(crate) asset_kind: String, + pub(crate) source_job_id: Option, + pub(crate) generated_at_micros: i64, + pub(crate) grid_size: usize, + pub(crate) row_index: usize, + pub(crate) view_index: usize, + pub(crate) prompt: GeneratedAssetSheetPersistPrompt, +} + +pub(crate) fn build_generated_asset_sheet_prompt( + input: &GeneratedAssetSheetPromptInput<'_>, +) -> Result { + let grid_size = input.grid_size; + if grid_size == 0 { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": GENERATED_ASSET_SHEET_PROVIDER, + "message": "系列素材图集的 n 必须大于 0。", + })), + ); + } + if input.item_names.len() > grid_size { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": GENERATED_ASSET_SHEET_PROVIDER, + "message": "系列素材图集的物品行数不能超过 n。", + "gridSize": grid_size, + "itemCount": input.item_names.len(), + })), + ); + } + + let subject_text = input.subject_text.trim(); + let subject_text = if subject_text.is_empty() { + "系列素材" + } else { + subject_text + }; + let item_rows = input + .item_names + .iter() + .enumerate() + .map(|(index, item_name)| { + let row_index = index + 1; + let item_name = item_name.trim(); + if let Some(template) = input + .item_name_prompt_template + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return template + .replace("{row_index}", row_index.to_string().as_str()) + .replace("{item_name}", item_name) + .replace("{view_count}", grid_size.to_string().as_str()); + } + format!("第{row_index}行:{item_name} 的 {grid_size} 个不同视图") + }) + .collect::>() + .join(";"); + let special_prompt = input + .special_prompt + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("每个物品生成 {grid_size} 个不同视图。")); + + Ok(format!( + "生成一张1:1图片。固定生成{grid_size}行*{grid_size}列网格素材图,画面是{subject_text}。严格{grid_size}*{grid_size}均匀排布,严格按行组织:{item_rows}。{special_prompt}每个格子一个独立居中的完整素材,每格背景必须是统一纯绿色绿幕背景(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无道具,方便后续抠成透明。素材本身不得使用与绿幕相同的纯绿色;若素材天然含绿色,必须使用更深、更黄或更蓝的绿色并用清晰描边与绿幕区分。统一柔和光照,清晰轮廓,适合直接切割成游戏2D素材。请让每个素材完整落在自己的格子中央,四周保留留白,相邻素材主体之间必须至少保留单个素材格宽度的1/4空白间距(约25%单格宽度),包含左右相邻格和上下相邻行,素材主体不得占满格子。禁止主体跨格、贴边或越界,禁止任何内容进入相邻格子影响裁剪后的效果。不要出现文字、水印、UI、边框、网格线、标签、底座、场景或其他物体。" + )) +} + +pub(crate) fn slice_generated_asset_sheet( + image: &DownloadedOpenAiImage, + item_names: &[String], + grid_size: usize, +) -> Result>, AppError> { + if grid_size == 0 { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": GENERATED_ASSET_SHEET_PROVIDER, + "message": "系列素材图集的 n 必须大于 0。", + })), + ); + } + + let grid_size_u32 = u32::try_from(grid_size).map_err(|_| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": GENERATED_ASSET_SHEET_PROVIDER, + "message": "系列素材图集的 n 超出可支持范围。", + })) + })?; + let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": GENERATED_ASSET_SHEET_PROVIDER, + "message": format!("系列素材图集解码失败:{error}"), + })) + })?; + let source = apply_generated_asset_sheet_green_screen_alpha(source); + let (width, height) = source.dimensions(); + let cell_width = width / grid_size_u32; + let cell_height = height / grid_size_u32; + if cell_width == 0 || cell_height == 0 { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": GENERATED_ASSET_SHEET_PROVIDER, + "message": "系列素材图集尺寸过小,无法切割。", + })), + ); + } + + let mut slices = Vec::with_capacity(item_names.len().min(grid_size)); + for item_index in 0..item_names.len().min(grid_size) { + let row = item_index as u32; + let mut views = Vec::with_capacity(grid_size); + for view_index in 0..grid_size { + let col = view_index as u32; + let (crop_x, crop_y, crop_width, crop_height) = + resolve_generated_asset_sheet_cell_crop(&source, grid_size_u32, row, col); + let cropped = source.crop_imm(crop_x, crop_y, crop_width, crop_height); + let cleaned = crop_generated_asset_sheet_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": GENERATED_ASSET_SHEET_PROVIDER, + "message": format!("系列素材图集切割失败:{error}"), + })) + })?; + views.push(GeneratedAssetSheetSliceImage { + bytes: cursor.into_inner(), + }); + } + slices.push(views); + } + + Ok(slices) +} + +pub(crate) fn crop_generated_asset_sheet_view_edge_matte( + image: image::DynamicImage, +) -> image::DynamicImage { + let mut image = image.to_rgba8(); + let (width, height) = image.dimensions(); + remove_generated_asset_sheet_view_edge_matte(image.as_mut(), width as usize, height as usize); + let bounds = detect_generated_asset_sheet_visible_bounds(&image).unwrap_or_else(|| { + GeneratedAssetSheetCellBounds { + 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(), + ) +} + +pub(crate) fn prepare_generated_asset_sheet_put_request( + input: GeneratedAssetSheetPersistInput, +) -> Result { + if input.grid_size == 0 { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": GENERATED_ASSET_SHEET_PROVIDER, + "message": "系列素材图集的 n 必须大于 0。", + })), + ); + } + if input.row_index == 0 + || input.view_index == 0 + || input.row_index > input.grid_size + || input.view_index > input.grid_size + { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": GENERATED_ASSET_SHEET_PROVIDER, + "message": "系列素材图集持久化的行列索引必须落在 n*n 范围内。", + "gridSize": input.grid_size, + "rowIndex": input.row_index, + "viewIndex": input.view_index, + })), + ); + } + + let mut metadata = BTreeMap::new(); + metadata.insert( + "x-oss-meta-asset-kind".to_string(), + input.asset_kind.clone(), + ); + metadata.insert( + "x-oss-meta-owner-user-id".to_string(), + input.owner_user_id.clone(), + ); + metadata.insert( + "x-oss-meta-profile-id".to_string(), + input.profile_id.clone(), + ); + metadata.insert( + "x-oss-meta-generated-asset-sheet-grid-size".to_string(), + input.grid_size.to_string(), + ); + metadata.insert( + "x-oss-meta-generated-asset-sheet-row-index".to_string(), + input.row_index.to_string(), + ); + metadata.insert( + "x-oss-meta-generated-asset-sheet-view-index".to_string(), + input.view_index.to_string(), + ); + metadata.insert( + "x-oss-meta-generated-at-micros".to_string(), + input.generated_at_micros.to_string(), + ); + if let Some(source_job_id) = input + .source_job_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + metadata.insert( + "x-oss-meta-source-job-id".to_string(), + source_job_id.to_string(), + ); + } + insert_generated_asset_sheet_prompt_metadata( + &mut metadata, + "generated-asset-sheet-prompt-b64", + input.prompt.sheet_prompt.as_deref(), + ); + insert_generated_asset_sheet_prompt_metadata( + &mut metadata, + "generated-asset-sheet-item-name-prompt-b64", + input.prompt.item_name_prompt.as_deref(), + ); + insert_generated_asset_sheet_prompt_metadata( + &mut metadata, + "generated-asset-sheet-special-prompt-b64", + input.prompt.special_prompt.as_deref(), + ); + if input.prompt.sheet_prompt.is_some() + || input.prompt.item_name_prompt.is_some() + || input.prompt.special_prompt.is_some() + { + metadata.insert( + "x-oss-meta-generated-asset-sheet-prompt-encoding".to_string(), + "utf8-base64".to_string(), + ); + } + + Ok(OssPutObjectRequest { + prefix: input.prefix, + path_segments: std::iter::once(input.session_id.as_str()) + .chain(std::iter::once(input.profile_id.as_str())) + .chain(input.path_segments.iter().map(String::as_str)) + .map(|segment| sanitize_generated_asset_sheet_path_segment(segment, "asset")) + .collect(), + file_name: input.file_name, + content_type: Some(input.content_type), + access: OssObjectAccess::Private, + metadata, + body: input.bytes, + }) +} + +pub(crate) async fn persist_generated_asset_sheet_bytes( + state: &AppState, + input: GeneratedAssetSheetPersistInput, +) -> 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 put_request = prepare_generated_asset_sheet_put_request(input)?; + let oss_http_client = reqwest::Client::builder() + .timeout(Duration::from_millis( + GENERATED_ASSET_SHEET_OSS_PUT_TIMEOUT_MS, + )) + .build() + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": GENERATED_ASSET_SHEET_PROVIDER, + "message": format!("构造系列素材图集 OSS 上传客户端失败:{error}"), + })) + })?; + let put_result = oss_client + .put_object(&oss_http_client, put_request) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + + Ok(GeneratedAssetSheetUpload { + src: put_result.legacy_public_path, + object_key: put_result.object_key, + }) +} + +fn insert_generated_asset_sheet_prompt_metadata( + metadata: &mut BTreeMap, + key: &str, + value: Option<&str>, +) { + let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { + return; + }; + metadata.insert( + format!("x-oss-meta-{key}"), + BASE64_STANDARD.encode(value.as_bytes()), + ); +} + +#[derive(Clone, Copy, Debug)] +struct GeneratedAssetSheetCellBounds { + x0: u32, + y0: u32, + x1: u32, + y1: u32, +} + +impl GeneratedAssetSheetCellBounds { + 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_generated_asset_sheet_cell_crop( + source: &image::DynamicImage, + grid_size: u32, + row: u32, + col: u32, +) -> (u32, u32, u32, u32) { + let (image_width, image_height) = source.dimensions(); + let cell = + resolve_generated_asset_sheet_cell_bounds(image_width, image_height, grid_size, row, col); + let Some(foreground) = detect_generated_asset_sheet_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 = GeneratedAssetSheetCellBounds { + 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 resolve_generated_asset_sheet_cell_bounds( + image_width: u32, + image_height: u32, + grid_size: u32, + row: u32, + col: u32, +) -> GeneratedAssetSheetCellBounds { + let normalized_grid_size = grid_size.max(1); + let cell_x0 = col.saturating_mul(image_width) / normalized_grid_size; + let cell_x1 = (col.saturating_add(1)).saturating_mul(image_width) / normalized_grid_size; + let cell_y0 = row.saturating_mul(image_height) / normalized_grid_size; + let cell_y1 = (row.saturating_add(1)).saturating_mul(image_height) / normalized_grid_size; + + GeneratedAssetSheetCellBounds { + 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_generated_asset_sheet_foreground_bounds( + source: &image::DynamicImage, + cell: GeneratedAssetSheetCellBounds, +) -> Option { + let background = sample_generated_asset_sheet_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_generated_asset_sheet_foreground_pixel(source.get_pixel(x, y).0, background) { + continue; + } + foreground_pixels = foreground_pixels.saturating_add(1); + foreground = Some(match foreground { + Some(bounds) => GeneratedAssetSheetCellBounds { + 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 => GeneratedAssetSheetCellBounds { + 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_generated_asset_sheet_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_generated_asset_sheet_visible_pixel(pixel) { + continue; + } + visible_pixels = visible_pixels.saturating_add(1); + bounds = Some(match bounds { + Some(current) => GeneratedAssetSheetCellBounds { + 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 => GeneratedAssetSheetCellBounds { + 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_generated_asset_sheet_cell_background( + source: &image::DynamicImage, + cell: GeneratedAssetSheetCellBounds, +) -> [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_generated_asset_sheet_unit(value: f32) -> f32 { + value.clamp(0.0, 1.0) +} + +fn lerp_generated_asset_sheet_channel(from: f32, to: f32, t: f32) -> f32 { + from + (to - from) * clamp_generated_asset_sheet_unit(t) +} + +fn is_generated_asset_sheet_foreground_pixel(pixel: [u8; 4], background: [u8; 4]) -> bool { + let alpha_diff = pixel[3] as i32 - background[3] as i32; + if alpha_diff.abs() >= GENERATED_ASSET_SHEET_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 >= GENERATED_ASSET_SHEET_FOREGROUND_DIFF_THRESHOLD +} + +fn remove_generated_asset_sheet_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_generated_asset_sheet_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_generated_asset_sheet_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_generated_asset_sheet_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_generated_asset_sheet_view_background_pixel([ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]) { + continue; + } + + if touches_generated_asset_sheet_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_generated_asset_sheet_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_generated_asset_sheet_green_contaminated_edge_pixel(pixel) { + continue; + } + if !touches_generated_asset_sheet_background_mask( + x, + y, + width, + height, + &background_mask, + ) { + continue; + } + + if is_generated_asset_sheet_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_generated_asset_sheet_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_generated_asset_sheet_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_generated_asset_sheet_view_background_pixel(pixel: [u8; 4]) -> bool { + pixel[3] < 16 + || is_generated_asset_sheet_soft_edge_pixel(pixel) + || compute_generated_asset_sheet_white_screen_score(pixel) > 0.18 +} + +fn is_generated_asset_sheet_visible_pixel(pixel: [u8; 4]) -> bool { + pixel[3] > 0 && (pixel[0] > 8 || pixel[1] > 8 || pixel[2] > 8) +} + +fn is_generated_asset_sheet_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_generated_asset_sheet_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_generated_asset_sheet_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_generated_asset_sheet_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_generated_asset_sheet_green_contaminated_edge_pixel(pixel) + || is_generated_asset_sheet_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_generated_asset_sheet_green_screen_alpha( + source: image::DynamicImage, +) -> image::DynamicImage { + let mut image = source.to_rgba8(); + let (width, height) = image.dimensions(); + remove_generated_asset_sheet_green_screen_background( + image.as_mut(), + width as usize, + height as usize, + ); + image::DynamicImage::ImageRgba8(image) +} + +fn remove_generated_asset_sheet_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_generated_asset_sheet_green_screen_score([red, green, blue, alpha]); + let white_score = + compute_generated_asset_sheet_white_screen_score([red, green, blue, alpha]); + let transparency_hint = + clamp_generated_asset_sheet_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] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE + || (alpha < 224 + && green_scores[pixel_index] > GENERATED_ASSET_SHEET_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 >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE + || (alpha < 224 && green_score > GENERATED_ASSET_SHEET_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); + } + } + } + + for pixel_index in 0..pixel_count { + if background_mask[pixel_index] == 0 + && green_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE + { + background_mask[pixel_index] = 1; + } + } + + 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_generated_asset_sheet_soft_green_matte_pixel(pixel, green_score, white_score) + { + continue; + } + if !touches_generated_asset_sheet_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 >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE; + if hint < GENERATED_ASSET_SHEET_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 >= GENERATED_ASSET_SHEET_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_generated_asset_sheet_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_generated_asset_sheet_unit(contamination.max(0.22)); + + if let Some((sample_red, sample_green, sample_blue)) = sample { + red = lerp_generated_asset_sheet_channel(red, sample_red as f32, blend); + green = lerp_generated_asset_sheet_channel(green, sample_green as f32, blend); + blue = lerp_generated_asset_sheet_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_generated_asset_sheet_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_generated_asset_sheet_soft_green_matte_pixel( + pixel: [u8; 4], + green_score: f32, + white_score: f32, +) -> bool { + if pixel[3] == 0 || green_score < GENERATED_ASSET_SHEET_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_generated_asset_sheet_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_generated_asset_sheet_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_generated_asset_sheet_unit((spread - 6.0) / 34.0); + let brightness = clamp_generated_asset_sheet_unit((average - 188.0) / 55.0); + let floor = clamp_generated_asset_sheet_unit((min_channel - 168.0) / 60.0); + clamp_generated_asset_sheet_unit(neutrality * (brightness * 0.85 + floor * 0.15)) +} + +fn collect_generated_asset_sheet_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, + )) +} + +fn sanitize_generated_asset_sheet_path_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() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn build_test_image(width: u32, height: u32, color: [u8; 4]) -> image::RgbaImage { + image::RgbaImage::from_pixel(width, height, image::Rgba(color)) + } + + #[test] + fn generated_asset_sheet_prompt_uses_default_rows_and_special_instruction() { + let item_names = vec!["草莓".to_string(), "苹果".to_string()]; + + let prompt = build_generated_asset_sheet_prompt(&GeneratedAssetSheetPromptInput { + subject_text: "水果题材的抓大鹅 2D 物品素材", + item_names: &item_names, + grid_size: 5, + item_name_prompt_template: None, + special_prompt: None, + }) + .expect("prompt should build"); + + assert!(prompt.contains("5行*5列")); + assert!(prompt.contains("第1行:草莓 的 5 个不同视图")); + assert!(prompt.contains("第2行:苹果 的 5 个不同视图")); + assert!(prompt.contains("每个物品生成 5 个不同视图")); + } + + #[test] + fn generated_asset_sheet_prompt_allows_custom_row_template_and_special_prompt() { + let item_names = vec!["草莓".to_string()]; + + let prompt = build_generated_asset_sheet_prompt(&GeneratedAssetSheetPromptInput { + subject_text: "水果题材的抓大鹅 2D 物品素材", + item_names: &item_names, + grid_size: 5, + item_name_prompt_template: Some( + "第{row_index}行是 {item_name},共 {view_count} 个视图", + ), + special_prompt: Some("每个物品要生成五个不同视图:正面、左前、右前、俯视、背面。"), + }) + .expect("prompt should build"); + + assert!(prompt.contains("第1行是 草莓,共 5 个视图")); + assert!(prompt.contains("每个物品要生成五个不同视图")); + } + + #[test] + fn generated_asset_sheet_prompt_rejects_zero_grid_size() { + let item_names = vec!["草莓".to_string()]; + + let error = build_generated_asset_sheet_prompt(&GeneratedAssetSheetPromptInput { + subject_text: "水果题材的抓大鹅 2D 物品素材", + item_names: &item_names, + grid_size: 0, + item_name_prompt_template: None, + special_prompt: None, + }) + .expect_err("grid size 0 should be rejected"); + + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + } + + #[test] + fn generated_asset_sheet_slices_by_requested_grid_size() { + let width = 500; + let height = 500; + let item_names = vec!["樱桃".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_generated_asset_sheet(&image, &item_names, 5).expect("sheet should slice"); + + assert_eq!(slices.len(), 2); + assert_eq!(slices[0].len(), 5); + assert_eq!(slices[1].len(), 5); + } + + #[test] + fn generated_asset_sheet_prepare_put_request_packs_prompt_metadata() { + let request = prepare_generated_asset_sheet_put_request(GeneratedAssetSheetPersistInput { + prefix: LegacyAssetPrefix::Match3DAssets, + owner_user_id: "user-1".to_string(), + session_id: "session-1".to_string(), + profile_id: "profile-1".to_string(), + path_segments: vec!["items".to_string(), "view".to_string()], + file_name: "view-01.png".to_string(), + content_type: "image/png".to_string(), + bytes: b"sheet-bytes".to_vec(), + asset_kind: "match3d_item_image_view".to_string(), + source_job_id: Some("task-1".to_string()), + generated_at_micros: 123, + grid_size: 5, + row_index: 1, + view_index: 2, + prompt: GeneratedAssetSheetPersistPrompt { + sheet_prompt: Some("sheet prompt".to_string()), + item_name_prompt: Some("item prompt".to_string()), + special_prompt: Some("special prompt".to_string()), + }, + }) + .expect("request should prepare"); + + assert_eq!( + request + .metadata + .get("x-oss-meta-generated-asset-sheet-prompt-encoding"), + Some(&"utf8-base64".to_string()) + ); + assert_eq!( + request + .metadata + .get("x-oss-meta-generated-asset-sheet-grid-size"), + Some(&"5".to_string()) + ); + assert_eq!( + request + .metadata + .get("x-oss-meta-generated-asset-sheet-row-index"), + Some(&"1".to_string()) + ); + assert_eq!( + request + .metadata + .get("x-oss-meta-generated-asset-sheet-view-index"), + Some(&"2".to_string()) + ); + assert_eq!( + request + .metadata + .get("x-oss-meta-generated-asset-sheet-prompt-b64"), + Some(&BASE64_STANDARD.encode("sheet prompt")) + ); + assert_eq!( + request + .metadata + .get("x-oss-meta-generated-asset-sheet-item-name-prompt-b64"), + Some(&BASE64_STANDARD.encode("item prompt")) + ); + assert_eq!( + request + .metadata + .get("x-oss-meta-generated-asset-sheet-special-prompt-b64"), + Some(&BASE64_STANDARD.encode("special prompt")) + ); + } +} diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs new file mode 100644 index 00000000..ec6d0a43 --- /dev/null +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -0,0 +1,447 @@ +use axum::{ + Json, + extract::{Extension, Path, State, rejection::JsonRejection}, + http::{HeaderName, StatusCode, header}, + response::Response, +}; +use serde_json::{Value, json}; +use shared_contracts::jump_hop::{ + JumpHopActionRequest, JumpHopDraftResponse, JumpHopGalleryDetailResponse, + JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse, JumpHopRestartRunRequest, + JumpHopRunResponse, JumpHopSessionResponse, JumpHopSessionSnapshotResponse, + JumpHopStartRunRequest, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, + JumpHopWorkspaceCreateRequest, +}; +use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; +use spacetime_client::SpacetimeClientError; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::{ + api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, + request_context::RequestContext, state::AppState, +}; + +const JUMP_HOP_PROVIDER: &str = "jump-hop"; +const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation"; +const JUMP_HOP_RUNTIME_PROVIDER: &str = "jump-hop-runtime"; +const JUMP_HOP_TEMPLATE_ID: &str = "jump-hop"; +const JUMP_HOP_TEMPLATE_NAME: &str = "跳一跳"; + +pub async fn create_jump_hop_session( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_CREATION_PROVIDER)?; + validate_workspace_request(&request_context, &payload)?; + + let owner_user_id = authenticated.claims().user_id().to_string(); + let session_id = build_prefixed_uuid_id("jump-hop-session-"); + let now = current_utc_micros(); + let draft = build_jump_hop_draft(&payload); + let session = JumpHopSessionSnapshotResponse { + session_id, + owner_user_id, + status: JumpHopGenerationStatus::Draft, + draft: Some(draft), + created_at: format_timestamp_micros(now), + updated_at: format_timestamp_micros(now), + }; + + Ok(json_success_body( + Some(&request_context), + JumpHopSessionResponse { + session: state + .spacetime_client() + .create_jump_hop_session(session) + .await + .map_err(|error| { + jump_hop_error_response( + &request_context, + JUMP_HOP_CREATION_PROVIDER, + map_jump_hop_client_error(error), + ) + })?, + }, + )) +} + +pub async fn get_jump_hop_session( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty(&request_context, &session_id, "sessionId")?; + let owner_user_id = authenticated.claims().user_id().to_string(); + let session = state + .spacetime_client() + .get_jump_hop_session(session_id, owner_user_id) + .await + .map_err(|error| { + jump_hop_error_response( + &request_context, + JUMP_HOP_CREATION_PROVIDER, + map_jump_hop_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + JumpHopSessionResponse { session }, + )) +} + +pub async fn execute_jump_hop_action( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + ensure_non_empty(&request_context, &session_id, "sessionId")?; + let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_CREATION_PROVIDER)?; + let owner_user_id = authenticated.claims().user_id().to_string(); + let response = state + .spacetime_client() + .execute_jump_hop_action(session_id, owner_user_id, payload) + .await + .map_err(|error| { + jump_hop_error_response( + &request_context, + JUMP_HOP_CREATION_PROVIDER, + map_jump_hop_client_error(error), + ) + })?; + + Ok(json_success_body(Some(&request_context), response)) +} + +pub async fn publish_jump_hop_work( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty(&request_context, &profile_id, "profileId")?; + let work = state + .spacetime_client() + .publish_jump_hop_work(profile_id, authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + jump_hop_error_response( + &request_context, + JUMP_HOP_CREATION_PROVIDER, + map_jump_hop_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + JumpHopWorkMutationResponse { item: work }, + )) +} + +pub async fn get_jump_hop_runtime_work( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, +) -> Result, Response> { + ensure_non_empty(&request_context, &profile_id, "profileId")?; + let work = state + .spacetime_client() + .get_jump_hop_runtime_work(profile_id) + .await + .map_err(|error| { + jump_hop_error_response( + &request_context, + JUMP_HOP_RUNTIME_PROVIDER, + map_jump_hop_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + JumpHopWorkDetailResponse { item: work }, + )) +} + +pub async fn start_jump_hop_run( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?; + ensure_non_empty(&request_context, &payload.profile_id, "profileId")?; + let run = state + .spacetime_client() + .start_jump_hop_run(payload, authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + jump_hop_error_response( + &request_context, + JUMP_HOP_RUNTIME_PROVIDER, + map_jump_hop_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + JumpHopRunResponse { run }, + )) +} + +pub async fn jump_hop_run_jump( + State(state): State, + Path(run_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + ensure_non_empty(&request_context, &run_id, "runId")?; + let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?; + let run = state + .spacetime_client() + .jump_hop_run_jump( + run_id, + authenticated.claims().user_id().to_string(), + payload, + ) + .await + .map_err(|error| { + jump_hop_error_response( + &request_context, + JUMP_HOP_RUNTIME_PROVIDER, + map_jump_hop_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + JumpHopJumpResponse { run }, + )) +} + +pub async fn restart_jump_hop_run( + State(state): State, + Path(run_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + ensure_non_empty(&request_context, &run_id, "runId")?; + let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?; + let run = state + .spacetime_client() + .restart_jump_hop_run( + run_id, + authenticated.claims().user_id().to_string(), + payload, + ) + .await + .map_err(|error| { + jump_hop_error_response( + &request_context, + JUMP_HOP_RUNTIME_PROVIDER, + map_jump_hop_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + JumpHopRunResponse { run }, + )) +} + +pub async fn list_jump_hop_gallery( + State(state): State, + Extension(request_context): Extension, +) -> Result, Response> { + let gallery = state + .spacetime_client() + .list_jump_hop_gallery() + .await + .map_err(|error| { + jump_hop_error_response( + &request_context, + JUMP_HOP_RUNTIME_PROVIDER, + map_jump_hop_client_error(error), + ) + })?; + + Ok(json_success_body(Some(&request_context), gallery)) +} + +pub async fn get_jump_hop_gallery_detail( + State(state): State, + Path(public_work_code): Path, + Extension(request_context): Extension, +) -> Result, Response> { + ensure_non_empty(&request_context, &public_work_code, "publicWorkCode")?; + let work = state + .spacetime_client() + .get_jump_hop_gallery_detail(public_work_code) + .await + .map_err(|error| { + jump_hop_error_response( + &request_context, + JUMP_HOP_RUNTIME_PROVIDER, + map_jump_hop_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + JumpHopGalleryDetailResponse { item: work }, + )) +} + +fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraftResponse { + JumpHopDraftResponse { + template_id: JUMP_HOP_TEMPLATE_ID.to_string(), + template_name: JUMP_HOP_TEMPLATE_NAME.to_string(), + profile_id: None, + work_title: payload.work_title.trim().to_string(), + work_description: payload.work_description.trim().to_string(), + theme_tags: normalize_tags(payload.theme_tags.clone()), + difficulty: payload.difficulty.clone(), + style_preset: payload.style_preset.clone(), + character_prompt: payload.character_prompt.trim().to_string(), + tile_prompt: payload.tile_prompt.trim().to_string(), + end_mood_prompt: payload + .end_mood_prompt + .as_ref() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + character_asset: None, + tile_atlas_asset: None, + tile_assets: Vec::new(), + path: None, + cover_composite: None, + generation_status: JumpHopGenerationStatus::Draft, + } +} + +fn validate_workspace_request( + request_context: &RequestContext, + payload: &JumpHopWorkspaceCreateRequest, +) -> Result<(), Response> { + ensure_non_empty(request_context, &payload.work_title, "workTitle")?; + ensure_non_empty( + request_context, + &payload.character_prompt, + "characterPrompt", + )?; + ensure_non_empty(request_context, &payload.tile_prompt, "tilePrompt")?; + if payload.template_id.trim() != JUMP_HOP_TEMPLATE_ID { + return Err(jump_hop_error_response( + request_context, + JUMP_HOP_CREATION_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": JUMP_HOP_PROVIDER, + "message": "templateId 必须为 jump-hop", + })), + )); + } + Ok(()) +} + +fn ensure_non_empty( + request_context: &RequestContext, + value: &str, + field: &str, +) -> Result<(), Response> { + if value.trim().is_empty() { + return Err(jump_hop_error_response( + request_context, + JUMP_HOP_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": JUMP_HOP_PROVIDER, + "field": field, + "message": format!("{field} 不能为空"), + })), + )); + } + Ok(()) +} + +fn normalize_tags(tags: Vec) -> Vec { + let mut normalized = Vec::new(); + for tag in tags { + let tag = tag.trim(); + if tag.is_empty() || normalized.iter().any(|item| item == tag) { + continue; + } + normalized.push(tag.to_string()); + if normalized.len() >= 6 { + break; + } + } + normalized +} + +fn jump_hop_json( + payload: Result, JsonRejection>, + request_context: &RequestContext, + provider: &str, +) -> Result, Response> { + payload.map_err(|error| { + jump_hop_error_response( + request_context, + provider, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": provider, + "message": error.to_string(), + })), + ) + }) +} + +fn map_jump_hop_client_error(error: SpacetimeClientError) -> AppError { + let status = match &error { + SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, + SpacetimeClientError::Procedure(message) + if message.contains("不存在") + || message.contains("not found") + || message.contains("does not exist") => + { + StatusCode::NOT_FOUND + } + SpacetimeClientError::Procedure(message) + if message.contains("发布需要") + || message.contains("不能为空") + || message.contains("必须") => + { + StatusCode::BAD_REQUEST + } + _ => StatusCode::BAD_GATEWAY, + }; + + AppError::from_status(status).with_details(json!({ + "provider": "spacetimedb", + "message": error.to_string(), + })) +} + +fn jump_hop_error_response( + request_context: &RequestContext, + provider: &str, + error: AppError, +) -> Response { + let mut response = error.into_response_with_context(Some(request_context)); + response.headers_mut().insert( + HeaderName::from_static("x-genarrative-provider"), + header::HeaderValue::from_str(provider) + .unwrap_or_else(|_| header::HeaderValue::from_static("jump-hop")), + ); + response +} + +fn current_utc_micros() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_micros().min(i64::MAX as u128) as i64) + .unwrap_or(0) +} diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 01ed6555..674e51c0 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -39,10 +39,12 @@ mod custom_world_rpg_draft_prompts; mod edutainment_baby_drawing; mod edutainment_baby_object; mod error_middleware; +pub(crate) mod generated_asset_sheets; mod generated_image_assets; mod health; mod http_error; mod hyper3d_generation; +mod jump_hop; mod llm; mod llm_model_routing; mod login_options; diff --git a/server-rs/crates/api-server/src/match3d.rs b/server-rs/crates/api-server/src/match3d.rs index 405393cd..789352fd 100644 --- a/server-rs/crates/api-server/src/match3d.rs +++ b/server-rs/crates/api-server/src/match3d.rs @@ -16,7 +16,7 @@ use axum::{ }; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use futures_util::{StreamExt, stream::FuturesUnordered}; -use image::{GenericImageView, ImageFormat}; +use image::ImageFormat; use module_match3d::{ MATCH3D_MESSAGE_ID_PREFIX, MATCH3D_PROFILE_ID_PREFIX, MATCH3D_RUN_ID_PREFIX, MATCH3D_SESSION_ID_PREFIX, @@ -98,11 +98,6 @@ const MATCH3D_ITEM_ASSETS_POINTS_PER_BATCH: u64 = 2; const MATCH3D_MATERIAL_ITEM_BATCH_SIZE: usize = 5; const MATCH3D_ITEM_VIEW_COUNT: usize = 5; const MATCH3D_MATERIAL_GRID_SIZE: u32 = 5; -const MATCH3D_MATERIAL_FOREGROUND_DIFF_THRESHOLD: i32 = 36; -const MATCH3D_MATERIAL_FOREGROUND_ALPHA_THRESHOLD: i32 = 36; -const MATCH3D_MATERIAL_GREEN_SCREEN_MIN_SCORE: f32 = 0.34; -const MATCH3D_MATERIAL_GREEN_SCREEN_SOFT_SCORE: f32 = 0.18; -const MATCH3D_MATERIAL_HARD_GREEN_SCREEN_SCORE: f32 = 0.82; const MATCH3D_MAX_GENERATED_ITEM_COUNT: usize = 25; const MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_MODEL: &str = "gemini-3-pro-image-preview"; const MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO: &str = "1:1"; diff --git a/server-rs/crates/api-server/src/match3d/draft.rs b/server-rs/crates/api-server/src/match3d/draft.rs index f4855b69..1f73f265 100644 --- a/server-rs/crates/api-server/src/match3d/draft.rs +++ b/server-rs/crates/api-server/src/match3d/draft.rs @@ -532,7 +532,9 @@ fn build_config_from_message( } } -pub(super) fn resolve_config_or_default(config: Option<&Match3DCreatorConfigRecord>) -> Match3DConfigJson { +pub(super) fn resolve_config_or_default( + config: Option<&Match3DCreatorConfigRecord>, +) -> Match3DConfigJson { config .map(|config| Match3DConfigJson { theme_text: config.theme_text.clone(), @@ -595,7 +597,10 @@ fn build_match3d_assistant_reply(config: &Match3DConfigJson) -> String { ) } -pub(super) fn build_match3d_assistant_reply_for_turn(config: &Match3DConfigJson, current_turn: u32) -> String { +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(), diff --git a/server-rs/crates/api-server/src/match3d/item_assets.rs b/server-rs/crates/api-server/src/match3d/item_assets.rs index ab6d59c7..87f86542 100644 --- a/server-rs/crates/api-server/src/match3d/item_assets.rs +++ b/server-rs/crates/api-server/src/match3d/item_assets.rs @@ -1,4 +1,12 @@ use super::*; +use crate::generated_asset_sheets::{ + GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, + GeneratedAssetSheetPromptInput, GeneratedAssetSheetSliceImage, + build_generated_asset_sheet_prompt, persist_generated_asset_sheet_bytes, + slice_generated_asset_sheet, +}; +#[cfg(test)] +use crate::generated_asset_sheets::crop_generated_asset_sheet_view_edge_matte; pub(super) async fn generate_match3d_item_assets( state: &AppState, @@ -151,8 +159,12 @@ struct Match3DItemImageGenerationSeed { struct Match3DMaterialBatchOutput { task_id: String, + prompt: String, generated_at_micros: i64, - items: Vec<(Match3DItemImageGenerationSeed, Vec)>, + items: Vec<( + Match3DItemImageGenerationSeed, + Vec, + )>, } struct Match3DGeneratedItemImageAssetOutput { @@ -206,10 +218,14 @@ async fn generate_match3d_item_image_assets_in_batches( .iter() .map(|item| item.item_name.clone()) .collect::>(); - let item_images = - slice_match3d_material_sheet(&material_sheet.image, &persisted_item_names)?; + let item_images = slice_generated_asset_sheet( + &material_sheet.image, + &persisted_item_names, + MATCH3D_MATERIAL_GRID_SIZE as usize, + )?; Ok::<_, AppError>(Match3DMaterialBatchOutput { task_id: material_sheet.task_id, + prompt: material_sheet.prompt, generated_at_micros, items: persisted_seeds .into_iter() @@ -231,26 +247,44 @@ async fn generate_match3d_item_image_assets_in_batches( let mut generated_assets = Vec::new(); for batch in batches { let sheet_task_id = batch.task_id; + let sheet_prompt = batch.prompt; 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( + let item_name_prompt = + format!("第{}行:{} 的 5 个不同视角", item_index + 1, seed.item_name); + let view_upload = persist_generated_asset_sheet_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, - ), + GeneratedAssetSheetPersistInput { + prefix: LegacyAssetPrefix::Match3DAssets, + owner_user_id: owner_user_id.to_string(), + session_id: session_id.to_string(), + profile_id: profile_id.to_string(), + path_segments: vec![ + "items".to_string(), + item_slug.clone(), + "views".to_string(), + ], + file_name: format!("view-{view_number:02}.png"), + content_type: "image/png".to_string(), + bytes: item_image.bytes, + asset_kind: "match3d_item_image_view".to_string(), + source_job_id: Some(sheet_task_id.clone()), + generated_at_micros: generated_at_micros.saturating_add( + (item_index * MATCH3D_ITEM_VIEW_COUNT + view_index) as i64 + 1, + ), + grid_size: MATCH3D_MATERIAL_GRID_SIZE as usize, + row_index: item_index + 1, + view_index: view_number, + prompt: GeneratedAssetSheetPersistPrompt { + sheet_prompt: Some(sheet_prompt.clone()), + item_name_prompt: Some(item_name_prompt), + special_prompt: Some(match3d_material_sheet_special_prompt()), + }, + }, ) .await .map_err(|error| match3d_error_response(request_context, provider, error))?; @@ -662,6 +696,7 @@ async fn replace_match3d_item_assets( pub(super) struct Match3DMaterialSheet { pub(super) task_id: String, + pub(super) prompt: String, pub(super) image: DownloadedOpenAiImage, } @@ -671,6 +706,7 @@ pub(super) struct Match3DVectorEngineGeminiImageSettings { pub(super) request_timeout_ms: u64, } +#[cfg(test)] pub(super) struct Match3DSlicedItemImage { pub(super) bytes: Vec, } @@ -1040,7 +1076,10 @@ pub(super) fn build_fallback_match3d_background_prompt(config: &Match3DConfigJso ) } -pub(super) fn build_fallback_match3d_item_sound_prompt(config: &Match3DConfigJson, item_name: &str) -> String { +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( @@ -1278,18 +1317,29 @@ pub(super) fn build_match3d_material_sheet_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, - ) + let subject_text = format!( + "{}题材的抓大鹅游戏2D物品素材。{style_clause}", + config.theme_text + ); + let special_prompt = match3d_material_sheet_special_prompt(); + build_generated_asset_sheet_prompt(&GeneratedAssetSheetPromptInput { + subject_text: subject_text.as_str(), + item_names, + grid_size: MATCH3D_MATERIAL_GRID_SIZE as usize, + item_name_prompt_template: Some("第{row_index}行:{item_name} 的 {view_count} 个不同视角"), + special_prompt: Some(special_prompt.as_str()), + }) + .unwrap_or_else(|_| { + format!( + "生成一张1:1图片。固定生成5行*5列网格素材图,画面是{}题材的抓大鹅游戏2D物品素材。{}", + config.theme_text, + match3d_material_sheet_special_prompt(), + ) + }) +} + +fn match3d_material_sheet_special_prompt() -> String { + "同一行五格必须是同一物品的五个不同视角,依次为正面、左前、右前、俯视、背面;".to_string() } pub(super) fn build_match3d_material_sheet_negative_prompt(config: &Match3DConfigJson) -> String { @@ -1334,1074 +1384,30 @@ fn is_match3d_pixel_retro_style(config: &Match3DConfigJson) -> bool { .is_some_and(|value| value.contains("像素复古")) } +#[cfg(test)] 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(), + slice_generated_asset_sheet(image, item_names, MATCH3D_MATERIAL_GRID_SIZE as usize).map( + |rows| { + rows.into_iter() + .map(|views| { + views + .into_iter() + .map(|view| Match3DSlicedItemImage { bytes: view.bytes }) + .collect() + }) + .collect() + }, ) } -#[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) +#[cfg(test)] +pub(super) fn crop_match3d_material_view_edge_matte( + image: image::DynamicImage, +) -> image::DynamicImage { + crop_generated_asset_sheet_view_edge_matte(image) } fn compute_match3d_material_white_screen_score(pixel: [u8; 4]) -> f32 { @@ -2420,12 +1426,11 @@ fn compute_match3d_material_white_screen_score(pixel: [u8; 4]) -> f32 { } 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)) + let neutrality = 1.0 - ((spread - 6.0) / 34.0).clamp(0.0, 1.0); + let brightness = ((average - 188.0) / 55.0).clamp(0.0, 1.0); + let floor = ((min_channel - 168.0) / 60.0).clamp(0.0, 1.0); + (neutrality * (brightness * 0.85 + floor * 0.15)).clamp(0.0, 1.0) } - pub(super) fn remove_match3d_container_plain_background( pixels: &mut [u8], width: usize, @@ -2565,67 +1570,3 @@ fn is_match3d_container_background_pixel(pixel: [u8; 4]) -> bool { 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 983159a8..c3b0067e 100644 --- a/server-rs/crates/api-server/src/match3d/mappers.rs +++ b/server-rs/crates/api-server/src/match3d/mappers.rs @@ -134,12 +134,11 @@ pub(super) fn map_match3d_draft_response( draft: Match3DResultDraftRecord, ) -> 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 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, diff --git a/server-rs/crates/api-server/src/match3d/tests.rs b/server-rs/crates/api-server/src/match3d/tests.rs index 23bfb659..e2cdc7b3 100644 --- a/server-rs/crates/api-server/src/match3d/tests.rs +++ b/server-rs/crates/api-server/src/match3d/tests.rs @@ -1,40 +1,804 @@ 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 test_match3d_generated_item_asset(index: u32, name: &str) -> Match3DGeneratedItemAsset { - Match3DGeneratedItemAsset { +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: 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}点击音效")), + 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, @@ -43,65 +807,476 @@ use super::*; background_asset: None, status: "image_ready".to_string(), error: None, - } - } + }) + .collect::>(); - fn config(theme_text: &str, clear_count: u32, difficulty: u32) -> Match3DConfigJson { - Match3DConfigJson { - theme_text: theme_text.to_string(), + 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, - difficulty, - asset_style_id: None, - asset_style_label: None, - asset_style_prompt: None, - generate_click_sound: false, - } - } + 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); - #[test] - fn match3d_agent_reply_asks_three_questions_before_confirmation() { - let current = config("水果", 4, 6); + 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") + ); +} - 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 { +#[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(), + value: "水果".to_string(), status: "confirmed".to_string(), }, clear_count: Match3DAnchorItemRecord { key: "clearCount".to_string(), - label: "需要消除次数".to_string(), + label: "消除次数".to_string(), value: "12".to_string(), status: "confirmed".to_string(), }, @@ -111,648 +1286,382 @@ use super::*; 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_anchor_pack_response_for_turn(pack, 0, "Collecting"); + let response = map_match3d_agent_session_response_with_assets(session, &assets); + let draft = response.draft.expect("session draft should exist"); - 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"); - } + 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_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::>(); +#[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(), + }; - assert_eq!( - slugs, - vec![ - "match3d-item-1-item", - "match3d-item-2-item", - "match3d-item-3-item", - ] - ); - } + let response = map_match3d_agent_session_response_with_assets(session, &[]); + let draft = response.draft.expect("session draft should exist"); - #[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(), - }; + 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") + ); +} - let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); +#[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、轻量休闲"), "轻量休闲"); +} - 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_plan_tags_are_kept_before_local_fallback_tags() { + let tags = merge_match3d_plan_tags_with_fallback( + "果园大鹅宴", + "水果", + &["果园".to_string(), "轻快".to_string(), "抓大鹅".to_string()], + ); - #[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(), - }; + assert_eq!(tags[0], "果园"); + assert_eq!(tags[1], "轻快"); + assert_eq!(tags[2], "抓大鹅"); + assert!(tags.contains(&"水果".to_string())); + assert!(tags.contains(&"经典消除".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(); +#[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" + ); +} - 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_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()); - #[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])); - } - } + assert!(is_match3d_glb_binary_payload(&glb)); + assert!(!is_match3d_glb_binary_payload(b"expired")); - 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 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)); +} - 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 { +#[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: None, + 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, @@ -769,959 +1678,12 @@ use super::*; 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!(!has_match3d_required_item_images(&assets, 3)); - 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) + let five_view_assets = (1..=3) .map(|index| Match3DGeneratedItemAsset { item_id: format!("match3d-item-{index}"), item_name: format!("物品{index}"), @@ -1761,35 +1723,35 @@ use super::*; }) .collect::>(); - assert!(has_match3d_required_item_images(&five_view_assets, 3)); - } + 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() - }; +#[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" - ); + 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()); - } + 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 { +#[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(), @@ -1815,61 +1777,59 @@ use super::*; ), }); - 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") - ); - } + 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), - }); +#[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")); - } + 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 index b19e89c3..9605c84f 100644 --- a/server-rs/crates/api-server/src/match3d/vector_engine_gemini.rs +++ b/server-rs/crates/api-server/src/match3d/vector_engine_gemini.rs @@ -26,6 +26,7 @@ pub(super) async fn generate_match3d_material_sheet( Ok(Match3DMaterialSheet { task_id: generated.task_id, + prompt, image, }) } diff --git a/server-rs/crates/api-server/src/match3d/works.rs b/server-rs/crates/api-server/src/match3d/works.rs index 0db5d0ef..67bbe7eb 100644 --- a/server-rs/crates/api-server/src/match3d/works.rs +++ b/server-rs/crates/api-server/src/match3d/works.rs @@ -587,7 +587,10 @@ async fn load_match3d_container_reference_image() -> Result 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(); @@ -596,7 +599,10 @@ pub(super) fn build_match3d_background_generation_prompt(config: &Match3DConfigJ ) } -pub(super) fn build_match3d_container_generation_prompt(config: &Match3DConfigJson, prompt: &str) -> String { +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(); @@ -1183,7 +1189,9 @@ pub(super) async fn persist_match3d_generated_bytes( }) } -pub(super) fn require_match3d_oss_client(state: &AppState) -> Result<&platform_oss::OssClient, AppError> { +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)) diff --git a/server-rs/crates/api-server/src/modules.rs b/server-rs/crates/api-server/src/modules.rs index 17a75784..a86abae9 100644 --- a/server-rs/crates/api-server/src/modules.rs +++ b/server-rs/crates/api-server/src/modules.rs @@ -7,6 +7,7 @@ pub mod custom_world; pub mod edutainment; pub mod health; pub mod internal; +pub mod jump_hop; pub mod match3d; pub mod platform; pub mod profile; diff --git a/server-rs/crates/api-server/src/modules/jump_hop.rs b/server-rs/crates/api-server/src/modules/jump_hop.rs new file mode 100644 index 00000000..7648fe91 --- /dev/null +++ b/server-rs/crates/api-server/src/modules/jump_hop.rs @@ -0,0 +1,76 @@ +use axum::{ + Router, middleware, + routing::{get, post}, +}; + +use crate::{ + auth::require_bearer_auth, + jump_hop::{ + create_jump_hop_session, execute_jump_hop_action, get_jump_hop_gallery_detail, + get_jump_hop_runtime_work, get_jump_hop_session, jump_hop_run_jump, list_jump_hop_gallery, + publish_jump_hop_work, restart_jump_hop_run, start_jump_hop_run, + }, + state::AppState, +}; + +pub fn router(state: AppState) -> Router { + Router::new() + .route( + "/api/creation/jump-hop/sessions", + post(create_jump_hop_session).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/creation/jump-hop/sessions/{session_id}", + get(get_jump_hop_session).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/creation/jump-hop/sessions/{session_id}/actions", + post(execute_jump_hop_action).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/creation/jump-hop/works/{profile_id}/publish", + post(publish_jump_hop_work).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/jump-hop/works/{profile_id}", + get(get_jump_hop_runtime_work), + ) + .route( + "/api/runtime/jump-hop/runs", + post(start_jump_hop_run).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/jump-hop/runs/{run_id}/jump", + post(jump_hop_run_jump).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/jump-hop/runs/{run_id}/restart", + post(restart_jump_hop_run).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route("/api/runtime/jump-hop/gallery", get(list_jump_hop_gallery)) + .route( + "/api/runtime/jump-hop/gallery/{public_work_code}", + get(get_jump_hop_gallery_detail), + ) +} diff --git a/server-rs/crates/api-server/src/process_metrics.rs b/server-rs/crates/api-server/src/process_metrics.rs index 4d3adad2..d61a7b15 100644 --- a/server-rs/crates/api-server/src/process_metrics.rs +++ b/server-rs/crates/api-server/src/process_metrics.rs @@ -199,11 +199,9 @@ fn cpu_usage_ratio_between_samples( #[cfg(windows)] fn collect_process_metrics() -> Result { - use windows_sys::Win32::{ - System::{ - ProcessStatus::{GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS_EX}, - Threading::{GetCurrentProcess, GetCurrentProcessId, GetProcessHandleCount}, - }, + use windows_sys::Win32::System::{ + ProcessStatus::{GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS_EX}, + Threading::{GetCurrentProcess, GetCurrentProcessId, GetProcessHandleCount}, }; let handle = unsafe { GetCurrentProcess() }; @@ -212,11 +210,7 @@ fn collect_process_metrics() -> Result { ..Default::default() }; let ok = unsafe { - GetProcessMemoryInfo( - handle, - std::ptr::addr_of_mut!(counters).cast(), - counters.cb, - ) + GetProcessMemoryInfo(handle, std::ptr::addr_of_mut!(counters).cast(), counters.cb) }; if ok == 0 { return Err("GetProcessMemoryInfo returned false".to_string()); @@ -244,10 +238,7 @@ fn collect_process_metrics() -> Result { #[cfg(windows)] fn windows_process_cpu_time_seconds(handle: windows_sys::Win32::Foundation::HANDLE) -> Option { - use windows_sys::Win32::{ - Foundation::FILETIME, - System::Threading::GetProcessTimes, - }; + use windows_sys::Win32::{Foundation::FILETIME, System::Threading::GetProcessTimes}; let mut creation_time = FILETIME::default(); let mut exit_time = FILETIME::default(); @@ -337,8 +328,8 @@ fn collect_process_metrics() -> Result { .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())?; + let thread_count = + parse_status_u64(&status, "Threads:").ok_or_else(|| "missing Threads field".to_string())?; Ok(ProcessMetricsSnapshot { rss_bytes, @@ -427,11 +418,7 @@ fn parse_status_u64(status: &str, key: &str) -> Option { #[cfg(target_os = "linux")] fn parse_statm_pages(statm: &str, index: usize) -> Option { - statm - .split_whitespace() - .nth(index)? - .parse::() - .ok() + statm.split_whitespace().nth(index)?.parse::().ok() } #[cfg(not(any(windows, target_os = "linux")))] diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index da2f52de..3d628ec1 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -2061,7 +2061,6 @@ 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), } } diff --git a/server-rs/crates/module-jump-hop/Cargo.toml b/server-rs/crates/module-jump-hop/Cargo.toml new file mode 100644 index 00000000..1ca24ddf --- /dev/null +++ b/server-rs/crates/module-jump-hop/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "module-jump-hop" +edition.workspace = true +version.workspace = true +license.workspace = true + +[features] +default = [] +spacetime-types = ["dep:spacetimedb"] + +[dependencies] +serde = { workspace = true } +shared-kernel = { workspace = true } +spacetimedb = { workspace = true, optional = true } diff --git a/server-rs/crates/module-jump-hop/src/application.rs b/server-rs/crates/module-jump-hop/src/application.rs new file mode 100644 index 00000000..3521c13a --- /dev/null +++ b/server-rs/crates/module-jump-hop/src/application.rs @@ -0,0 +1,395 @@ +use shared_kernel::normalize_required_string; + +use crate::{ + JumpHopDifficulty, JumpHopError, JumpHopJumpResultKind, JumpHopLastJump, JumpHopPath, + JumpHopPlatform, JumpHopRunSnapshot, JumpHopRunStatus, JumpHopScoring, JumpHopTileType, +}; + +pub fn generate_jump_hop_path(seed: &str, difficulty: JumpHopDifficulty) -> JumpHopPath { + let config = difficulty_config(difficulty); + let mut rng = DeterministicRng::new(seed, difficulty.as_str()); + let platform_count = rng.range_u32(config.min_platforms, config.max_platforms) as usize; + let mut platforms = Vec::with_capacity(platform_count); + let mut x = 0.0f32; + let mut y = 0.0f32; + + for index in 0..platform_count { + let tile_type = if index == 0 { + JumpHopTileType::Start + } else if index + 1 == platform_count { + JumpHopTileType::Finish + } else if index % 7 == 0 { + JumpHopTileType::Bonus + } else if index % 5 == 0 { + JumpHopTileType::Target + } else if index % 4 == 0 { + JumpHopTileType::Accent + } else { + JumpHopTileType::Normal + }; + let width = rng.range_f32(config.min_width, config.max_width); + let height = width * rng.range_f32(0.86, 1.04); + let landing_radius = width * config.landing_radius_factor; + let perfect_radius = landing_radius * config.perfect_radius_factor; + + platforms.push(JumpHopPlatform { + platform_id: format!("jump-hop-platform-{index:03}"), + tile_type, + x, + y, + width, + height, + landing_radius, + perfect_radius, + score_value: if tile_type == JumpHopTileType::Bonus { + 180 + } else { + 100 + }, + }); + + if index + 1 < platform_count { + let distance = rng.range_f32(config.min_gap, config.max_gap); + let direction = if rng.next_u32() % 2 == 0 { 1.0 } else { -1.0 }; + x += distance * 0.62 * direction; + y += distance; + } + } + + JumpHopPath { + seed: seed.trim().to_string(), + difficulty, + finish_index: platform_count.saturating_sub(1) as u32, + platforms, + camera_preset: "portrait-isometric-9x16".to_string(), + scoring: JumpHopScoring { + charge_to_distance_ratio: config.charge_to_distance_ratio, + max_charge_ms: config.max_charge_ms, + hit_bonus: 20, + perfect_bonus: 60, + }, + } +} + +pub fn start_run( + run_id: String, + owner_user_id: String, + profile_id: String, + path: JumpHopPath, + started_at_ms: u64, +) -> Result { + let run_id = normalize_required_string(run_id).ok_or(JumpHopError::MissingRunId)?; + let owner_user_id = + normalize_required_string(owner_user_id).ok_or(JumpHopError::MissingOwnerUserId)?; + let profile_id = normalize_required_string(profile_id).ok_or(JumpHopError::MissingProfileId)?; + if path.platforms.is_empty() { + return Err(JumpHopError::EmptyPath); + } + + Ok(JumpHopRunSnapshot { + run_id, + profile_id, + owner_user_id, + status: JumpHopRunStatus::Playing, + current_platform_index: 0, + score: 0, + combo: 0, + last_jump: None, + started_at_ms, + finished_at_ms: None, + path, + }) +} + +pub fn apply_jump( + run: &JumpHopRunSnapshot, + charge_ms: u32, + jumped_at_ms: u64, +) -> Result { + if run.status != JumpHopRunStatus::Playing { + return Err(JumpHopError::RunNotPlaying); + } + let current_index = run.current_platform_index as usize; + let next_index = current_index + 1; + let current = run + .path + .platforms + .get(current_index) + .ok_or(JumpHopError::EmptyPath)?; + let target = run + .path + .platforms + .get(next_index) + .ok_or(JumpHopError::NoNextPlatform)?; + let capped_charge = charge_ms.min(run.path.scoring.max_charge_ms); + let jump_distance = capped_charge as f32 * run.path.scoring.charge_to_distance_ratio; + let vector_x = target.x - current.x; + let vector_y = target.y - current.y; + let target_distance = vector_x.hypot(vector_y).max(0.0001); + let unit_x = vector_x / target_distance; + let unit_y = vector_y / target_distance; + let landed_x = current.x + unit_x * jump_distance; + let landed_y = current.y + unit_y * jump_distance; + let landing_error = (landed_x - target.x).hypot(landed_y - target.y); + + let mut next = run.clone(); + let result = if landing_error <= target.perfect_radius { + if next_index as u32 == run.path.finish_index { + JumpHopJumpResultKind::Finish + } else { + JumpHopJumpResultKind::Perfect + } + } else if landing_error <= target.landing_radius { + if next_index as u32 == run.path.finish_index { + JumpHopJumpResultKind::Finish + } else { + JumpHopJumpResultKind::Hit + } + } else { + JumpHopJumpResultKind::Miss + }; + + next.last_jump = Some(JumpHopLastJump { + charge_ms: capped_charge, + jump_distance, + target_platform_index: next_index as u32, + landed_x, + landed_y, + result, + }); + + if result == JumpHopJumpResultKind::Miss { + next.status = JumpHopRunStatus::Failed; + next.combo = 0; + next.finished_at_ms = Some(jumped_at_ms); + return Ok(next); + } + + next.current_platform_index = next_index as u32; + next.combo = next.combo.saturating_add(1); + next.score = next.score.saturating_add(target.score_value); + if matches!( + result, + JumpHopJumpResultKind::Perfect | JumpHopJumpResultKind::Finish + ) { + next.score = next + .score + .saturating_add(run.path.scoring.perfect_bonus) + .saturating_add(next.combo.saturating_mul(run.path.scoring.hit_bonus)); + } else { + next.score = next.score.saturating_add(run.path.scoring.hit_bonus); + } + if result == JumpHopJumpResultKind::Finish { + next.status = JumpHopRunStatus::Cleared; + next.finished_at_ms = Some(jumped_at_ms); + } + + Ok(next) +} + +pub fn restart_run( + run: &JumpHopRunSnapshot, + next_run_id: String, + restarted_at_ms: u64, +) -> Result { + start_run( + next_run_id, + run.owner_user_id.clone(), + run.profile_id.clone(), + run.path.clone(), + restarted_at_ms, + ) +} + +struct DifficultyConfig { + min_platforms: u32, + max_platforms: u32, + min_gap: f32, + max_gap: f32, + min_width: f32, + max_width: f32, + landing_radius_factor: f32, + perfect_radius_factor: f32, + charge_to_distance_ratio: f32, + max_charge_ms: u32, +} + +fn difficulty_config(difficulty: JumpHopDifficulty) -> DifficultyConfig { + match difficulty { + JumpHopDifficulty::Easy => DifficultyConfig { + min_platforms: 12, + max_platforms: 14, + min_gap: 1.0, + max_gap: 1.45, + min_width: 0.9, + max_width: 1.08, + landing_radius_factor: 0.62, + perfect_radius_factor: 0.32, + charge_to_distance_ratio: 0.004, + max_charge_ms: 700, + }, + JumpHopDifficulty::Standard => DifficultyConfig { + min_platforms: 16, + max_platforms: 18, + min_gap: 1.22, + max_gap: 1.78, + min_width: 0.82, + max_width: 1.0, + landing_radius_factor: 0.54, + perfect_radius_factor: 0.26, + charge_to_distance_ratio: 0.004, + max_charge_ms: 780, + }, + JumpHopDifficulty::Advanced => DifficultyConfig { + min_platforms: 20, + max_platforms: 24, + min_gap: 1.45, + max_gap: 2.05, + min_width: 0.72, + max_width: 0.94, + landing_radius_factor: 0.48, + perfect_radius_factor: 0.22, + charge_to_distance_ratio: 0.004, + max_charge_ms: 860, + }, + JumpHopDifficulty::Challenge => DifficultyConfig { + min_platforms: 26, + max_platforms: 32, + min_gap: 1.7, + max_gap: 2.35, + min_width: 0.66, + max_width: 0.88, + landing_radius_factor: 0.42, + perfect_radius_factor: 0.18, + charge_to_distance_ratio: 0.004, + max_charge_ms: 950, + }, + } +} + +struct DeterministicRng { + state: u64, +} + +impl DeterministicRng { + fn new(seed: &str, salt: &str) -> Self { + let mut state = 0xcbf2_9ce4_8422_2325u64; + for byte in seed.bytes().chain(salt.bytes()) { + state ^= u64::from(byte); + state = state.wrapping_mul(0x1000_0000_01b3); + } + Self { state } + } + + fn next_u32(&mut self) -> u32 { + self.state = self + .state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1); + (self.state >> 32) as u32 + } + + fn range_u32(&mut self, min: u32, max: u32) -> u32 { + if max <= min { + return min; + } + min + self.next_u32() % (max - min + 1) + } + + fn range_f32(&mut self, min: f32, max: f32) -> f32 { + if max <= min { + return min; + } + let unit = self.next_u32() as f32 / u32::MAX as f32; + min + (max - min) * unit + } +} + +#[cfg(test)] +mod tests { + use crate::{ + JumpHopDifficulty, JumpHopJumpResultKind, JumpHopRunStatus, apply_jump, + generate_jump_hop_path, restart_run, start_run, + }; + + #[test] + fn path_generation_is_seeded_and_uses_difficulty_ranges() { + let first = generate_jump_hop_path("seed-a", JumpHopDifficulty::Standard); + let second = generate_jump_hop_path("seed-a", JumpHopDifficulty::Standard); + let challenge = generate_jump_hop_path("seed-a", JumpHopDifficulty::Challenge); + + assert_eq!(first, second); + assert!((16..=18).contains(&first.platforms.len())); + assert!((26..=32).contains(&challenge.platforms.len())); + assert_eq!(first.platforms.first().unwrap().tile_type.as_str(), "start"); + assert_eq!(first.platforms.last().unwrap().tile_type.as_str(), "finish"); + } + + #[test] + fn jump_resolution_distinguishes_perfect_hit_and_miss() { + let path = generate_jump_hop_path("seed-b", JumpHopDifficulty::Easy); + let run = start_run( + "run-1".to_string(), + "user-1".to_string(), + "profile-1".to_string(), + path, + 100, + ) + .expect("run should start"); + let target = &run.path.platforms[1]; + let distance = target.x.hypot(target.y); + let perfect_charge = (distance / run.path.scoring.charge_to_distance_ratio) as u32; + + let perfect = apply_jump(&run, perfect_charge, 200).expect("jump should resolve"); + assert_eq!( + perfect.last_jump.as_ref().unwrap().result, + JumpHopJumpResultKind::Perfect + ); + assert_eq!(perfect.status, JumpHopRunStatus::Playing); + assert_eq!(perfect.current_platform_index, 1); + + let hit = apply_jump(&run, perfect_charge.saturating_add(80), 200) + .expect("jump should resolve"); + assert_eq!( + hit.last_jump.as_ref().unwrap().result, + JumpHopJumpResultKind::Hit + ); + + let miss = apply_jump(&run, perfect_charge.saturating_add(900), 200) + .expect("jump should resolve"); + assert_eq!(miss.status, JumpHopRunStatus::Failed); + assert_eq!( + miss.last_jump.as_ref().unwrap().result, + JumpHopJumpResultKind::Miss + ); + } + + #[test] + fn restart_returns_to_first_platform_and_playing_state() { + let path = generate_jump_hop_path("seed-c", JumpHopDifficulty::Easy); + let mut run = start_run( + "run-1".to_string(), + "user-1".to_string(), + "profile-1".to_string(), + path, + 100, + ) + .expect("run should start"); + run.status = JumpHopRunStatus::Failed; + run.current_platform_index = 3; + run.score = 300; + run.combo = 2; + run.finished_at_ms = Some(200); + + let restarted = restart_run(&run, "run-2".to_string(), 300).expect("run should restart"); + + assert_eq!(restarted.run_id, "run-2"); + assert_eq!(restarted.status, JumpHopRunStatus::Playing); + assert_eq!(restarted.current_platform_index, 0); + assert_eq!(restarted.score, 0); + assert_eq!(restarted.combo, 0); + assert!(restarted.last_jump.is_none()); + assert_eq!(restarted.started_at_ms, 300); + assert!(restarted.finished_at_ms.is_none()); + } +} diff --git a/server-rs/crates/module-jump-hop/src/commands.rs b/server-rs/crates/module-jump-hop/src/commands.rs new file mode 100644 index 00000000..7da94e8c --- /dev/null +++ b/server-rs/crates/module-jump-hop/src/commands.rs @@ -0,0 +1,18 @@ +use shared_kernel::normalize_required_string; + +use crate::JumpHopDifficulty; + +pub fn parse_jump_hop_difficulty(value: &str) -> JumpHopDifficulty { + match value.trim().to_ascii_lowercase().as_str() { + "easy" | "轻松" => JumpHopDifficulty::Easy, + "advanced" | "进阶" => JumpHopDifficulty::Advanced, + "challenge" | "挑战" => JumpHopDifficulty::Challenge, + _ => JumpHopDifficulty::Standard, + } +} + +pub fn normalize_jump_hop_seed(seed: &str, fallback: &str) -> String { + normalize_required_string(seed) + .or_else(|| normalize_required_string(fallback)) + .unwrap_or_else(|| "jump-hop".to_string()) +} diff --git a/server-rs/crates/module-jump-hop/src/domain.rs b/server-rs/crates/module-jump-hop/src/domain.rs new file mode 100644 index 00000000..bfc20e7f --- /dev/null +++ b/server-rs/crates/module-jump-hop/src/domain.rs @@ -0,0 +1,151 @@ +use serde::{Deserialize, Serialize}; +#[cfg(feature = "spacetime-types")] +use spacetimedb::SpacetimeType; + +pub const JUMP_HOP_SESSION_ID_PREFIX: &str = "jump-hop-session-"; +pub const JUMP_HOP_PROFILE_ID_PREFIX: &str = "jump-hop-profile-"; +pub const JUMP_HOP_WORK_ID_PREFIX: &str = "jump-hop-work-"; +pub const JUMP_HOP_RUN_ID_PREFIX: &str = "jump-hop-run-"; + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum JumpHopDifficulty { + Easy, + Standard, + Advanced, + Challenge, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum JumpHopTileType { + Start, + Normal, + Target, + Finish, + Bonus, + Accent, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum JumpHopRunStatus { + Playing, + Failed, + Cleared, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum JumpHopJumpResultKind { + Miss, + Hit, + Perfect, + Finish, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct JumpHopScoring { + pub charge_to_distance_ratio: f32, + pub max_charge_ms: u32, + pub hit_bonus: u32, + pub perfect_bonus: u32, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct JumpHopPlatform { + pub platform_id: String, + pub tile_type: JumpHopTileType, + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, + pub landing_radius: f32, + pub perfect_radius: f32, + pub score_value: u32, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct JumpHopPath { + pub seed: String, + pub difficulty: JumpHopDifficulty, + pub platforms: Vec, + pub finish_index: u32, + pub camera_preset: String, + pub scoring: JumpHopScoring, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct JumpHopLastJump { + pub charge_ms: u32, + pub jump_distance: f32, + pub target_platform_index: u32, + pub landed_x: f32, + pub landed_y: f32, + pub result: JumpHopJumpResultKind, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct JumpHopRunSnapshot { + pub run_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub status: JumpHopRunStatus, + pub current_platform_index: u32, + pub score: u32, + pub combo: u32, + pub last_jump: Option, + pub started_at_ms: u64, + pub finished_at_ms: Option, + pub path: JumpHopPath, +} + +impl JumpHopDifficulty { + pub fn as_str(self) -> &'static str { + match self { + Self::Easy => "easy", + Self::Standard => "standard", + Self::Advanced => "advanced", + Self::Challenge => "challenge", + } + } +} + +impl JumpHopTileType { + pub fn as_str(self) -> &'static str { + match self { + Self::Start => "start", + Self::Normal => "normal", + Self::Target => "target", + Self::Finish => "finish", + Self::Bonus => "bonus", + Self::Accent => "accent", + } + } +} + +impl JumpHopRunStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Playing => "playing", + Self::Failed => "failed", + Self::Cleared => "cleared", + } + } +} + +impl JumpHopJumpResultKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Miss => "miss", + Self::Hit => "hit", + Self::Perfect => "perfect", + Self::Finish => "finish", + } + } +} diff --git a/server-rs/crates/module-jump-hop/src/errors.rs b/server-rs/crates/module-jump-hop/src/errors.rs new file mode 100644 index 00000000..6ce73bf5 --- /dev/null +++ b/server-rs/crates/module-jump-hop/src/errors.rs @@ -0,0 +1,27 @@ +use std::fmt::{self, Display}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum JumpHopError { + MissingRunId, + MissingProfileId, + MissingOwnerUserId, + EmptyPath, + RunNotPlaying, + NoNextPlatform, +} + +impl Display for JumpHopError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::MissingRunId => "缺少 runId", + Self::MissingProfileId => "缺少 profileId", + Self::MissingOwnerUserId => "owner_user_id 缺失", + Self::EmptyPath => "跳一跳路径为空", + Self::RunNotPlaying => "当前运行态不是 playing", + Self::NoNextPlatform => "没有下一块平台", + }; + write!(f, "{message}") + } +} + +impl std::error::Error for JumpHopError {} diff --git a/server-rs/crates/module-jump-hop/src/events.rs b/server-rs/crates/module-jump-hop/src/events.rs new file mode 100644 index 00000000..70179dcd --- /dev/null +++ b/server-rs/crates/module-jump-hop/src/events.rs @@ -0,0 +1,23 @@ +//! 跳一跳领域事件。 +//! +//! 事件只表达已发生的领域事实,是否持久化、投影或广播由 SpacetimeDB adapter 决定。 + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum JumpHopDomainEvent { + DraftCompiled { + profile_id: String, + owner_user_id: String, + occurred_at_micros: i64, + }, + WorkPublished { + profile_id: String, + owner_user_id: String, + occurred_at_micros: i64, + }, + RunSettled { + run_id: String, + owner_user_id: String, + status: String, + occurred_at_micros: i64, + }, +} diff --git a/server-rs/crates/module-jump-hop/src/lib.rs b/server-rs/crates/module-jump-hop/src/lib.rs new file mode 100644 index 00000000..6acdc7c7 --- /dev/null +++ b/server-rs/crates/module-jump-hop/src/lib.rs @@ -0,0 +1,11 @@ +mod application; +mod commands; +mod domain; +mod errors; +mod events; + +pub use application::*; +pub use commands::*; +pub use domain::*; +pub use errors::*; +pub use events::*; diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index 905a72e7..c3ebf645 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -94,6 +94,17 @@ pub fn default_creation_entry_type_snapshots( 40, updated_at_micros, ), + build_default_creation_entry_type_snapshot( + "jump-hop", + "跳一跳", + "俯视角跳跃闯关", + "可创建", + "/creation-type-references/puzzle.webp", + true, + true, + 45, + updated_at_micros, + ), build_default_creation_entry_type_snapshot( "square-hole", "方洞", diff --git a/server-rs/crates/shared-contracts/src/jump_hop.rs b/server-rs/crates/shared-contracts/src/jump_hop.rs new file mode 100644 index 00000000..e4d4657d --- /dev/null +++ b/server-rs/crates/shared-contracts/src/jump_hop.rs @@ -0,0 +1,401 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum JumpHopDifficulty { + Easy, + Standard, + Advanced, + Challenge, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum JumpHopStylePreset { + MinimalBlocks, + PaperToy, + NeonGlass, + ForestStone, + FutureMetal, + Custom, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum JumpHopGenerationStatus { + Draft, + Generating, + Ready, + Failed, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum JumpHopTileType { + Start, + Normal, + Target, + Finish, + Bonus, + Accent, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum JumpHopActionType { + CompileDraft, + RegenerateCharacter, + RegenerateTiles, + UpdateWorkMeta, + UpdateDifficulty, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum JumpHopRunStatus { + Playing, + Failed, + Cleared, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum JumpHopJumpResult { + Miss, + Hit, + Perfect, + Finish, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopWorkspaceCreateRequest { + pub template_id: String, + pub work_title: String, + pub work_description: String, + pub theme_tags: Vec, + pub difficulty: JumpHopDifficulty, + pub style_preset: JumpHopStylePreset, + pub character_prompt: String, + pub tile_prompt: String, + #[serde(default)] + pub end_mood_prompt: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopActionRequest { + pub action_type: JumpHopActionType, + #[serde(default)] + pub work_title: Option, + #[serde(default)] + pub work_description: Option, + #[serde(default)] + pub theme_tags: Option>, + #[serde(default)] + pub difficulty: Option, + #[serde(default)] + pub style_preset: Option, + #[serde(default)] + pub character_prompt: Option, + #[serde(default)] + pub tile_prompt: Option, + #[serde(default)] + pub end_mood_prompt: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopCharacterAsset { + pub asset_id: String, + pub image_src: String, + pub image_object_key: String, + pub asset_object_id: String, + pub generation_provider: String, + pub prompt: String, + pub width: u32, + pub height: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopTileAsset { + pub tile_type: JumpHopTileType, + pub image_src: String, + pub image_object_key: String, + pub asset_object_id: String, + pub source_atlas_cell: String, + pub visual_width: u32, + pub visual_height: u32, + pub top_surface_radius: f32, + pub landing_radius: f32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopScoring { + pub charge_to_distance_ratio: f32, + pub max_charge_ms: u32, + pub hit_bonus: u32, + pub perfect_bonus: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopPlatform { + pub platform_id: String, + pub tile_type: JumpHopTileType, + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, + pub landing_radius: f32, + pub perfect_radius: f32, + pub score_value: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopPath { + pub seed: String, + pub difficulty: JumpHopDifficulty, + pub platforms: Vec, + pub finish_index: u32, + pub camera_preset: String, + pub scoring: JumpHopScoring, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopLastJump { + pub charge_ms: u32, + pub jump_distance: f32, + pub target_platform_index: u32, + pub landed_x: f32, + pub landed_y: f32, + pub result: JumpHopJumpResult, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopDraftResponse { + pub template_id: String, + pub template_name: String, + #[serde(default)] + pub profile_id: Option, + pub work_title: String, + pub work_description: String, + pub theme_tags: Vec, + pub difficulty: JumpHopDifficulty, + pub style_preset: JumpHopStylePreset, + pub character_prompt: String, + pub tile_prompt: String, + #[serde(default)] + pub end_mood_prompt: Option, + #[serde(default)] + pub character_asset: Option, + #[serde(default)] + pub tile_atlas_asset: Option, + #[serde(default)] + pub tile_assets: Vec, + #[serde(default)] + pub path: Option, + #[serde(default)] + pub cover_composite: Option, + pub generation_status: JumpHopGenerationStatus, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopSessionSnapshotResponse { + pub session_id: String, + pub owner_user_id: String, + pub status: JumpHopGenerationStatus, + #[serde(default)] + pub draft: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopSessionResponse { + pub session: JumpHopSessionSnapshotResponse, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopActionResponse { + pub action_type: JumpHopActionType, + pub session: JumpHopSessionSnapshotResponse, + #[serde(default)] + pub work: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopWorkSummaryResponse { + pub runtime_kind: String, + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + #[serde(default)] + pub source_session_id: Option, + pub work_title: String, + pub work_description: String, + pub theme_tags: Vec, + pub difficulty: JumpHopDifficulty, + pub style_preset: JumpHopStylePreset, + #[serde(default)] + pub cover_image_src: Option, + pub publication_status: String, + pub play_count: u32, + pub updated_at: String, + #[serde(default)] + pub published_at: Option, + pub publish_ready: bool, + pub generation_status: JumpHopGenerationStatus, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopWorkProfileResponse { + #[serde(flatten)] + pub summary: JumpHopWorkSummaryResponse, + pub draft: JumpHopDraftResponse, + pub path: JumpHopPath, + pub character_asset: JumpHopCharacterAsset, + pub tile_atlas_asset: JumpHopCharacterAsset, + pub tile_assets: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopWorksResponse { + pub items: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopWorkDetailResponse { + pub item: JumpHopWorkProfileResponse, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopWorkMutationResponse { + pub item: JumpHopWorkProfileResponse, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopGalleryCardResponse { + pub public_work_code: String, + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub author_display_name: String, + pub work_title: String, + pub work_description: String, + #[serde(default)] + pub cover_image_src: Option, + pub theme_tags: Vec, + pub difficulty: JumpHopDifficulty, + pub style_preset: JumpHopStylePreset, + pub publication_status: String, + pub play_count: u32, + pub updated_at: String, + #[serde(default)] + pub published_at: Option, + pub generation_status: JumpHopGenerationStatus, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopGalleryResponse { + pub items: Vec, + pub has_more: bool, + #[serde(default)] + pub next_cursor: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopGalleryDetailResponse { + pub item: JumpHopWorkProfileResponse, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopRuntimeRunSnapshotResponse { + pub run_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub status: JumpHopRunStatus, + pub current_platform_index: u32, + pub score: u32, + pub combo: u32, + pub path: JumpHopPath, + #[serde(default)] + pub last_jump: Option, + pub started_at_ms: u64, + #[serde(default)] + pub finished_at_ms: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopRunResponse { + pub run: JumpHopRuntimeRunSnapshotResponse, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopStartRunRequest { + pub profile_id: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopJumpRequest { + pub charge_ms: u32, + pub client_event_id: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopRestartRunRequest { + pub client_action_id: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopJumpResponse { + pub run: JumpHopRuntimeRunSnapshotResponse, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn jump_hop_workspace_request_uses_camel_case() { + let payload = serde_json::to_value(JumpHopWorkspaceCreateRequest { + template_id: "jump-hop".to_string(), + work_title: "跳一跳".to_string(), + work_description: "俯视角跳跃闯关".to_string(), + theme_tags: vec!["休闲".to_string()], + difficulty: JumpHopDifficulty::Easy, + style_preset: JumpHopStylePreset::MinimalBlocks, + character_prompt: "角色".to_string(), + tile_prompt: "地块".to_string(), + end_mood_prompt: None, + }) + .expect("payload should serialize"); + + assert_eq!(payload["templateId"], json!("jump-hop")); + assert_eq!(payload["difficulty"], json!("easy")); + assert_eq!(payload["stylePreset"], json!("minimal-blocks")); + } +} diff --git a/server-rs/crates/shared-contracts/src/lib.rs b/server-rs/crates/shared-contracts/src/lib.rs index 19c713fd..5324480d 100644 --- a/server-rs/crates/shared-contracts/src/lib.rs +++ b/server-rs/crates/shared-contracts/src/lib.rs @@ -12,6 +12,7 @@ pub mod creation_audio; pub mod creation_entry_config; pub mod creative_agent; pub mod hyper3d; +pub mod jump_hop; pub mod llm; pub mod match3d_agent; pub mod match3d_runtime; diff --git a/server-rs/crates/spacetime-client/Cargo.toml b/server-rs/crates/spacetime-client/Cargo.toml index 734c0df9..95c172ef 100644 --- a/server-rs/crates/spacetime-client/Cargo.toml +++ b/server-rs/crates/spacetime-client/Cargo.toml @@ -11,6 +11,7 @@ module-big-fish = { workspace = true } module-combat = { workspace = true } module-custom-world = { workspace = true } module-inventory = { workspace = true } +module-jump-hop = { workspace = true } module-match3d = { workspace = true } module-npc = { workspace = true } module-puzzle = { workspace = true } diff --git a/server-rs/crates/spacetime-client/src/jump_hop.rs b/server-rs/crates/spacetime-client/src/jump_hop.rs new file mode 100644 index 00000000..1b22eddf --- /dev/null +++ b/server-rs/crates/spacetime-client/src/jump_hop.rs @@ -0,0 +1,1061 @@ +use super::*; +use crate::mapper::{ + map_jump_hop_agent_session_procedure_result, map_jump_hop_gallery_card_view_row, + map_jump_hop_run_procedure_result, map_jump_hop_work_procedure_result, + map_jump_hop_works_procedure_result, +}; +use shared_contracts::jump_hop::{ + JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset, JumpHopDifficulty, + JumpHopDraftResponse, JumpHopGalleryResponse, JumpHopGenerationStatus, JumpHopJumpRequest, + JumpHopRestartRunRequest, JumpHopRuntimeRunSnapshotResponse, JumpHopSessionSnapshotResponse, + JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset, JumpHopTileType, + JumpHopWorkProfileResponse, +}; +use shared_kernel::build_prefixed_uuid_id; + +const JUMP_HOP_TEMPLATE_ID: &str = "jump-hop"; +const JUMP_HOP_TEMPLATE_NAME: &str = "跳一跳"; + +impl SpacetimeClient { + pub async fn create_jump_hop_session( + &self, + session: JumpHopSessionSnapshotResponse, + ) -> Result { + let draft = session + .draft + .clone() + .ok_or_else(|| SpacetimeClientError::validation_failed("jump-hop session 缺少 draft"))?; + let theme_tags_json = Some(json_string(&draft.theme_tags)?); + let config_json = Some(build_config_json(&draft)?); + let work_title = draft.work_title.clone(); + let work_description = draft.work_description.clone(); + let procedure_input = JumpHopAgentSessionCreateInput { + session_id: session.session_id, + owner_user_id: session.owner_user_id, + seed_text: work_title.clone(), + work_title, + work_description, + theme_tags_json, + welcome_message_text: "跳一跳草稿已准备好。".to_string(), + config_json, + created_at_micros: current_unix_micros(), + }; + + self.call_after_connect( + "create_jump_hop_agent_session", + move |connection, sender| { + connection.procedures().create_jump_hop_agent_session_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_jump_hop_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn get_jump_hop_session( + &self, + session_id: String, + owner_user_id: String, + ) -> Result { + let procedure_input = JumpHopAgentSessionGetInput { + session_id, + owner_user_id, + }; + + self.call_after_connect("get_jump_hop_agent_session", move |connection, sender| { + connection.procedures().get_jump_hop_agent_session_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_jump_hop_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn execute_jump_hop_action( + &self, + session_id: String, + owner_user_id: String, + payload: JumpHopActionRequest, + ) -> Result { + let current = self + .get_jump_hop_session(session_id.clone(), owner_user_id.clone()) + .await?; + let (procedure, _) = + build_jump_hop_action_plan(¤t, &owner_user_id, &payload, current_unix_micros())?; + let (session, work) = match procedure { + JumpHopActionProcedure::Compile(input) => { + let profile_id = input.profile_id.clone(); + let session = self.compile_jump_hop_draft(input).await?; + let work = self + .get_jump_hop_work_profile(profile_id, owner_user_id) + .await + .ok(); + (session, work) + } + JumpHopActionProcedure::Update(input) => { + let work = self.update_jump_hop_work(input).await?; + let session = apply_jump_hop_work_to_session(current, &work); + (session, Some(work)) + } + }; + + Ok(JumpHopActionResponse { + action_type: payload.action_type, + session, + work, + }) + } + + pub async fn compile_jump_hop_draft( + &self, + procedure_input: JumpHopDraftCompileInput, + ) -> Result { + self.call_after_connect("compile_jump_hop_draft", move |connection, sender| { + connection.procedures().compile_jump_hop_draft_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_jump_hop_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn get_jump_hop_work_profile( + &self, + profile_id: String, + owner_user_id: String, + ) -> Result { + let procedure_input = JumpHopWorkGetInput { + profile_id, + owner_user_id, + }; + + self.call_after_connect("get_jump_hop_work_profile", move |connection, sender| { + connection.procedures().get_jump_hop_work_profile_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_jump_hop_work_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn update_jump_hop_work( + &self, + procedure_input: JumpHopWorkUpdateInput, + ) -> Result { + self.call_after_connect("update_jump_hop_work", move |connection, sender| { + connection.procedures().update_jump_hop_work_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_jump_hop_work_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn publish_jump_hop_work( + &self, + profile_id: String, + owner_user_id: String, + ) -> Result { + let procedure_input = JumpHopWorkPublishInput { + profile_id, + owner_user_id, + published_at_micros: current_unix_micros(), + }; + + self.call_after_connect("publish_jump_hop_work", move |connection, sender| { + connection.procedures().publish_jump_hop_work_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_jump_hop_work_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn list_jump_hop_works( + &self, + owner_user_id: String, + ) -> Result, SpacetimeClientError> { + let procedure_input = JumpHopWorksListInput { + owner_user_id, + published_only: false, + }; + + self.call_after_connect("list_jump_hop_works", move |connection, sender| { + connection.procedures().list_jump_hop_works_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_jump_hop_works_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn get_jump_hop_runtime_work( + &self, + profile_id: String, + ) -> Result { + self.get_jump_hop_work_profile(profile_id, String::new()).await + } + + pub async fn start_jump_hop_run( + &self, + payload: JumpHopStartRunRequest, + owner_user_id: String, + ) -> Result { + let run_id = build_prefixed_uuid_id("jump-hop-run-"); + let procedure_input = JumpHopRunStartInput { + client_event_id: format!("{run_id}:start"), + run_id, + owner_user_id, + profile_id: payload.profile_id, + started_at_ms: current_unix_micros().div_euclid(1000), + }; + self.start_jump_hop_run_with_input(procedure_input).await + } + + pub async fn start_jump_hop_run_with_input( + &self, + procedure_input: JumpHopRunStartInput, + ) -> Result { + self.call_after_connect("start_jump_hop_run", move |connection, sender| { + connection.procedures().start_jump_hop_run_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_jump_hop_run_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn get_jump_hop_run( + &self, + run_id: String, + owner_user_id: String, + ) -> Result { + let procedure_input = JumpHopRunGetInput { + run_id, + owner_user_id, + }; + + self.call_after_connect("get_jump_hop_run", move |connection, sender| { + connection.procedures().get_jump_hop_run_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_jump_hop_run_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn jump_hop_run_jump( + &self, + run_id: String, + owner_user_id: String, + payload: JumpHopJumpRequest, + ) -> Result { + let procedure_input = JumpHopRunJumpInput { + run_id, + owner_user_id, + charge_ms: payload.charge_ms, + client_event_id: payload.client_event_id, + jumped_at_ms: current_unix_micros().div_euclid(1000), + }; + + self.call_after_connect("jump_hop_jump", move |connection, sender| { + connection.procedures().jump_hop_jump_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_jump_hop_run_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn restart_jump_hop_run( + &self, + run_id: String, + owner_user_id: String, + payload: JumpHopRestartRunRequest, + ) -> Result { + let procedure_input = JumpHopRunRestartInput { + source_run_id: run_id, + next_run_id: build_prefixed_uuid_id("jump-hop-run-"), + owner_user_id, + client_action_id: payload.client_action_id, + restarted_at_ms: current_unix_micros().div_euclid(1000), + }; + + self.call_after_connect("restart_jump_hop_run", move |connection, sender| { + connection.procedures().restart_jump_hop_run_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_jump_hop_run_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn list_jump_hop_gallery( + &self, + ) -> Result { + self.read_after_connect("list_jump_hop_gallery", move |connection| { + let mut items = connection + .db() + .jump_hop_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)) + }); + + Ok(JumpHopGalleryResponse { + items: items + .into_iter() + .map(map_jump_hop_gallery_card_view_row) + .collect(), + has_more: false, + next_cursor: None, + }) + }) + .await + } + + pub async fn get_jump_hop_gallery_detail( + &self, + public_work_code: String, + ) -> Result { + self.get_jump_hop_work_profile(public_work_code, String::new()) + .await + } +} + +enum JumpHopActionProcedure { + Compile(JumpHopDraftCompileInput), + Update(JumpHopWorkUpdateInput), +} + +#[derive(Clone, Copy)] +enum JumpHopDraftMergeScope { + CompileDraft, + RegenerateCharacter, + RegenerateTiles, + UpdateWorkMeta, + UpdateDifficulty, +} + +#[derive(Clone, Copy)] +enum JumpHopAssetRefresh { + Preserve, + Character, + Tiles, +} + +fn build_jump_hop_action_plan( + current: &JumpHopSessionSnapshotResponse, + owner_user_id: &str, + payload: &JumpHopActionRequest, + now_micros: i64, +) -> Result<(JumpHopActionProcedure, JumpHopDraftResponse), SpacetimeClientError> { + let scope = match payload.action_type { + JumpHopActionType::CompileDraft => JumpHopDraftMergeScope::CompileDraft, + JumpHopActionType::RegenerateCharacter => JumpHopDraftMergeScope::RegenerateCharacter, + JumpHopActionType::RegenerateTiles => JumpHopDraftMergeScope::RegenerateTiles, + JumpHopActionType::UpdateWorkMeta => JumpHopDraftMergeScope::UpdateWorkMeta, + JumpHopActionType::UpdateDifficulty => JumpHopDraftMergeScope::UpdateDifficulty, + }; + let mut draft = merge_action_into_draft(current.draft.clone(), payload, scope)?; + let profile_id = resolve_jump_hop_profile_id(&draft, &payload.action_type)?; + draft.profile_id = Some(profile_id.clone()); + + let procedure = match payload.action_type { + JumpHopActionType::CompileDraft => JumpHopActionProcedure::Compile(build_compile_input( + current, + owner_user_id, + &profile_id, + &mut draft, + JumpHopAssetRefresh::Preserve, + now_micros, + )?), + JumpHopActionType::RegenerateCharacter => JumpHopActionProcedure::Compile( + build_compile_input( + current, + owner_user_id, + &profile_id, + &mut draft, + JumpHopAssetRefresh::Character, + now_micros, + )?, + ), + JumpHopActionType::RegenerateTiles => JumpHopActionProcedure::Compile(build_compile_input( + current, + owner_user_id, + &profile_id, + &mut draft, + JumpHopAssetRefresh::Tiles, + now_micros, + )?), + JumpHopActionType::UpdateWorkMeta | JumpHopActionType::UpdateDifficulty => { + JumpHopActionProcedure::Update(build_update_input( + owner_user_id, + &profile_id, + &draft, + &payload.action_type, + now_micros, + )?) + } + }; + + Ok((procedure, draft)) +} + +fn merge_action_into_draft( + draft: Option, + payload: &JumpHopActionRequest, + scope: JumpHopDraftMergeScope, +) -> Result { + let mut draft = draft.unwrap_or_else(default_draft); + if matches!( + scope, + JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::UpdateWorkMeta + ) { + if let Some(value) = payload.work_title.as_ref().filter(|value| !value.trim().is_empty()) { + draft.work_title = value.trim().to_string(); + } + if let Some(value) = payload.work_description.as_ref() { + draft.work_description = value.trim().to_string(); + } + if let Some(value) = payload.theme_tags.clone() { + draft.theme_tags = normalize_jump_hop_tags(value); + } + } + if matches!( + scope, + JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::UpdateDifficulty + ) { + if let Some(value) = payload.difficulty.clone() { + draft.difficulty = value; + } + } + if matches!(scope, JumpHopDraftMergeScope::CompileDraft) { + if let Some(value) = payload.style_preset.clone() { + draft.style_preset = value; + } + if payload.end_mood_prompt.is_some() { + draft.end_mood_prompt = payload + .end_mood_prompt + .as_ref() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + } + } + if matches!( + scope, + JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateCharacter + ) && let Some(value) = payload + .character_prompt + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + draft.character_prompt = value.trim().to_string(); + } + if matches!( + scope, + JumpHopDraftMergeScope::CompileDraft | JumpHopDraftMergeScope::RegenerateTiles + ) && let Some(value) = payload + .tile_prompt + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + draft.tile_prompt = value.trim().to_string(); + } + if draft.work_title.trim().is_empty() { + return Err(SpacetimeClientError::validation_failed("jump-hop work_title 不能为空")); + } + Ok(draft) +} + +fn build_compile_input( + current: &JumpHopSessionSnapshotResponse, + owner_user_id: &str, + profile_id: &str, + draft: &mut JumpHopDraftResponse, + refresh: JumpHopAssetRefresh, + now_micros: i64, +) -> Result { + let force_character = matches!(refresh, JumpHopAssetRefresh::Character); + let force_tiles = matches!(refresh, JumpHopAssetRefresh::Tiles); + if force_character { + draft.character_asset = None; + } + if force_tiles { + draft.tile_atlas_asset = None; + draft.tile_assets.clear(); + } + let character_asset = ensure_character_asset( + draft.character_asset.clone(), + profile_id, + &draft.character_prompt, + force_character, + now_micros, + ); + let tile_atlas_asset = ensure_tile_atlas_asset( + draft.tile_atlas_asset.clone(), + profile_id, + &draft.tile_prompt, + force_tiles, + now_micros, + ); + let tile_assets = ensure_tile_assets( + draft.tile_assets.clone(), + profile_id, + force_tiles, + now_micros, + ); + let cover_composite = resolve_cover_composite(draft, profile_id, refresh, now_micros); + + draft.character_asset = Some(character_asset.clone()); + draft.tile_atlas_asset = Some(tile_atlas_asset.clone()); + draft.tile_assets = tile_assets.clone(); + draft.cover_composite = cover_composite.clone(); + draft.generation_status = JumpHopGenerationStatus::Ready; + + Ok(JumpHopDraftCompileInput { + session_id: current.session_id.clone(), + owner_user_id: owner_user_id.to_string(), + profile_id: profile_id.to_string(), + author_display_name: "跳一跳玩家".to_string(), + seed_text: draft.work_title.clone(), + work_title: draft.work_title.clone(), + work_description: draft.work_description.clone(), + theme_tags_json: Some(json_string(&draft.theme_tags)?), + theme_text: Some(draft.work_title.clone()), + difficulty: Some(difficulty_to_str(&draft.difficulty).to_string()), + style_preset: Some(style_to_str(&draft.style_preset).to_string()), + character_prompt: Some(draft.character_prompt.clone()), + tile_prompt: Some(draft.tile_prompt.clone()), + end_mood_prompt: draft.end_mood_prompt.clone(), + character_asset_json: Some(json_string(&character_asset)?), + tile_atlas_asset_json: Some(json_string(&tile_atlas_asset)?), + tile_assets_json: Some(json_string(&tile_assets)?), + cover_composite, + generation_status: Some("ready".to_string()), + compiled_at_micros: now_micros, + }) +} + +fn build_update_input( + owner_user_id: &str, + profile_id: &str, + draft: &JumpHopDraftResponse, + action_type: &JumpHopActionType, + now_micros: i64, +) -> Result { + Ok(JumpHopWorkUpdateInput { + profile_id: profile_id.to_string(), + owner_user_id: owner_user_id.to_string(), + work_title: draft.work_title.clone(), + work_description: draft.work_description.clone(), + theme_tags_json: json_string(&draft.theme_tags)?, + difficulty: matches!(action_type, JumpHopActionType::UpdateDifficulty) + .then(|| difficulty_to_str(&draft.difficulty).to_string()), + style_preset: None, + cover_image_src: None, + cover_composite: None, + updated_at_micros: now_micros, + }) +} + +fn resolve_jump_hop_profile_id( + draft: &JumpHopDraftResponse, + action_type: &JumpHopActionType, +) -> Result { + if let Some(profile_id) = draft + .profile_id + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + { + return Ok(profile_id.to_string()); + } + if matches!(action_type, JumpHopActionType::CompileDraft) { + return Ok(build_prefixed_uuid_id("jump-hop-profile-")); + } + Err(SpacetimeClientError::validation_failed( + "jump-hop action 需要先完成 compile-draft", + )) +} + +fn apply_jump_hop_work_to_session( + mut session: JumpHopSessionSnapshotResponse, + work: &JumpHopWorkProfileResponse, +) -> JumpHopSessionSnapshotResponse { + session.status = work.draft.generation_status.clone(); + session.draft = Some(work.draft.clone()); + session.updated_at = work.summary.updated_at.clone(); + session +} + +fn normalize_jump_hop_tags(tags: Vec) -> Vec { + tags.into_iter() + .map(|tag| tag.trim().to_string()) + .filter(|tag| !tag.is_empty()) + .take(8) + .collect() +} + +fn default_draft() -> JumpHopDraftResponse { + JumpHopDraftResponse { + template_id: JUMP_HOP_TEMPLATE_ID.to_string(), + template_name: JUMP_HOP_TEMPLATE_NAME.to_string(), + profile_id: None, + work_title: JUMP_HOP_TEMPLATE_NAME.to_string(), + work_description: "俯视角跳跃闯关".to_string(), + theme_tags: vec!["跳一跳".to_string(), "休闲".to_string()], + difficulty: JumpHopDifficulty::Standard, + style_preset: JumpHopStylePreset::MinimalBlocks, + character_prompt: "俯视角可爱主角,透明背景".to_string(), + tile_prompt: "等距立体地块图集".to_string(), + end_mood_prompt: None, + character_asset: None, + tile_atlas_asset: None, + tile_assets: Vec::new(), + path: None, + cover_composite: None, + generation_status: JumpHopGenerationStatus::Draft, + } +} + +fn build_config_json(draft: &JumpHopDraftResponse) -> Result { + serde_json::to_string(&serde_json::json!({ + "themeText": draft.work_title, + "difficulty": difficulty_to_str(&draft.difficulty), + "stylePreset": style_to_str(&draft.style_preset), + "characterPrompt": draft.character_prompt, + "tilePrompt": draft.tile_prompt, + "endMoodPrompt": draft.end_mood_prompt.clone().unwrap_or_default(), + })) + .map_err(SpacetimeClientError::validation_failed) +} + +fn ensure_character_asset( + existing: Option, + profile_id: &str, + prompt: &str, + force_new: bool, + now_micros: i64, +) -> JumpHopCharacterAsset { + if !force_new && let Some(asset) = existing { + return asset; + } + let revision = force_new.then_some(now_micros); + let suffix = asset_revision_suffix(revision); + JumpHopCharacterAsset { + asset_id: format!("{profile_id}-character{suffix}"), + image_src: format!("/generated-jump-hop-assets/{profile_id}/character{suffix}.png"), + image_object_key: format!("generated-jump-hop-assets/{profile_id}/character{suffix}.png"), + asset_object_id: format!("{profile_id}-character{suffix}-object"), + generation_provider: "deterministic-placeholder".to_string(), + prompt: prompt.to_string(), + width: 768, + height: 768, + } +} + +fn ensure_tile_atlas_asset( + existing: Option, + profile_id: &str, + prompt: &str, + force_new: bool, + now_micros: i64, +) -> JumpHopCharacterAsset { + if !force_new && let Some(asset) = existing { + return asset; + } + let revision = force_new.then_some(now_micros); + let suffix = asset_revision_suffix(revision); + JumpHopCharacterAsset { + asset_id: format!("{profile_id}-tile-atlas{suffix}"), + image_src: format!("/generated-jump-hop-assets/{profile_id}/tile-atlas{suffix}.png"), + image_object_key: format!("generated-jump-hop-assets/{profile_id}/tile-atlas{suffix}.png"), + asset_object_id: format!("{profile_id}-tile-atlas{suffix}-object"), + generation_provider: "deterministic-placeholder".to_string(), + prompt: prompt.to_string(), + width: 1024, + height: 1024, + } +} + +fn ensure_tile_assets( + existing: Vec, + profile_id: &str, + force_new: bool, + now_micros: i64, +) -> Vec { + if !force_new && !existing.is_empty() { + return existing; + } + let suffix = asset_revision_suffix(force_new.then_some(now_micros)); + [ + JumpHopTileType::Start, + JumpHopTileType::Normal, + JumpHopTileType::Target, + JumpHopTileType::Finish, + JumpHopTileType::Bonus, + JumpHopTileType::Accent, + ] + .into_iter() + .enumerate() + .map(|(index, tile_type)| JumpHopTileAsset { + tile_type, + image_src: format!("/generated-jump-hop-assets/{profile_id}/tiles/{index}{suffix}.png"), + image_object_key: format!("generated-jump-hop-assets/{profile_id}/tiles/{index}{suffix}.png"), + asset_object_id: format!("{profile_id}-tile-{index}{suffix}-object"), + source_atlas_cell: format!("cell-{index}{suffix}"), + visual_width: 256, + visual_height: 192, + top_surface_radius: 42.0, + landing_radius: 34.0, + }) + .collect() +} + +fn resolve_cover_composite( + draft: &JumpHopDraftResponse, + profile_id: &str, + refresh: JumpHopAssetRefresh, + now_micros: i64, +) -> Option { + if matches!(refresh, JumpHopAssetRefresh::Preserve) + && let Some(value) = draft + .cover_composite + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + { + return Some(value.to_string()); + } + let suffix = asset_revision_suffix((!matches!(refresh, JumpHopAssetRefresh::Preserve)).then_some(now_micros)); + Some(format!( + "/generated-jump-hop-assets/{profile_id}/cover-composite{suffix}.png" + )) +} + +fn asset_revision_suffix(revision: Option) -> String { + revision + .filter(|value| *value > 0) + .map(|value| format!("-{value}")) + .unwrap_or_default() +} + +fn json_string(value: &T) -> Result { + serde_json::to_string(value).map_err(SpacetimeClientError::validation_failed) +} + +fn difficulty_to_str(value: &JumpHopDifficulty) -> &'static str { + match value { + JumpHopDifficulty::Easy => "easy", + JumpHopDifficulty::Standard => "standard", + JumpHopDifficulty::Advanced => "advanced", + JumpHopDifficulty::Challenge => "challenge", + } +} + +fn style_to_str(value: &JumpHopStylePreset) -> &'static str { + match value { + JumpHopStylePreset::MinimalBlocks => "minimal-blocks", + JumpHopStylePreset::PaperToy => "paper-toy", + JumpHopStylePreset::NeonGlass => "neon-glass", + JumpHopStylePreset::ForestStone => "forest-stone", + JumpHopStylePreset::FutureMetal => "future-metal", + JumpHopStylePreset::Custom => "custom", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use shared_contracts::jump_hop::JumpHopActionType; + + const SESSION_ID: &str = "jump-hop-session-test"; + const OWNER_USER_ID: &str = "user-test"; + const PROFILE_ID: &str = "jump-hop-profile-test"; + const NOW_MICROS: i64 = 1_763_456_789_000_000; + + #[test] + fn jump_hop_action_compile_draft_builds_compile_input_with_assets() { + let session = session_with_draft(draft_without_assets()); + let payload = action(JumpHopActionType::CompileDraft); + + let (plan, draft) = + build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) + .expect("compile-draft should build plan"); + + let JumpHopActionProcedure::Compile(input) = plan else { + panic!("compile-draft should call compile_jump_hop_draft"); + }; + assert_eq!(input.session_id, SESSION_ID); + assert_eq!(input.owner_user_id, OWNER_USER_ID); + assert_eq!(input.generation_status.as_deref(), Some("ready")); + assert!(input.character_asset_json.as_deref().unwrap_or("").contains("-character")); + assert!(input.tile_atlas_asset_json.as_deref().unwrap_or("").contains("-tile-atlas")); + assert!(input.tile_assets_json.as_deref().unwrap_or("").contains("tile-0-object")); + assert_eq!(draft.generation_status, JumpHopGenerationStatus::Ready); + } + + #[test] + fn jump_hop_action_regenerate_character_replaces_only_character_asset_input() { + let session = session_with_draft(draft_with_assets()); + let mut payload = action(JumpHopActionType::RegenerateCharacter); + payload.character_prompt = Some("新的主角提示词".to_string()); + + let (plan, _draft) = + build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) + .expect("regenerate-character should build plan"); + + let JumpHopActionProcedure::Compile(input) = plan else { + panic!("regenerate-character should call compile_jump_hop_draft"); + }; + assert!(!input.character_asset_json.as_deref().unwrap_or("").contains("old-character")); + assert!(input.character_asset_json.as_deref().unwrap_or("").contains(&NOW_MICROS.to_string())); + assert!(input.tile_atlas_asset_json.as_deref().unwrap_or("").contains("old-tile-atlas")); + assert!(input.tile_assets_json.as_deref().unwrap_or("").contains("old-normal-tile")); + } + + #[test] + fn jump_hop_action_regenerate_tiles_replaces_only_tile_asset_input() { + let session = session_with_draft(draft_with_assets()); + let mut payload = action(JumpHopActionType::RegenerateTiles); + payload.tile_prompt = Some("新的地块提示词".to_string()); + + let (plan, _draft) = + build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) + .expect("regenerate-tiles should build plan"); + + let JumpHopActionProcedure::Compile(input) = plan else { + panic!("regenerate-tiles should call compile_jump_hop_draft"); + }; + assert!(input.character_asset_json.as_deref().unwrap_or("").contains("old-character")); + assert!(!input.tile_atlas_asset_json.as_deref().unwrap_or("").contains("old-tile-atlas")); + assert!(!input.tile_assets_json.as_deref().unwrap_or("").contains("old-normal-tile")); + assert!(input.tile_atlas_asset_json.as_deref().unwrap_or("").contains(&NOW_MICROS.to_string())); + assert!(input.tile_assets_json.as_deref().unwrap_or("").contains(&NOW_MICROS.to_string())); + } + + #[test] + fn jump_hop_action_update_work_meta_builds_update_input_without_asset_compile() { + let session = session_with_draft(draft_with_assets()); + let mut payload = action(JumpHopActionType::UpdateWorkMeta); + payload.work_title = Some("新标题".to_string()); + payload.work_description = Some("新描述".to_string()); + payload.theme_tags = Some(vec![" A ".to_string(), "B".to_string()]); + payload.character_prompt = Some("不应影响角色资产".to_string()); + + let (plan, draft) = + build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) + .expect("update-work-meta should build plan"); + + let JumpHopActionProcedure::Update(input) = plan else { + panic!("update-work-meta should call update_jump_hop_work"); + }; + assert_eq!(input.profile_id, PROFILE_ID); + assert_eq!(input.work_title, "新标题"); + assert_eq!(input.work_description, "新描述"); + assert!(input.difficulty.is_none()); + assert!(input.style_preset.is_none()); + assert_eq!(draft.character_prompt, "旧角色提示词"); + } + + #[test] + fn jump_hop_action_update_difficulty_builds_update_input_without_asset_compile() { + let session = session_with_draft(draft_with_assets()); + let mut payload = action(JumpHopActionType::UpdateDifficulty); + payload.difficulty = Some(JumpHopDifficulty::Challenge); + + let (plan, draft) = + build_jump_hop_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) + .expect("update-difficulty should build plan"); + + let JumpHopActionProcedure::Update(input) = plan else { + panic!("update-difficulty should call update_jump_hop_work"); + }; + assert_eq!(input.difficulty.as_deref(), Some("challenge")); + assert!(input.style_preset.is_none()); + assert_eq!(draft.character_asset.as_ref().map(|asset| asset.asset_id.as_str()), Some("old-character")); + assert_eq!(draft.tile_assets.first().map(|asset| asset.asset_object_id.as_str()), Some("old-normal-tile-object")); + } + + fn action(action_type: JumpHopActionType) -> JumpHopActionRequest { + JumpHopActionRequest { + action_type, + work_title: None, + work_description: None, + theme_tags: None, + difficulty: None, + style_preset: None, + character_prompt: None, + tile_prompt: None, + end_mood_prompt: None, + } + } + + fn session_with_draft(draft: JumpHopDraftResponse) -> JumpHopSessionSnapshotResponse { + JumpHopSessionSnapshotResponse { + session_id: SESSION_ID.to_string(), + owner_user_id: OWNER_USER_ID.to_string(), + status: draft.generation_status.clone(), + draft: Some(draft), + created_at: "2026-05-19T00:00:00Z".to_string(), + updated_at: "2026-05-19T00:00:00Z".to_string(), + } + } + + fn draft_without_assets() -> JumpHopDraftResponse { + JumpHopDraftResponse { + profile_id: None, + ..base_draft() + } + } + + fn draft_with_assets() -> JumpHopDraftResponse { + JumpHopDraftResponse { + profile_id: Some(PROFILE_ID.to_string()), + character_asset: Some(JumpHopCharacterAsset { + asset_id: "old-character".to_string(), + image_src: "/generated-jump-hop-assets/old-character.png".to_string(), + image_object_key: "generated-jump-hop-assets/old-character.png".to_string(), + asset_object_id: "old-character-object".to_string(), + generation_provider: "old-provider".to_string(), + prompt: "旧角色提示词".to_string(), + width: 768, + height: 768, + }), + tile_atlas_asset: Some(JumpHopCharacterAsset { + asset_id: "old-tile-atlas".to_string(), + image_src: "/generated-jump-hop-assets/old-tile-atlas.png".to_string(), + image_object_key: "generated-jump-hop-assets/old-tile-atlas.png".to_string(), + asset_object_id: "old-tile-atlas-object".to_string(), + generation_provider: "old-provider".to_string(), + prompt: "旧地块提示词".to_string(), + width: 1024, + height: 1024, + }), + tile_assets: vec![JumpHopTileAsset { + tile_type: JumpHopTileType::Normal, + image_src: "/generated-jump-hop-assets/old-normal-tile.png".to_string(), + image_object_key: "generated-jump-hop-assets/old-normal-tile.png".to_string(), + asset_object_id: "old-normal-tile-object".to_string(), + source_atlas_cell: "old-cell".to_string(), + visual_width: 256, + visual_height: 192, + top_surface_radius: 42.0, + landing_radius: 34.0, + }], + path: Some(sample_jump_hop_path()), + cover_composite: Some("/generated-jump-hop-assets/old-cover.png".to_string()), + generation_status: JumpHopGenerationStatus::Ready, + ..base_draft() + } + } + + fn base_draft() -> JumpHopDraftResponse { + JumpHopDraftResponse { + template_id: JUMP_HOP_TEMPLATE_ID.to_string(), + template_name: JUMP_HOP_TEMPLATE_NAME.to_string(), + profile_id: None, + work_title: "旧标题".to_string(), + work_description: "旧描述".to_string(), + theme_tags: vec!["旧标签".to_string()], + difficulty: JumpHopDifficulty::Standard, + style_preset: JumpHopStylePreset::MinimalBlocks, + character_prompt: "旧角色提示词".to_string(), + tile_prompt: "旧地块提示词".to_string(), + end_mood_prompt: None, + character_asset: None, + tile_atlas_asset: None, + tile_assets: Vec::new(), + path: None, + cover_composite: None, + generation_status: JumpHopGenerationStatus::Draft, + } + } + + fn sample_jump_hop_path() -> JumpHopPath { + JumpHopPath { + seed: "jump-hop-test".to_string(), + difficulty: JumpHopDifficulty::Standard, + platforms: vec![JumpHopPlatform { + platform_id: "platform-0".to_string(), + tile_type: JumpHopTileType::Start, + x: 0.0, + y: 0.0, + width: 92.0, + height: 70.0, + landing_radius: 34.0, + perfect_radius: 14.0, + score_value: 10, + }], + finish_index: 0, + camera_preset: "portrait-isometric-follow".to_string(), + scoring: JumpHopScoring { + charge_to_distance_ratio: 0.018, + max_charge_ms: 1_200, + hit_bonus: 10, + perfect_bonus: 20, + }, + } + } +} diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index b3b33e7d..26ff1ad9 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -38,6 +38,16 @@ pub use mapper::{ Match3DRunClickRecordInput, Match3DRunRecord, Match3DRunRestartRecordInput, Match3DRunStartRecordInput, Match3DRunStopRecordInput, Match3DRunTimeUpRecordInput, Match3DTraySlotRecord, Match3DWorkProfileRecord, Match3DWorkUpdateRecordInput, + JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset, + JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse, + JumpHopGalleryDetailResponse, JumpHopGalleryResponse, JumpHopGenerationStatus, + JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, JumpHopLastJump, JumpHopPath, + JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus, + JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse, + JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset, + JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, + JumpHopWorkProfileResponse, JumpHopWorkSummaryResponse, JumpHopWorksResponse, + JumpHopWorkspaceCreateRequest, NpcBattleInteractionRecord, NpcInteractionRecord, NpcStateRecord, PuzzleAgentMessageFinalizeRecordInput, PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, @@ -86,6 +96,7 @@ pub mod big_fish; pub mod combat; pub mod custom_world; pub mod inventory; +pub mod jump_hop; pub mod match3d; pub mod npc; pub mod puzzle; @@ -551,6 +562,7 @@ impl SpacetimeClient { let mut subscriptions = Vec::new(); for query in [ "SELECT * FROM puzzle_gallery_card_view", + "SELECT * FROM jump_hop_gallery_card_view", "SELECT * FROM custom_world_gallery_entry", "SELECT * FROM match_3_d_gallery_view", "SELECT * FROM square_hole_gallery_view", @@ -565,6 +577,7 @@ impl SpacetimeClient { for query in [ "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle'", + "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'jump-hop'", "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'", diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 7f6a1904..23ce69a8 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -9,6 +9,7 @@ mod combat; mod common; mod custom_world; mod inventory; +mod jump_hop; mod match3d; mod npc; mod puzzle; @@ -35,6 +36,18 @@ pub use self::combat::{ BarkBattleDraftConfigRecord, BarkBattleRunRecord, BarkBattleRuntimeConfigRecord, ResolveCombatActionRecord, }; +pub use self::jump_hop::{ + JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset, + JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse, + JumpHopGalleryDetailResponse, JumpHopGalleryResponse, JumpHopGenerationStatus, + JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, JumpHopLastJump, JumpHopPath, + JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus, + JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse, + JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset, + JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, + JumpHopWorkProfileResponse, JumpHopWorkSummaryResponse, JumpHopWorksResponse, + JumpHopWorkspaceCreateRequest, +}; pub use self::common::{ BigFishAgentMessageRecord, BigFishAnchorItemRecord, BigFishAnchorPackRecord, BigFishBackgroundBlueprintRecord, BigFishDraftCompileRecordInput, @@ -139,6 +152,11 @@ 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::jump_hop::{ + map_jump_hop_agent_session_procedure_result, map_jump_hop_gallery_card_view_row, + map_jump_hop_run_procedure_result, map_jump_hop_work_procedure_result, + map_jump_hop_works_procedure_result, +}; 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, @@ -158,9 +176,9 @@ pub(crate) use self::runtime::{ build_creation_entry_config_record_from_rows, map_creation_entry_config_procedure_result, map_runtime_setting_procedure_result, map_runtime_snapshot_delete_procedure_result, map_runtime_snapshot_procedure_result, map_runtime_snapshot_required_procedure_result, - map_runtime_tracking_event_procedure_result, map_runtime_tracking_scope_kind, - map_runtime_tracking_scope_kind_back, parse_json_array, parse_json_string_array, - parse_json_value, parse_supported_actions_json, + 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, diff --git a/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs b/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs new file mode 100644 index 00000000..e5716a14 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/mapper/jump_hop.rs @@ -0,0 +1,344 @@ +use super::*; +pub use shared_contracts::jump_hop::{ + JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset, + JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse, + JumpHopGalleryDetailResponse, JumpHopGalleryResponse, JumpHopGenerationStatus, + JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, JumpHopLastJump, JumpHopPath, + JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus, + JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse, + JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset, + JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, + JumpHopWorkProfileResponse, JumpHopWorkSummaryResponse, JumpHopWorksResponse, + JumpHopWorkspaceCreateRequest, +}; + +pub(crate) fn map_jump_hop_agent_session_procedure_result( + result: JumpHopAgentSessionProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + let session = result + .session + .ok_or_else(|| SpacetimeClientError::missing_snapshot("jump hop agent session 快照"))?; + Ok(map_jump_hop_session_snapshot(session)) +} + +pub(crate) fn map_jump_hop_work_procedure_result( + result: JumpHopWorkProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + let work = result + .work + .ok_or_else(|| SpacetimeClientError::missing_snapshot("jump hop work 快照"))?; + map_jump_hop_work_snapshot(work) +} + +pub(crate) fn map_jump_hop_works_procedure_result( + result: JumpHopWorksProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + result + .items + .into_iter() + .map(map_jump_hop_work_snapshot) + .collect() +} + +pub(crate) fn map_jump_hop_run_procedure_result( + result: JumpHopRunProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + let run = result + .run + .ok_or_else(|| SpacetimeClientError::missing_snapshot("jump hop run 快照"))?; + Ok(map_jump_hop_run_snapshot(run)) +} + +pub(crate) fn map_jump_hop_gallery_card_view_row( + row: JumpHopGalleryCardViewRow, +) -> JumpHopGalleryCardResponse { + JumpHopGalleryCardResponse { + public_work_code: row.public_work_code, + work_id: row.work_id, + profile_id: row.profile_id, + owner_user_id: row.owner_user_id, + author_display_name: row.author_display_name, + work_title: row.work_title, + work_description: row.work_description, + cover_image_src: empty_string_to_none(row.cover_image_src), + theme_tags: row.theme_tags, + difficulty: parse_difficulty(&row.difficulty), + style_preset: parse_style_preset(&row.style_preset), + publication_status: normalize_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), + generation_status: parse_generation_status(&row.generation_status), + } +} + +fn map_jump_hop_session_snapshot( + snapshot: JumpHopAgentSessionSnapshot, +) -> JumpHopSessionSnapshotResponse { + JumpHopSessionSnapshotResponse { + session_id: snapshot.session_id, + owner_user_id: snapshot.owner_user_id, + status: snapshot + .draft + .as_ref() + .map(|draft| parse_generation_status(&draft.generation_status)) + .unwrap_or(JumpHopGenerationStatus::Draft), + draft: snapshot.draft.map(map_jump_hop_draft_snapshot), + created_at: format_timestamp_micros(snapshot.created_at_micros), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +fn map_jump_hop_work_snapshot( + snapshot: JumpHopWorkSnapshot, +) -> Result { + let draft = JumpHopDraftResponse { + template_id: "jump-hop".to_string(), + template_name: "跳一跳".to_string(), + profile_id: Some(snapshot.profile_id.clone()), + work_title: snapshot.work_title.clone(), + work_description: snapshot.work_description.clone(), + theme_tags: snapshot.theme_tags.clone(), + difficulty: parse_difficulty(&snapshot.difficulty), + style_preset: parse_style_preset(&snapshot.style_preset), + character_prompt: snapshot.character_prompt.clone(), + tile_prompt: snapshot.tile_prompt.clone(), + end_mood_prompt: snapshot.end_mood_prompt.clone(), + character_asset: snapshot.character_asset.clone().map(map_character_asset), + tile_atlas_asset: snapshot.tile_atlas_asset.clone().map(map_character_asset), + tile_assets: snapshot + .tile_assets + .clone() + .into_iter() + .map(map_tile_asset) + .collect(), + path: Some(map_jump_hop_path(snapshot.path.clone())), + cover_composite: snapshot.cover_composite.clone(), + generation_status: parse_generation_status(&snapshot.generation_status), + }; + let character_asset = draft + .character_asset + .clone() + .ok_or_else(|| SpacetimeClientError::missing_snapshot("jump hop character asset"))?; + let tile_atlas_asset = draft + .tile_atlas_asset + .clone() + .ok_or_else(|| SpacetimeClientError::missing_snapshot("jump hop tile atlas asset"))?; + Ok(JumpHopWorkProfileResponse { + summary: JumpHopWorkSummaryResponse { + runtime_kind: "jump-hop".to_string(), + 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), + work_title: snapshot.work_title, + work_description: snapshot.work_description, + theme_tags: snapshot.theme_tags, + difficulty: parse_difficulty(&snapshot.difficulty), + style_preset: parse_style_preset(&snapshot.style_preset), + cover_image_src: empty_string_to_none(snapshot.cover_image_src), + publication_status: normalize_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, + generation_status: parse_generation_status(&snapshot.generation_status), + }, + draft, + path: map_jump_hop_path(snapshot.path), + character_asset, + tile_atlas_asset, + tile_assets: snapshot.tile_assets.into_iter().map(map_tile_asset).collect(), + }) +} + +fn map_jump_hop_draft_snapshot(snapshot: JumpHopDraftSnapshot) -> JumpHopDraftResponse { + JumpHopDraftResponse { + template_id: snapshot.template_id, + template_name: snapshot.template_name, + profile_id: snapshot.profile_id, + work_title: snapshot.work_title, + work_description: snapshot.work_description, + theme_tags: snapshot.theme_tags, + difficulty: parse_difficulty(&snapshot.difficulty), + style_preset: parse_style_preset(&snapshot.style_preset), + character_prompt: snapshot.character_prompt, + tile_prompt: snapshot.tile_prompt, + end_mood_prompt: snapshot.end_mood_prompt, + character_asset: snapshot.character_asset.map(map_character_asset), + tile_atlas_asset: snapshot.tile_atlas_asset.map(map_character_asset), + tile_assets: snapshot.tile_assets.into_iter().map(map_tile_asset).collect(), + path: snapshot.path.map(map_jump_hop_path), + cover_composite: snapshot.cover_composite, + generation_status: parse_generation_status(&snapshot.generation_status), + } +} + +fn map_character_asset(snapshot: JumpHopCharacterAssetSnapshot) -> JumpHopCharacterAsset { + JumpHopCharacterAsset { + asset_id: snapshot.asset_id, + image_src: snapshot.image_src, + image_object_key: snapshot.image_object_key, + asset_object_id: snapshot.asset_object_id, + generation_provider: snapshot.generation_provider, + prompt: snapshot.prompt, + width: snapshot.width, + height: snapshot.height, + } +} + +fn map_tile_asset(snapshot: JumpHopTileAssetSnapshot) -> JumpHopTileAsset { + JumpHopTileAsset { + tile_type: parse_tile_type(&snapshot.tile_type), + image_src: snapshot.image_src, + image_object_key: snapshot.image_object_key, + asset_object_id: snapshot.asset_object_id, + source_atlas_cell: snapshot.source_atlas_cell, + visual_width: snapshot.visual_width, + visual_height: snapshot.visual_height, + top_surface_radius: snapshot.top_surface_radius, + landing_radius: snapshot.landing_radius, + } +} + +fn map_jump_hop_path(snapshot: crate::module_bindings::JumpHopPath) -> JumpHopPath { + JumpHopPath { + seed: snapshot.seed, + difficulty: parse_domain_difficulty(snapshot.difficulty), + platforms: snapshot + .platforms + .into_iter() + .map(|platform| JumpHopPlatform { + platform_id: platform.platform_id, + tile_type: parse_domain_tile_type(platform.tile_type), + x: platform.x, + y: platform.y, + width: platform.width, + height: platform.height, + landing_radius: platform.landing_radius, + perfect_radius: platform.perfect_radius, + score_value: platform.score_value, + }) + .collect(), + finish_index: snapshot.finish_index, + camera_preset: snapshot.camera_preset, + scoring: JumpHopScoring { + charge_to_distance_ratio: snapshot.scoring.charge_to_distance_ratio, + max_charge_ms: snapshot.scoring.max_charge_ms, + hit_bonus: snapshot.scoring.hit_bonus, + perfect_bonus: snapshot.scoring.perfect_bonus, + }, + } +} + +fn map_jump_hop_run_snapshot(snapshot: JumpHopRunSnapshot) -> JumpHopRuntimeRunSnapshotResponse { + JumpHopRuntimeRunSnapshotResponse { + run_id: snapshot.run_id, + profile_id: snapshot.profile_id, + owner_user_id: snapshot.owner_user_id, + status: match snapshot.status { + crate::module_bindings::JumpHopRunStatus::Failed => JumpHopRunStatus::Failed, + crate::module_bindings::JumpHopRunStatus::Cleared => JumpHopRunStatus::Cleared, + crate::module_bindings::JumpHopRunStatus::Playing => JumpHopRunStatus::Playing, + }, + current_platform_index: snapshot.current_platform_index, + score: snapshot.score, + combo: snapshot.combo, + path: map_jump_hop_path(snapshot.path), + last_jump: snapshot.last_jump.map(|jump| JumpHopLastJump { + charge_ms: jump.charge_ms, + jump_distance: jump.jump_distance, + target_platform_index: jump.target_platform_index, + landed_x: jump.landed_x, + landed_y: jump.landed_y, + result: match jump.result { + crate::module_bindings::JumpHopJumpResultKind::Miss => JumpHopJumpResult::Miss, + crate::module_bindings::JumpHopJumpResultKind::Hit => JumpHopJumpResult::Hit, + crate::module_bindings::JumpHopJumpResultKind::Finish => JumpHopJumpResult::Finish, + crate::module_bindings::JumpHopJumpResultKind::Perfect => JumpHopJumpResult::Perfect, + }, + }), + started_at_ms: snapshot.started_at_ms, + finished_at_ms: snapshot.finished_at_ms, + } +} + +fn parse_difficulty(value: &str) -> JumpHopDifficulty { + match value { + "easy" => JumpHopDifficulty::Easy, + "advanced" => JumpHopDifficulty::Advanced, + "challenge" => JumpHopDifficulty::Challenge, + _ => JumpHopDifficulty::Standard, + } +} + +fn parse_domain_difficulty(value: crate::module_bindings::JumpHopDifficulty) -> JumpHopDifficulty { + match value { + crate::module_bindings::JumpHopDifficulty::Easy => JumpHopDifficulty::Easy, + crate::module_bindings::JumpHopDifficulty::Advanced => JumpHopDifficulty::Advanced, + crate::module_bindings::JumpHopDifficulty::Challenge => JumpHopDifficulty::Challenge, + crate::module_bindings::JumpHopDifficulty::Standard => JumpHopDifficulty::Standard, + } +} + +fn parse_style_preset(value: &str) -> JumpHopStylePreset { + match value { + "paper-toy" => JumpHopStylePreset::PaperToy, + "neon-glass" => JumpHopStylePreset::NeonGlass, + "forest-stone" => JumpHopStylePreset::ForestStone, + "future-metal" => JumpHopStylePreset::FutureMetal, + "custom" => JumpHopStylePreset::Custom, + _ => JumpHopStylePreset::MinimalBlocks, + } +} + +fn parse_tile_type(value: &str) -> JumpHopTileType { + match value { + "start" => JumpHopTileType::Start, + "target" => JumpHopTileType::Target, + "finish" => JumpHopTileType::Finish, + "bonus" => JumpHopTileType::Bonus, + "accent" => JumpHopTileType::Accent, + _ => JumpHopTileType::Normal, + } +} + +fn parse_domain_tile_type(value: crate::module_bindings::JumpHopTileType) -> JumpHopTileType { + match value { + crate::module_bindings::JumpHopTileType::Start => JumpHopTileType::Start, + crate::module_bindings::JumpHopTileType::Target => JumpHopTileType::Target, + crate::module_bindings::JumpHopTileType::Finish => JumpHopTileType::Finish, + crate::module_bindings::JumpHopTileType::Bonus => JumpHopTileType::Bonus, + crate::module_bindings::JumpHopTileType::Accent => JumpHopTileType::Accent, + crate::module_bindings::JumpHopTileType::Normal => JumpHopTileType::Normal, + } +} + +fn parse_generation_status(value: &str) -> JumpHopGenerationStatus { + match value { + "generating" => JumpHopGenerationStatus::Generating, + "ready" => JumpHopGenerationStatus::Ready, + "failed" => JumpHopGenerationStatus::Failed, + _ => JumpHopGenerationStatus::Draft, + } +} + +fn normalize_publication_status(value: &str) -> &str { + match value { + "Published" | "published" => "published", + _ => "draft", + } +} diff --git a/server-rs/crates/spacetime-client/src/mapper/runtime.rs b/server-rs/crates/spacetime-client/src/mapper/runtime.rs index 4b268707..4d74dcc4 100644 --- a/server-rs/crates/spacetime-client/src/mapper/runtime.rs +++ b/server-rs/crates/spacetime-client/src/mapper/runtime.rs @@ -213,6 +213,16 @@ pub(crate) fn map_runtime_tracking_event_procedure_result( 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> { diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 6a53dc72..5b6a593d 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.2.0 (commit eb11e2f5c41dce6979715ad407996270d61329f6). +// This was generated using spacetimedb cli version 2.1.0 (commit 6981f48b4bc1a71c8dd9bdfe5a2c343f6370243d). #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; @@ -199,6 +199,7 @@ pub mod click_match_3_d_item_procedure; pub mod combat_outcome_type; pub mod compile_big_fish_draft_procedure; pub mod compile_custom_world_published_profile_procedure; +pub mod compile_jump_hop_draft_procedure; pub mod compile_match_3_d_draft_procedure; pub mod compile_puzzle_agent_draft_procedure; pub mod compile_square_hole_draft_procedure; @@ -218,6 +219,7 @@ pub mod create_battle_state_and_return_procedure; pub mod create_battle_state_reducer; pub mod create_big_fish_session_procedure; pub mod create_custom_world_agent_session_procedure; +pub mod create_jump_hop_agent_session_procedure; pub mod create_match_3_d_agent_session_procedure; pub mod create_profile_recharge_order_and_return_procedure; pub mod create_puzzle_agent_session_procedure; @@ -349,6 +351,9 @@ pub mod get_custom_world_agent_session_procedure; pub mod get_custom_world_gallery_detail_by_code_procedure; pub mod get_custom_world_gallery_detail_procedure; pub mod get_custom_world_library_detail_procedure; +pub mod get_jump_hop_agent_session_procedure; +pub mod get_jump_hop_run_procedure; +pub mod get_jump_hop_work_profile_procedure; pub mod get_match_3_d_agent_session_procedure; pub mod get_match_3_d_run_procedure; pub mod get_match_3_d_work_detail_procedure; @@ -393,11 +398,55 @@ pub mod inventory_mutation_type; pub mod inventory_slot_snapshot_type; pub mod inventory_slot_table; pub mod inventory_slot_type; +pub mod jump_hop_agent_session_create_input_type; +pub mod jump_hop_agent_session_get_input_type; +pub mod jump_hop_agent_session_procedure_result_type; +pub mod jump_hop_agent_session_row_type; +pub mod jump_hop_agent_session_snapshot_type; +pub mod jump_hop_agent_session_table; +pub mod jump_hop_character_asset_snapshot_type; +pub mod jump_hop_creator_config_snapshot_type; +pub mod jump_hop_difficulty_type; +pub mod jump_hop_draft_compile_input_type; +pub mod jump_hop_draft_snapshot_type; +pub mod jump_hop_event_row_type; +pub mod jump_hop_event_table; +pub mod jump_hop_gallery_card_view_row_type; +pub mod jump_hop_gallery_card_view_table; +pub mod jump_hop_gallery_view_row_type; +pub mod jump_hop_gallery_view_table; +pub mod jump_hop_jump_procedure; +pub mod jump_hop_jump_result_kind_type; +pub mod jump_hop_last_jump_type; +pub mod jump_hop_path_type; +pub mod jump_hop_platform_type; +pub mod jump_hop_run_get_input_type; +pub mod jump_hop_run_jump_input_type; +pub mod jump_hop_run_procedure_result_type; +pub mod jump_hop_run_restart_input_type; +pub mod jump_hop_run_snapshot_type; +pub mod jump_hop_run_start_input_type; +pub mod jump_hop_run_status_type; +pub mod jump_hop_runtime_run_row_type; +pub mod jump_hop_runtime_run_table; +pub mod jump_hop_scoring_type; +pub mod jump_hop_tile_asset_snapshot_type; +pub mod jump_hop_tile_type_type; +pub mod jump_hop_work_get_input_type; +pub mod jump_hop_work_procedure_result_type; +pub mod jump_hop_work_profile_row_type; +pub mod jump_hop_work_profile_table; +pub mod jump_hop_work_publish_input_type; +pub mod jump_hop_work_snapshot_type; +pub mod jump_hop_work_update_input_type; +pub mod jump_hop_works_list_input_type; +pub mod jump_hop_works_procedure_result_type; pub mod list_asset_history_and_return_procedure; pub mod list_big_fish_works_procedure; pub mod list_custom_world_gallery_entries_procedure; pub mod list_custom_world_profiles_procedure; pub mod list_custom_world_works_procedure; +pub mod list_jump_hop_works_procedure; pub mod list_match_3_d_works_procedure; pub mod list_platform_browse_history_procedure; pub mod list_profile_save_archives_procedure; @@ -508,6 +557,7 @@ pub mod publish_big_fish_game_procedure; pub mod publish_custom_world_profile_and_return_procedure; pub mod publish_custom_world_profile_reducer; pub mod publish_custom_world_world_procedure; +pub mod publish_jump_hop_work_procedure; pub mod publish_match_3_d_work_procedure; pub mod publish_puzzle_work_procedure; pub mod publish_square_hole_work_procedure; @@ -649,6 +699,7 @@ pub mod resolve_npc_social_action_input_type; pub mod resolve_npc_social_action_reducer; pub mod resolve_treasure_interaction_and_return_procedure; pub mod resolve_treasure_interaction_reducer; +pub mod restart_jump_hop_run_procedure; pub mod restart_match_3_d_run_procedure; pub mod restart_square_hole_run_procedure; pub mod resume_profile_save_archive_and_return_procedure; @@ -820,6 +871,7 @@ pub mod start_ai_task_reducer; pub mod start_ai_task_stage_reducer; pub mod start_bark_battle_run_procedure; pub mod start_big_fish_run_procedure; +pub mod start_jump_hop_run_procedure; pub mod start_match_3_d_run_procedure; pub mod start_puzzle_run_procedure; pub mod start_square_hole_run_procedure; @@ -864,6 +916,7 @@ pub mod unequip_inventory_item_input_type; pub mod unpublish_custom_world_profile_and_return_procedure; pub mod unpublish_custom_world_profile_reducer; pub mod update_bark_battle_draft_config_procedure; +pub mod update_jump_hop_work_procedure; pub mod update_match_3_d_work_procedure; pub mod update_puzzle_run_pause_procedure; pub mod update_puzzle_work_procedure; @@ -1125,6 +1178,7 @@ pub use click_match_3_d_item_procedure::click_match_3_d_item; pub use combat_outcome_type::CombatOutcome; pub use compile_big_fish_draft_procedure::compile_big_fish_draft; pub use compile_custom_world_published_profile_procedure::compile_custom_world_published_profile; +pub use compile_jump_hop_draft_procedure::compile_jump_hop_draft; pub use compile_match_3_d_draft_procedure::compile_match_3_d_draft; pub use compile_puzzle_agent_draft_procedure::compile_puzzle_agent_draft; pub use compile_square_hole_draft_procedure::compile_square_hole_draft; @@ -1144,6 +1198,7 @@ pub use create_battle_state_and_return_procedure::create_battle_state_and_return pub use create_battle_state_reducer::create_battle_state; pub use create_big_fish_session_procedure::create_big_fish_session; pub use create_custom_world_agent_session_procedure::create_custom_world_agent_session; +pub use create_jump_hop_agent_session_procedure::create_jump_hop_agent_session; pub use create_match_3_d_agent_session_procedure::create_match_3_d_agent_session; pub use create_profile_recharge_order_and_return_procedure::create_profile_recharge_order_and_return; pub use create_puzzle_agent_session_procedure::create_puzzle_agent_session; @@ -1275,6 +1330,9 @@ pub use get_custom_world_agent_session_procedure::get_custom_world_agent_session pub use get_custom_world_gallery_detail_by_code_procedure::get_custom_world_gallery_detail_by_code; pub use get_custom_world_gallery_detail_procedure::get_custom_world_gallery_detail; pub use get_custom_world_library_detail_procedure::get_custom_world_library_detail; +pub use get_jump_hop_agent_session_procedure::get_jump_hop_agent_session; +pub use get_jump_hop_run_procedure::get_jump_hop_run; +pub use get_jump_hop_work_profile_procedure::get_jump_hop_work_profile; pub use get_match_3_d_agent_session_procedure::get_match_3_d_agent_session; pub use get_match_3_d_run_procedure::get_match_3_d_run; pub use get_match_3_d_work_detail_procedure::get_match_3_d_work_detail; @@ -1319,11 +1377,55 @@ pub use inventory_mutation_type::InventoryMutation; pub use inventory_slot_snapshot_type::InventorySlotSnapshot; pub use inventory_slot_table::*; pub use inventory_slot_type::InventorySlot; +pub use jump_hop_agent_session_create_input_type::JumpHopAgentSessionCreateInput; +pub use jump_hop_agent_session_get_input_type::JumpHopAgentSessionGetInput; +pub use jump_hop_agent_session_procedure_result_type::JumpHopAgentSessionProcedureResult; +pub use jump_hop_agent_session_row_type::JumpHopAgentSessionRow; +pub use jump_hop_agent_session_snapshot_type::JumpHopAgentSessionSnapshot; +pub use jump_hop_agent_session_table::*; +pub use jump_hop_character_asset_snapshot_type::JumpHopCharacterAssetSnapshot; +pub use jump_hop_creator_config_snapshot_type::JumpHopCreatorConfigSnapshot; +pub use jump_hop_difficulty_type::JumpHopDifficulty; +pub use jump_hop_draft_compile_input_type::JumpHopDraftCompileInput; +pub use jump_hop_draft_snapshot_type::JumpHopDraftSnapshot; +pub use jump_hop_event_row_type::JumpHopEventRow; +pub use jump_hop_event_table::*; +pub use jump_hop_gallery_card_view_row_type::JumpHopGalleryCardViewRow; +pub use jump_hop_gallery_card_view_table::*; +pub use jump_hop_gallery_view_row_type::JumpHopGalleryViewRow; +pub use jump_hop_gallery_view_table::*; +pub use jump_hop_jump_procedure::jump_hop_jump; +pub use jump_hop_jump_result_kind_type::JumpHopJumpResultKind; +pub use jump_hop_last_jump_type::JumpHopLastJump; +pub use jump_hop_path_type::JumpHopPath; +pub use jump_hop_platform_type::JumpHopPlatform; +pub use jump_hop_run_get_input_type::JumpHopRunGetInput; +pub use jump_hop_run_jump_input_type::JumpHopRunJumpInput; +pub use jump_hop_run_procedure_result_type::JumpHopRunProcedureResult; +pub use jump_hop_run_restart_input_type::JumpHopRunRestartInput; +pub use jump_hop_run_snapshot_type::JumpHopRunSnapshot; +pub use jump_hop_run_start_input_type::JumpHopRunStartInput; +pub use jump_hop_run_status_type::JumpHopRunStatus; +pub use jump_hop_runtime_run_row_type::JumpHopRuntimeRunRow; +pub use jump_hop_runtime_run_table::*; +pub use jump_hop_scoring_type::JumpHopScoring; +pub use jump_hop_tile_asset_snapshot_type::JumpHopTileAssetSnapshot; +pub use jump_hop_tile_type_type::JumpHopTileType; +pub use jump_hop_work_get_input_type::JumpHopWorkGetInput; +pub use jump_hop_work_procedure_result_type::JumpHopWorkProcedureResult; +pub use jump_hop_work_profile_row_type::JumpHopWorkProfileRow; +pub use jump_hop_work_profile_table::*; +pub use jump_hop_work_publish_input_type::JumpHopWorkPublishInput; +pub use jump_hop_work_snapshot_type::JumpHopWorkSnapshot; +pub use jump_hop_work_update_input_type::JumpHopWorkUpdateInput; +pub use jump_hop_works_list_input_type::JumpHopWorksListInput; +pub use jump_hop_works_procedure_result_type::JumpHopWorksProcedureResult; pub use list_asset_history_and_return_procedure::list_asset_history_and_return; pub use list_big_fish_works_procedure::list_big_fish_works; pub use list_custom_world_gallery_entries_procedure::list_custom_world_gallery_entries; pub use list_custom_world_profiles_procedure::list_custom_world_profiles; pub use list_custom_world_works_procedure::list_custom_world_works; +pub use list_jump_hop_works_procedure::list_jump_hop_works; pub use list_match_3_d_works_procedure::list_match_3_d_works; pub use list_platform_browse_history_procedure::list_platform_browse_history; pub use list_profile_save_archives_procedure::list_profile_save_archives; @@ -1434,6 +1536,7 @@ pub use publish_big_fish_game_procedure::publish_big_fish_game; pub use publish_custom_world_profile_and_return_procedure::publish_custom_world_profile_and_return; pub use publish_custom_world_profile_reducer::publish_custom_world_profile; pub use publish_custom_world_world_procedure::publish_custom_world_world; +pub use publish_jump_hop_work_procedure::publish_jump_hop_work; pub use publish_match_3_d_work_procedure::publish_match_3_d_work; pub use publish_puzzle_work_procedure::publish_puzzle_work; pub use publish_square_hole_work_procedure::publish_square_hole_work; @@ -1575,6 +1678,7 @@ pub use resolve_npc_social_action_input_type::ResolveNpcSocialActionInput; pub use resolve_npc_social_action_reducer::resolve_npc_social_action; pub use resolve_treasure_interaction_and_return_procedure::resolve_treasure_interaction_and_return; pub use resolve_treasure_interaction_reducer::resolve_treasure_interaction; +pub use restart_jump_hop_run_procedure::restart_jump_hop_run; pub use restart_match_3_d_run_procedure::restart_match_3_d_run; pub use restart_square_hole_run_procedure::restart_square_hole_run; pub use resume_profile_save_archive_and_return_procedure::resume_profile_save_archive_and_return; @@ -1746,6 +1850,7 @@ pub use start_ai_task_reducer::start_ai_task; pub use start_ai_task_stage_reducer::start_ai_task_stage; pub use start_bark_battle_run_procedure::start_bark_battle_run; pub use start_big_fish_run_procedure::start_big_fish_run; +pub use start_jump_hop_run_procedure::start_jump_hop_run; pub use start_match_3_d_run_procedure::start_match_3_d_run; pub use start_puzzle_run_procedure::start_puzzle_run; pub use start_square_hole_run_procedure::start_square_hole_run; @@ -1790,6 +1895,7 @@ pub use unequip_inventory_item_input_type::UnequipInventoryItemInput; pub use unpublish_custom_world_profile_and_return_procedure::unpublish_custom_world_profile_and_return; pub use unpublish_custom_world_profile_reducer::unpublish_custom_world_profile; pub use update_bark_battle_draft_config_procedure::update_bark_battle_draft_config; +pub use update_jump_hop_work_procedure::update_jump_hop_work; pub use update_match_3_d_work_procedure::update_match_3_d_work; pub use update_puzzle_run_pause_procedure::update_puzzle_run_pause; pub use update_puzzle_work_procedure::update_puzzle_work; @@ -2169,6 +2275,12 @@ pub struct DbUpdate { database_migration_import_chunk: __sdk::TableUpdate, database_migration_operator: __sdk::TableUpdate, inventory_slot: __sdk::TableUpdate, + jump_hop_agent_session: __sdk::TableUpdate, + jump_hop_event: __sdk::TableUpdate, + jump_hop_gallery_card_view: __sdk::TableUpdate, + jump_hop_gallery_view: __sdk::TableUpdate, + jump_hop_runtime_run: __sdk::TableUpdate, + jump_hop_work_profile: __sdk::TableUpdate, match_3_d_agent_message: __sdk::TableUpdate, match_3_d_agent_session: __sdk::TableUpdate, match_3_d_gallery_view: __sdk::TableUpdate, @@ -2358,6 +2470,24 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "inventory_slot" => db_update .inventory_slot .append(inventory_slot_table::parse_table_update(table_update)?), + "jump_hop_agent_session" => db_update.jump_hop_agent_session.append( + jump_hop_agent_session_table::parse_table_update(table_update)?, + ), + "jump_hop_event" => db_update + .jump_hop_event + .append(jump_hop_event_table::parse_table_update(table_update)?), + "jump_hop_gallery_card_view" => db_update.jump_hop_gallery_card_view.append( + jump_hop_gallery_card_view_table::parse_table_update(table_update)?, + ), + "jump_hop_gallery_view" => db_update.jump_hop_gallery_view.append( + jump_hop_gallery_view_table::parse_table_update(table_update)?, + ), + "jump_hop_runtime_run" => db_update.jump_hop_runtime_run.append( + jump_hop_runtime_run_table::parse_table_update(table_update)?, + ), + "jump_hop_work_profile" => db_update.jump_hop_work_profile.append( + jump_hop_work_profile_table::parse_table_update(table_update)?, + ), "match_3_d_agent_message" => db_update.match_3_d_agent_message.append( match_3_d_agent_message_table::parse_table_update(table_update)?, ), @@ -2748,6 +2878,27 @@ impl __sdk::DbUpdate for DbUpdate { diff.inventory_slot = cache .apply_diff_to_table::("inventory_slot", &self.inventory_slot) .with_updates_by_pk(|row| &row.slot_id); + diff.jump_hop_agent_session = cache + .apply_diff_to_table::( + "jump_hop_agent_session", + &self.jump_hop_agent_session, + ) + .with_updates_by_pk(|row| &row.session_id); + diff.jump_hop_event = cache + .apply_diff_to_table::("jump_hop_event", &self.jump_hop_event) + .with_updates_by_pk(|row| &row.event_id); + diff.jump_hop_runtime_run = cache + .apply_diff_to_table::( + "jump_hop_runtime_run", + &self.jump_hop_runtime_run, + ) + .with_updates_by_pk(|row| &row.run_id); + diff.jump_hop_work_profile = cache + .apply_diff_to_table::( + "jump_hop_work_profile", + &self.jump_hop_work_profile, + ) + .with_updates_by_pk(|row| &row.profile_id); diff.match_3_d_agent_message = cache .apply_diff_to_table::( "match_3_d_agent_message", @@ -3012,6 +3163,14 @@ impl __sdk::DbUpdate for DbUpdate { "big_fish_gallery_view", &self.big_fish_gallery_view, ); + diff.jump_hop_gallery_card_view = cache.apply_diff_to_table::( + "jump_hop_gallery_card_view", + &self.jump_hop_gallery_card_view, + ); + diff.jump_hop_gallery_view = cache.apply_diff_to_table::( + "jump_hop_gallery_view", + &self.jump_hop_gallery_view, + ); diff.match_3_d_gallery_view = cache.apply_diff_to_table::( "match_3_d_gallery_view", &self.match_3_d_gallery_view, @@ -3156,6 +3315,24 @@ impl __sdk::DbUpdate for DbUpdate { "inventory_slot" => db_update .inventory_slot .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "jump_hop_agent_session" => db_update + .jump_hop_agent_session + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "jump_hop_event" => db_update + .jump_hop_event + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "jump_hop_gallery_card_view" => db_update + .jump_hop_gallery_card_view + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "jump_hop_gallery_view" => db_update + .jump_hop_gallery_view + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "jump_hop_runtime_run" => db_update + .jump_hop_runtime_run + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "jump_hop_work_profile" => db_update + .jump_hop_work_profile + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "match_3_d_agent_message" => db_update .match_3_d_agent_message .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -3454,6 +3631,24 @@ impl __sdk::DbUpdate for DbUpdate { "inventory_slot" => db_update .inventory_slot .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "jump_hop_agent_session" => db_update + .jump_hop_agent_session + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "jump_hop_event" => db_update + .jump_hop_event + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "jump_hop_gallery_card_view" => db_update + .jump_hop_gallery_card_view + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "jump_hop_gallery_view" => db_update + .jump_hop_gallery_view + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "jump_hop_runtime_run" => db_update + .jump_hop_runtime_run + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "jump_hop_work_profile" => db_update + .jump_hop_work_profile + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "match_3_d_agent_message" => db_update .match_3_d_agent_message .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -3678,6 +3873,12 @@ pub struct AppliedDiff<'r> { database_migration_import_chunk: __sdk::TableAppliedDiff<'r, DatabaseMigrationImportChunk>, database_migration_operator: __sdk::TableAppliedDiff<'r, DatabaseMigrationOperator>, inventory_slot: __sdk::TableAppliedDiff<'r, InventorySlot>, + jump_hop_agent_session: __sdk::TableAppliedDiff<'r, JumpHopAgentSessionRow>, + jump_hop_event: __sdk::TableAppliedDiff<'r, JumpHopEventRow>, + jump_hop_gallery_card_view: __sdk::TableAppliedDiff<'r, JumpHopGalleryCardViewRow>, + jump_hop_gallery_view: __sdk::TableAppliedDiff<'r, JumpHopGalleryViewRow>, + jump_hop_runtime_run: __sdk::TableAppliedDiff<'r, JumpHopRuntimeRunRow>, + jump_hop_work_profile: __sdk::TableAppliedDiff<'r, JumpHopWorkProfileRow>, 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>, @@ -3935,6 +4136,36 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.inventory_slot, event, ); + callbacks.invoke_table_row_callbacks::( + "jump_hop_agent_session", + &self.jump_hop_agent_session, + event, + ); + callbacks.invoke_table_row_callbacks::( + "jump_hop_event", + &self.jump_hop_event, + event, + ); + callbacks.invoke_table_row_callbacks::( + "jump_hop_gallery_card_view", + &self.jump_hop_gallery_card_view, + event, + ); + callbacks.invoke_table_row_callbacks::( + "jump_hop_gallery_view", + &self.jump_hop_gallery_view, + event, + ); + callbacks.invoke_table_row_callbacks::( + "jump_hop_runtime_run", + &self.jump_hop_runtime_run, + event, + ); + callbacks.invoke_table_row_callbacks::( + "jump_hop_work_profile", + &self.jump_hop_work_profile, + event, + ); callbacks.invoke_table_row_callbacks::( "match_3_d_agent_message", &self.match_3_d_agent_message, @@ -4902,6 +5133,12 @@ impl __sdk::SpacetimeModule for RemoteModule { database_migration_import_chunk_table::register_table(client_cache); database_migration_operator_table::register_table(client_cache); inventory_slot_table::register_table(client_cache); + jump_hop_agent_session_table::register_table(client_cache); + jump_hop_event_table::register_table(client_cache); + jump_hop_gallery_card_view_table::register_table(client_cache); + jump_hop_gallery_view_table::register_table(client_cache); + jump_hop_runtime_run_table::register_table(client_cache); + jump_hop_work_profile_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); @@ -4999,6 +5236,12 @@ impl __sdk::SpacetimeModule for RemoteModule { "database_migration_import_chunk", "database_migration_operator", "inventory_slot", + "jump_hop_agent_session", + "jump_hop_event", + "jump_hop_gallery_card_view", + "jump_hop_gallery_view", + "jump_hop_runtime_run", + "jump_hop_work_profile", "match_3_d_agent_message", "match_3_d_agent_session", "match_3_d_gallery_view", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/compile_jump_hop_draft_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/compile_jump_hop_draft_procedure.rs new file mode 100644 index 00000000..f0479afa --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/compile_jump_hop_draft_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::jump_hop_agent_session_procedure_result_type::JumpHopAgentSessionProcedureResult; +use super::jump_hop_draft_compile_input_type::JumpHopDraftCompileInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CompileJumpHopDraftArgs { + pub input: JumpHopDraftCompileInput, +} + +impl __sdk::InModule for CompileJumpHopDraftArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `compile_jump_hop_draft`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait compile_jump_hop_draft { + fn compile_jump_hop_draft(&self, input: JumpHopDraftCompileInput) { + self.compile_jump_hop_draft_then(input, |_, _| {}); + } + + fn compile_jump_hop_draft_then( + &self, + input: JumpHopDraftCompileInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl compile_jump_hop_draft for super::RemoteProcedures { + fn compile_jump_hop_draft_then( + &self, + input: JumpHopDraftCompileInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, JumpHopAgentSessionProcedureResult>( + "compile_jump_hop_draft", + CompileJumpHopDraftArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/create_jump_hop_agent_session_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/create_jump_hop_agent_session_procedure.rs new file mode 100644 index 00000000..6bfce7c5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/create_jump_hop_agent_session_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::jump_hop_agent_session_create_input_type::JumpHopAgentSessionCreateInput; +use super::jump_hop_agent_session_procedure_result_type::JumpHopAgentSessionProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CreateJumpHopAgentSessionArgs { + pub input: JumpHopAgentSessionCreateInput, +} + +impl __sdk::InModule for CreateJumpHopAgentSessionArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `create_jump_hop_agent_session`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait create_jump_hop_agent_session { + fn create_jump_hop_agent_session(&self, input: JumpHopAgentSessionCreateInput) { + self.create_jump_hop_agent_session_then(input, |_, _| {}); + } + + fn create_jump_hop_agent_session_then( + &self, + input: JumpHopAgentSessionCreateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl create_jump_hop_agent_session for super::RemoteProcedures { + fn create_jump_hop_agent_session_then( + &self, + input: JumpHopAgentSessionCreateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, JumpHopAgentSessionProcedureResult>( + "create_jump_hop_agent_session", + CreateJumpHopAgentSessionArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_operation_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_operation_procedure.rs index cce1dbb5..b4d81a52 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_operation_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_custom_world_agent_operation_procedure.rs @@ -1,3 +1,4 @@ + // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_jump_hop_agent_session_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_jump_hop_agent_session_procedure.rs new file mode 100644 index 00000000..482aa1a5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_jump_hop_agent_session_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::jump_hop_agent_session_get_input_type::JumpHopAgentSessionGetInput; +use super::jump_hop_agent_session_procedure_result_type::JumpHopAgentSessionProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetJumpHopAgentSessionArgs { + pub input: JumpHopAgentSessionGetInput, +} + +impl __sdk::InModule for GetJumpHopAgentSessionArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_jump_hop_agent_session`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_jump_hop_agent_session { + fn get_jump_hop_agent_session(&self, input: JumpHopAgentSessionGetInput) { + self.get_jump_hop_agent_session_then(input, |_, _| {}); + } + + fn get_jump_hop_agent_session_then( + &self, + input: JumpHopAgentSessionGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_jump_hop_agent_session for super::RemoteProcedures { + fn get_jump_hop_agent_session_then( + &self, + input: JumpHopAgentSessionGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, JumpHopAgentSessionProcedureResult>( + "get_jump_hop_agent_session", + GetJumpHopAgentSessionArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_jump_hop_run_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_jump_hop_run_procedure.rs new file mode 100644 index 00000000..5c301da7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_jump_hop_run_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::jump_hop_run_get_input_type::JumpHopRunGetInput; +use super::jump_hop_run_procedure_result_type::JumpHopRunProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetJumpHopRunArgs { + pub input: JumpHopRunGetInput, +} + +impl __sdk::InModule for GetJumpHopRunArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_jump_hop_run`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_jump_hop_run { + fn get_jump_hop_run(&self, input: JumpHopRunGetInput) { + self.get_jump_hop_run_then(input, |_, _| {}); + } + + fn get_jump_hop_run_then( + &self, + input: JumpHopRunGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_jump_hop_run for super::RemoteProcedures { + fn get_jump_hop_run_then( + &self, + input: JumpHopRunGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, JumpHopRunProcedureResult>( + "get_jump_hop_run", + GetJumpHopRunArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_jump_hop_work_profile_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_jump_hop_work_profile_procedure.rs new file mode 100644 index 00000000..fd1fbd3e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_jump_hop_work_profile_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::jump_hop_work_get_input_type::JumpHopWorkGetInput; +use super::jump_hop_work_procedure_result_type::JumpHopWorkProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetJumpHopWorkProfileArgs { + pub input: JumpHopWorkGetInput, +} + +impl __sdk::InModule for GetJumpHopWorkProfileArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_jump_hop_work_profile`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_jump_hop_work_profile { + fn get_jump_hop_work_profile(&self, input: JumpHopWorkGetInput) { + self.get_jump_hop_work_profile_then(input, |_, _| {}); + } + + fn get_jump_hop_work_profile_then( + &self, + input: JumpHopWorkGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_jump_hop_work_profile for super::RemoteProcedures { + fn get_jump_hop_work_profile_then( + &self, + input: JumpHopWorkGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, JumpHopWorkProcedureResult>( + "get_jump_hop_work_profile", + GetJumpHopWorkProfileArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_agent_session_create_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_agent_session_create_input_type.rs new file mode 100644 index 00000000..14e2f410 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_agent_session_create_input_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 JumpHopAgentSessionCreateInput { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub work_title: String, + pub work_description: String, + pub theme_tags_json: Option, + pub welcome_message_text: String, + pub config_json: Option, + pub created_at_micros: i64, +} + +impl __sdk::InModule for JumpHopAgentSessionCreateInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_agent_session_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_agent_session_get_input_type.rs new file mode 100644 index 00000000..cf6b9b5d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_agent_session_get_input_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 JumpHopAgentSessionGetInput { + pub session_id: String, + pub owner_user_id: String, +} + +impl __sdk::InModule for JumpHopAgentSessionGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_agent_session_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_agent_session_procedure_result_type.rs new file mode 100644 index 00000000..1d833be0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_agent_session_procedure_result_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::jump_hop_agent_session_snapshot_type::JumpHopAgentSessionSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct JumpHopAgentSessionProcedureResult { + pub ok: bool, + pub session: Option, + pub error_message: Option, +} + +impl __sdk::InModule for JumpHopAgentSessionProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_agent_session_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_agent_session_row_type.rs new file mode 100644 index 00000000..9783325f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_agent_session_row_type.rs @@ -0,0 +1,90 @@ +// 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 JumpHopAgentSessionRow { + 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_json: String, + pub draft_json: String, + pub last_assistant_reply: String, + pub published_profile_id: String, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for JumpHopAgentSessionRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `JumpHopAgentSessionRow`. +/// +/// Provides typed access to columns for query building. +pub struct JumpHopAgentSessionRowCols { + pub session_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub seed_text: __sdk::__query_builder::Col, + pub current_turn: __sdk::__query_builder::Col, + pub progress_percent: __sdk::__query_builder::Col, + pub stage: __sdk::__query_builder::Col, + pub config_json: __sdk::__query_builder::Col, + pub draft_json: __sdk::__query_builder::Col, + pub last_assistant_reply: __sdk::__query_builder::Col, + pub published_profile_id: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for JumpHopAgentSessionRow { + type Cols = JumpHopAgentSessionRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + JumpHopAgentSessionRowCols { + session_id: __sdk::__query_builder::Col::new(table_name, "session_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + seed_text: __sdk::__query_builder::Col::new(table_name, "seed_text"), + current_turn: __sdk::__query_builder::Col::new(table_name, "current_turn"), + progress_percent: __sdk::__query_builder::Col::new(table_name, "progress_percent"), + stage: __sdk::__query_builder::Col::new(table_name, "stage"), + config_json: __sdk::__query_builder::Col::new(table_name, "config_json"), + draft_json: __sdk::__query_builder::Col::new(table_name, "draft_json"), + last_assistant_reply: __sdk::__query_builder::Col::new( + table_name, + "last_assistant_reply", + ), + published_profile_id: __sdk::__query_builder::Col::new( + table_name, + "published_profile_id", + ), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `JumpHopAgentSessionRow`. +/// +/// Provides typed access to indexed columns for query building. +pub struct JumpHopAgentSessionRowIxCols { + pub owner_user_id: __sdk::__query_builder::IxCol, + pub session_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for JumpHopAgentSessionRow { + type IxCols = JumpHopAgentSessionRowIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + JumpHopAgentSessionRowIxCols { + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + session_id: __sdk::__query_builder::IxCol::new(table_name, "session_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for JumpHopAgentSessionRow {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_agent_session_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_agent_session_snapshot_type.rs new file mode 100644 index 00000000..aaaacacc --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_agent_session_snapshot_type.rs @@ -0,0 +1,29 @@ +// 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::jump_hop_creator_config_snapshot_type::JumpHopCreatorConfigSnapshot; +use super::jump_hop_draft_snapshot_type::JumpHopDraftSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct JumpHopAgentSessionSnapshot { + 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: JumpHopCreatorConfigSnapshot, + pub draft: Option, + pub last_assistant_reply: String, + pub published_profile_id: Option, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for JumpHopAgentSessionSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_agent_session_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_agent_session_table.rs new file mode 100644 index 00000000..7e77ef24 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_agent_session_table.rs @@ -0,0 +1,161 @@ +// 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::jump_hop_agent_session_row_type::JumpHopAgentSessionRow; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `jump_hop_agent_session`. +/// +/// Obtain a handle from the [`JumpHopAgentSessionTableAccess::jump_hop_agent_session`] method on [`super::RemoteTables`], +/// like `ctx.db.jump_hop_agent_session()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.jump_hop_agent_session().on_insert(...)`. +pub struct JumpHopAgentSessionTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `jump_hop_agent_session`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait JumpHopAgentSessionTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`JumpHopAgentSessionTableHandle`], which mediates access to the table `jump_hop_agent_session`. + fn jump_hop_agent_session(&self) -> JumpHopAgentSessionTableHandle<'_>; +} + +impl JumpHopAgentSessionTableAccess for super::RemoteTables { + fn jump_hop_agent_session(&self) -> JumpHopAgentSessionTableHandle<'_> { + JumpHopAgentSessionTableHandle { + imp: self + .imp + .get_table::("jump_hop_agent_session"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct JumpHopAgentSessionInsertCallbackId(__sdk::CallbackId); +pub struct JumpHopAgentSessionDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for JumpHopAgentSessionTableHandle<'ctx> { + type Row = JumpHopAgentSessionRow; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = JumpHopAgentSessionInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> JumpHopAgentSessionInsertCallbackId { + JumpHopAgentSessionInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: JumpHopAgentSessionInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = JumpHopAgentSessionDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> JumpHopAgentSessionDeleteCallbackId { + JumpHopAgentSessionDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: JumpHopAgentSessionDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct JumpHopAgentSessionUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for JumpHopAgentSessionTableHandle<'ctx> { + type UpdateCallbackId = JumpHopAgentSessionUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> JumpHopAgentSessionUpdateCallbackId { + JumpHopAgentSessionUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: JumpHopAgentSessionUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `session_id` unique index on the table `jump_hop_agent_session`, +/// which allows point queries on the field of the same name +/// via the [`JumpHopAgentSessionSessionIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.jump_hop_agent_session().session_id().find(...)`. +pub struct JumpHopAgentSessionSessionIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> JumpHopAgentSessionTableHandle<'ctx> { + /// Get a handle on the `session_id` unique index on the table `jump_hop_agent_session`. + pub fn session_id(&self) -> JumpHopAgentSessionSessionIdUnique<'ctx> { + JumpHopAgentSessionSessionIdUnique { + imp: self.imp.get_unique_constraint::("session_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> JumpHopAgentSessionSessionIdUnique<'ctx> { + /// Find the subscribed row whose `session_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("jump_hop_agent_session"); + _table.add_unique_constraint::("session_id", |row| &row.session_id); +} + +#[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 `JumpHopAgentSessionRow`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait jump_hop_agent_sessionQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `JumpHopAgentSessionRow`. + fn jump_hop_agent_session(&self) -> __sdk::__query_builder::Table; +} + +impl jump_hop_agent_sessionQueryTableAccess for __sdk::QueryTableAccessor { + fn jump_hop_agent_session(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("jump_hop_agent_session") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_character_asset_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_character_asset_snapshot_type.rs new file mode 100644 index 00000000..e562198f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_character_asset_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 JumpHopCharacterAssetSnapshot { + pub asset_id: String, + pub image_src: String, + pub image_object_key: String, + pub asset_object_id: String, + pub generation_provider: String, + pub prompt: String, + pub width: u32, + pub height: u32, +} + +impl __sdk::InModule for JumpHopCharacterAssetSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_creator_config_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_creator_config_snapshot_type.rs new file mode 100644 index 00000000..6d0f3738 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_creator_config_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 JumpHopCreatorConfigSnapshot { + pub theme_text: String, + pub difficulty: String, + pub style_preset: String, + pub character_prompt: String, + pub tile_prompt: String, + pub end_mood_prompt: String, +} + +impl __sdk::InModule for JumpHopCreatorConfigSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_difficulty_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_difficulty_type.rs new file mode 100644 index 00000000..4e89783c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_difficulty_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 JumpHopDifficulty { + Easy, + + Standard, + + Advanced, + + Challenge, +} + +impl __sdk::InModule for JumpHopDifficulty { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_draft_compile_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_draft_compile_input_type.rs new file mode 100644 index 00000000..d8f3e7f5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_draft_compile_input_type.rs @@ -0,0 +1,34 @@ +// 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 JumpHopDraftCompileInput { + pub session_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub author_display_name: String, + pub seed_text: String, + pub work_title: String, + pub work_description: String, + pub theme_tags_json: Option, + pub theme_text: Option, + pub difficulty: Option, + pub style_preset: Option, + pub character_prompt: Option, + pub tile_prompt: Option, + pub end_mood_prompt: Option, + pub character_asset_json: Option, + pub tile_atlas_asset_json: Option, + pub tile_assets_json: Option, + pub cover_composite: Option, + pub generation_status: Option, + pub compiled_at_micros: i64, +} + +impl __sdk::InModule for JumpHopDraftCompileInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_draft_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_draft_snapshot_type.rs new file mode 100644 index 00000000..09e12197 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_draft_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::jump_hop_character_asset_snapshot_type::JumpHopCharacterAssetSnapshot; +use super::jump_hop_path_type::JumpHopPath; +use super::jump_hop_tile_asset_snapshot_type::JumpHopTileAssetSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct JumpHopDraftSnapshot { + pub template_id: String, + pub template_name: String, + pub profile_id: Option, + pub work_title: String, + pub work_description: String, + pub theme_tags: Vec, + pub difficulty: String, + pub style_preset: String, + pub character_prompt: String, + pub tile_prompt: String, + pub end_mood_prompt: Option, + pub character_asset: Option, + pub tile_atlas_asset: Option, + pub tile_assets: Vec, + pub path: Option, + pub cover_composite: Option, + pub generation_status: String, +} + +impl __sdk::InModule for JumpHopDraftSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_event_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_event_row_type.rs new file mode 100644 index 00000000..6e0fe7e2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_event_row_type.rs @@ -0,0 +1,71 @@ +// 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 JumpHopEventRow { + pub event_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub run_id: String, + pub event_type: String, + pub result: String, + pub occurred_at: __sdk::Timestamp, +} + +impl __sdk::InModule for JumpHopEventRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `JumpHopEventRow`. +/// +/// Provides typed access to columns for query building. +pub struct JumpHopEventRowCols { + pub event_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub profile_id: __sdk::__query_builder::Col, + pub run_id: __sdk::__query_builder::Col, + pub event_type: __sdk::__query_builder::Col, + pub result: __sdk::__query_builder::Col, + pub occurred_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for JumpHopEventRow { + type Cols = JumpHopEventRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + JumpHopEventRowCols { + event_id: __sdk::__query_builder::Col::new(table_name, "event_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + run_id: __sdk::__query_builder::Col::new(table_name, "run_id"), + event_type: __sdk::__query_builder::Col::new(table_name, "event_type"), + result: __sdk::__query_builder::Col::new(table_name, "result"), + occurred_at: __sdk::__query_builder::Col::new(table_name, "occurred_at"), + } + } +} + +/// Indexed column accessor struct for the table `JumpHopEventRow`. +/// +/// Provides typed access to indexed columns for query building. +pub struct JumpHopEventRowIxCols { + pub event_id: __sdk::__query_builder::IxCol, + pub profile_id: __sdk::__query_builder::IxCol, + pub run_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for JumpHopEventRow { + type IxCols = JumpHopEventRowIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + JumpHopEventRowIxCols { + event_id: __sdk::__query_builder::IxCol::new(table_name, "event_id"), + profile_id: __sdk::__query_builder::IxCol::new(table_name, "profile_id"), + run_id: __sdk::__query_builder::IxCol::new(table_name, "run_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for JumpHopEventRow {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_event_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_event_table.rs new file mode 100644 index 00000000..8070f7a8 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_event_table.rs @@ -0,0 +1,159 @@ +// 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::jump_hop_event_row_type::JumpHopEventRow; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `jump_hop_event`. +/// +/// Obtain a handle from the [`JumpHopEventTableAccess::jump_hop_event`] method on [`super::RemoteTables`], +/// like `ctx.db.jump_hop_event()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.jump_hop_event().on_insert(...)`. +pub struct JumpHopEventTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `jump_hop_event`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait JumpHopEventTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`JumpHopEventTableHandle`], which mediates access to the table `jump_hop_event`. + fn jump_hop_event(&self) -> JumpHopEventTableHandle<'_>; +} + +impl JumpHopEventTableAccess for super::RemoteTables { + fn jump_hop_event(&self) -> JumpHopEventTableHandle<'_> { + JumpHopEventTableHandle { + imp: self.imp.get_table::("jump_hop_event"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct JumpHopEventInsertCallbackId(__sdk::CallbackId); +pub struct JumpHopEventDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for JumpHopEventTableHandle<'ctx> { + type Row = JumpHopEventRow; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = JumpHopEventInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> JumpHopEventInsertCallbackId { + JumpHopEventInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: JumpHopEventInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = JumpHopEventDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> JumpHopEventDeleteCallbackId { + JumpHopEventDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: JumpHopEventDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct JumpHopEventUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for JumpHopEventTableHandle<'ctx> { + type UpdateCallbackId = JumpHopEventUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> JumpHopEventUpdateCallbackId { + JumpHopEventUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: JumpHopEventUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `event_id` unique index on the table `jump_hop_event`, +/// which allows point queries on the field of the same name +/// via the [`JumpHopEventEventIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.jump_hop_event().event_id().find(...)`. +pub struct JumpHopEventEventIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> JumpHopEventTableHandle<'ctx> { + /// Get a handle on the `event_id` unique index on the table `jump_hop_event`. + pub fn event_id(&self) -> JumpHopEventEventIdUnique<'ctx> { + JumpHopEventEventIdUnique { + imp: self.imp.get_unique_constraint::("event_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> JumpHopEventEventIdUnique<'ctx> { + /// Find the subscribed row whose `event_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("jump_hop_event"); + _table.add_unique_constraint::("event_id", |row| &row.event_id); +} + +#[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 `JumpHopEventRow`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait jump_hop_eventQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `JumpHopEventRow`. + fn jump_hop_event(&self) -> __sdk::__query_builder::Table; +} + +impl jump_hop_eventQueryTableAccess for __sdk::QueryTableAccessor { + fn jump_hop_event(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("jump_hop_event") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_gallery_card_view_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_gallery_card_view_row_type.rs new file mode 100644 index 00000000..25622a80 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_gallery_card_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 JumpHopGalleryCardViewRow { + pub public_work_code: String, + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub author_display_name: String, + pub work_title: String, + pub work_description: String, + pub theme_tags: Vec, + pub difficulty: String, + pub style_preset: String, + pub cover_image_src: String, + pub publication_status: String, + pub play_count: u32, + pub updated_at_micros: i64, + pub published_at_micros: Option, + pub generation_status: String, +} + +impl __sdk::InModule for JumpHopGalleryCardViewRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `JumpHopGalleryCardViewRow`. +/// +/// Provides typed access to columns for query building. +pub struct JumpHopGalleryCardViewRowCols { + pub public_work_code: __sdk::__query_builder::Col, + pub work_id: __sdk::__query_builder::Col, + pub profile_id: __sdk::__query_builder::Col, + pub owner_user_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 theme_tags: __sdk::__query_builder::Col>, + pub difficulty: __sdk::__query_builder::Col, + pub style_preset: __sdk::__query_builder::Col, + pub cover_image_src: __sdk::__query_builder::Col, + pub publication_status: __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 generation_status: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for JumpHopGalleryCardViewRow { + type Cols = JumpHopGalleryCardViewRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + JumpHopGalleryCardViewRowCols { + public_work_code: __sdk::__query_builder::Col::new(table_name, "public_work_code"), + 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"), + 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"), + theme_tags: __sdk::__query_builder::Col::new(table_name, "theme_tags"), + difficulty: __sdk::__query_builder::Col::new(table_name, "difficulty"), + style_preset: __sdk::__query_builder::Col::new(table_name, "style_preset"), + cover_image_src: __sdk::__query_builder::Col::new(table_name, "cover_image_src"), + publication_status: __sdk::__query_builder::Col::new(table_name, "publication_status"), + 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", + ), + generation_status: __sdk::__query_builder::Col::new(table_name, "generation_status"), + } + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_gallery_card_view_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_gallery_card_view_table.rs new file mode 100644 index 00000000..719ad477 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_gallery_card_view_table.rs @@ -0,0 +1,118 @@ +// 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::jump_hop_gallery_card_view_row_type::JumpHopGalleryCardViewRow; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `jump_hop_gallery_card_view`. +/// +/// Obtain a handle from the [`JumpHopGalleryCardViewTableAccess::jump_hop_gallery_card_view`] method on [`super::RemoteTables`], +/// like `ctx.db.jump_hop_gallery_card_view()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.jump_hop_gallery_card_view().on_insert(...)`. +pub struct JumpHopGalleryCardViewTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `jump_hop_gallery_card_view`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait JumpHopGalleryCardViewTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`JumpHopGalleryCardViewTableHandle`], which mediates access to the table `jump_hop_gallery_card_view`. + fn jump_hop_gallery_card_view(&self) -> JumpHopGalleryCardViewTableHandle<'_>; +} + +impl JumpHopGalleryCardViewTableAccess for super::RemoteTables { + fn jump_hop_gallery_card_view(&self) -> JumpHopGalleryCardViewTableHandle<'_> { + JumpHopGalleryCardViewTableHandle { + imp: self + .imp + .get_table::("jump_hop_gallery_card_view"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct JumpHopGalleryCardViewInsertCallbackId(__sdk::CallbackId); +pub struct JumpHopGalleryCardViewDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for JumpHopGalleryCardViewTableHandle<'ctx> { + type Row = JumpHopGalleryCardViewRow; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = JumpHopGalleryCardViewInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> JumpHopGalleryCardViewInsertCallbackId { + JumpHopGalleryCardViewInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: JumpHopGalleryCardViewInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = JumpHopGalleryCardViewDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> JumpHopGalleryCardViewDeleteCallbackId { + JumpHopGalleryCardViewDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: JumpHopGalleryCardViewDeleteCallbackId) { + 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::("jump_hop_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 `JumpHopGalleryCardViewRow`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait jump_hop_gallery_card_viewQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `JumpHopGalleryCardViewRow`. + fn jump_hop_gallery_card_view( + &self, + ) -> __sdk::__query_builder::Table; +} + +impl jump_hop_gallery_card_viewQueryTableAccess for __sdk::QueryTableAccessor { + fn jump_hop_gallery_card_view( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("jump_hop_gallery_card_view") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_gallery_view_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_gallery_view_row_type.rs new file mode 100644 index 00000000..cdf7e954 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_gallery_view_row_type.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 spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::jump_hop_character_asset_snapshot_type::JumpHopCharacterAssetSnapshot; +use super::jump_hop_path_type::JumpHopPath; +use super::jump_hop_tile_asset_snapshot_type::JumpHopTileAssetSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct JumpHopGalleryViewRow { + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: String, + pub author_display_name: String, + pub work_title: String, + pub work_description: String, + pub theme_tags: Vec, + pub difficulty: String, + pub style_preset: String, + pub character_prompt: String, + pub tile_prompt: String, + pub end_mood_prompt: Option, + pub character_asset: Option, + pub tile_atlas_asset: Option, + pub tile_assets: Vec, + pub path: JumpHopPath, + pub cover_image_src: String, + pub cover_composite: Option, + pub publication_status: String, + pub publish_ready: bool, + pub play_count: u32, + pub generation_status: String, + pub updated_at_micros: i64, + pub published_at_micros: Option, +} + +impl __sdk::InModule for JumpHopGalleryViewRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `JumpHopGalleryViewRow`. +/// +/// Provides typed access to columns for query building. +pub struct JumpHopGalleryViewRowCols { + 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 theme_tags: __sdk::__query_builder::Col>, + pub difficulty: __sdk::__query_builder::Col, + pub style_preset: __sdk::__query_builder::Col, + pub character_prompt: __sdk::__query_builder::Col, + pub tile_prompt: __sdk::__query_builder::Col, + pub end_mood_prompt: __sdk::__query_builder::Col>, + pub character_asset: + __sdk::__query_builder::Col>, + pub tile_atlas_asset: + __sdk::__query_builder::Col>, + pub tile_assets: + __sdk::__query_builder::Col>, + pub path: __sdk::__query_builder::Col, + pub cover_image_src: __sdk::__query_builder::Col, + pub cover_composite: __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 generation_status: __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 JumpHopGalleryViewRow { + type Cols = JumpHopGalleryViewRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + JumpHopGalleryViewRowCols { + 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"), + theme_tags: __sdk::__query_builder::Col::new(table_name, "theme_tags"), + difficulty: __sdk::__query_builder::Col::new(table_name, "difficulty"), + style_preset: __sdk::__query_builder::Col::new(table_name, "style_preset"), + character_prompt: __sdk::__query_builder::Col::new(table_name, "character_prompt"), + tile_prompt: __sdk::__query_builder::Col::new(table_name, "tile_prompt"), + end_mood_prompt: __sdk::__query_builder::Col::new(table_name, "end_mood_prompt"), + character_asset: __sdk::__query_builder::Col::new(table_name, "character_asset"), + tile_atlas_asset: __sdk::__query_builder::Col::new(table_name, "tile_atlas_asset"), + tile_assets: __sdk::__query_builder::Col::new(table_name, "tile_assets"), + path: __sdk::__query_builder::Col::new(table_name, "path"), + cover_image_src: __sdk::__query_builder::Col::new(table_name, "cover_image_src"), + cover_composite: __sdk::__query_builder::Col::new(table_name, "cover_composite"), + 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"), + generation_status: __sdk::__query_builder::Col::new(table_name, "generation_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", + ), + } + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_gallery_view_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_gallery_view_table.rs new file mode 100644 index 00000000..c55683d1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_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::jump_hop_character_asset_snapshot_type::JumpHopCharacterAssetSnapshot; +use super::jump_hop_gallery_view_row_type::JumpHopGalleryViewRow; +use super::jump_hop_path_type::JumpHopPath; +use super::jump_hop_tile_asset_snapshot_type::JumpHopTileAssetSnapshot; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `jump_hop_gallery_view`. +/// +/// Obtain a handle from the [`JumpHopGalleryViewTableAccess::jump_hop_gallery_view`] method on [`super::RemoteTables`], +/// like `ctx.db.jump_hop_gallery_view()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.jump_hop_gallery_view().on_insert(...)`. +pub struct JumpHopGalleryViewTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `jump_hop_gallery_view`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait JumpHopGalleryViewTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`JumpHopGalleryViewTableHandle`], which mediates access to the table `jump_hop_gallery_view`. + fn jump_hop_gallery_view(&self) -> JumpHopGalleryViewTableHandle<'_>; +} + +impl JumpHopGalleryViewTableAccess for super::RemoteTables { + fn jump_hop_gallery_view(&self) -> JumpHopGalleryViewTableHandle<'_> { + JumpHopGalleryViewTableHandle { + imp: self + .imp + .get_table::("jump_hop_gallery_view"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct JumpHopGalleryViewInsertCallbackId(__sdk::CallbackId); +pub struct JumpHopGalleryViewDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for JumpHopGalleryViewTableHandle<'ctx> { + type Row = JumpHopGalleryViewRow; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = JumpHopGalleryViewInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> JumpHopGalleryViewInsertCallbackId { + JumpHopGalleryViewInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: JumpHopGalleryViewInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = JumpHopGalleryViewDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> JumpHopGalleryViewDeleteCallbackId { + JumpHopGalleryViewDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: JumpHopGalleryViewDeleteCallbackId) { + 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::("jump_hop_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 `JumpHopGalleryViewRow`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait jump_hop_gallery_viewQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `JumpHopGalleryViewRow`. + fn jump_hop_gallery_view(&self) -> __sdk::__query_builder::Table; +} + +impl jump_hop_gallery_viewQueryTableAccess for __sdk::QueryTableAccessor { + fn jump_hop_gallery_view(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("jump_hop_gallery_view") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_jump_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_jump_procedure.rs new file mode 100644 index 00000000..1535f96f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_jump_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::jump_hop_run_jump_input_type::JumpHopRunJumpInput; +use super::jump_hop_run_procedure_result_type::JumpHopRunProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct JumpHopJumpArgs { + pub input: JumpHopRunJumpInput, +} + +impl __sdk::InModule for JumpHopJumpArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `jump_hop_jump`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait jump_hop_jump { + fn jump_hop_jump(&self, input: JumpHopRunJumpInput) { + self.jump_hop_jump_then(input, |_, _| {}); + } + + fn jump_hop_jump_then( + &self, + input: JumpHopRunJumpInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl jump_hop_jump for super::RemoteProcedures { + fn jump_hop_jump_then( + &self, + input: JumpHopRunJumpInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, JumpHopRunProcedureResult>( + "jump_hop_jump", + JumpHopJumpArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_jump_result_kind_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_jump_result_kind_type.rs new file mode 100644 index 00000000..6db87bac --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_jump_result_kind_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 JumpHopJumpResultKind { + Miss, + + Hit, + + Perfect, + + Finish, +} + +impl __sdk::InModule for JumpHopJumpResultKind { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_last_jump_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_last_jump_type.rs new file mode 100644 index 00000000..5d8ef5bd --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_last_jump_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::jump_hop_jump_result_kind_type::JumpHopJumpResultKind; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct JumpHopLastJump { + pub charge_ms: u32, + pub jump_distance: f32, + pub target_platform_index: u32, + pub landed_x: f32, + pub landed_y: f32, + pub result: JumpHopJumpResultKind, +} + +impl __sdk::InModule for JumpHopLastJump { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_path_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_path_type.rs new file mode 100644 index 00000000..b0ebd1f7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_path_type.rs @@ -0,0 +1,24 @@ +// 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::jump_hop_difficulty_type::JumpHopDifficulty; +use super::jump_hop_platform_type::JumpHopPlatform; +use super::jump_hop_scoring_type::JumpHopScoring; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct JumpHopPath { + pub seed: String, + pub difficulty: JumpHopDifficulty, + pub platforms: Vec, + pub finish_index: u32, + pub camera_preset: String, + pub scoring: JumpHopScoring, +} + +impl __sdk::InModule for JumpHopPath { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_platform_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_platform_type.rs new file mode 100644 index 00000000..1aa60925 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_platform_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::jump_hop_tile_type_type::JumpHopTileType; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct JumpHopPlatform { + pub platform_id: String, + pub tile_type: JumpHopTileType, + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, + pub landing_radius: f32, + pub perfect_radius: f32, + pub score_value: u32, +} + +impl __sdk::InModule for JumpHopPlatform { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_get_input_type.rs new file mode 100644 index 00000000..7600f622 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_get_input_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 JumpHopRunGetInput { + pub run_id: String, + pub owner_user_id: String, +} + +impl __sdk::InModule for JumpHopRunGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_jump_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_jump_input_type.rs new file mode 100644 index 00000000..e73b5530 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_jump_input_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 JumpHopRunJumpInput { + pub run_id: String, + pub owner_user_id: String, + pub charge_ms: u32, + pub client_event_id: String, + pub jumped_at_ms: i64, +} + +impl __sdk::InModule for JumpHopRunJumpInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_procedure_result_type.rs new file mode 100644 index 00000000..c963bbbf --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_procedure_result_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::jump_hop_run_snapshot_type::JumpHopRunSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct JumpHopRunProcedureResult { + pub ok: bool, + pub run: Option, + pub error_message: Option, +} + +impl __sdk::InModule for JumpHopRunProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_restart_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_restart_input_type.rs new file mode 100644 index 00000000..dde3900f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_restart_input_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 JumpHopRunRestartInput { + pub source_run_id: String, + pub next_run_id: String, + pub owner_user_id: String, + pub client_action_id: String, + pub restarted_at_ms: i64, +} + +impl __sdk::InModule for JumpHopRunRestartInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_snapshot_type.rs new file mode 100644 index 00000000..e1402458 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_snapshot_type.rs @@ -0,0 +1,29 @@ +// 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::jump_hop_last_jump_type::JumpHopLastJump; +use super::jump_hop_path_type::JumpHopPath; +use super::jump_hop_run_status_type::JumpHopRunStatus; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct JumpHopRunSnapshot { + pub run_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub status: JumpHopRunStatus, + pub current_platform_index: u32, + pub score: u32, + pub combo: u32, + pub last_jump: Option, + pub started_at_ms: u64, + pub finished_at_ms: Option, + pub path: JumpHopPath, +} + +impl __sdk::InModule for JumpHopRunSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_start_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_start_input_type.rs new file mode 100644 index 00000000..40578dae --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_start_input_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 JumpHopRunStartInput { + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub client_event_id: String, + pub started_at_ms: i64, +} + +impl __sdk::InModule for JumpHopRunStartInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_status_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_status_type.rs new file mode 100644 index 00000000..46993265 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_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 JumpHopRunStatus { + Playing, + + Failed, + + Cleared, +} + +impl __sdk::InModule for JumpHopRunStatus { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_runtime_run_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_runtime_run_row_type.rs new file mode 100644 index 00000000..64c5205f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_runtime_run_row_type.rs @@ -0,0 +1,89 @@ +// 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 JumpHopRuntimeRunRow { + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub status: String, + pub started_at_ms: i64, + pub finished_at_ms: i64, + pub current_platform_index: u32, + pub score: u32, + pub combo: u32, + pub snapshot_json: String, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for JumpHopRuntimeRunRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `JumpHopRuntimeRunRow`. +/// +/// Provides typed access to columns for query building. +pub struct JumpHopRuntimeRunRowCols { + pub run_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub profile_id: __sdk::__query_builder::Col, + pub status: __sdk::__query_builder::Col, + pub started_at_ms: __sdk::__query_builder::Col, + pub finished_at_ms: __sdk::__query_builder::Col, + pub current_platform_index: __sdk::__query_builder::Col, + pub score: __sdk::__query_builder::Col, + pub combo: __sdk::__query_builder::Col, + pub snapshot_json: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for JumpHopRuntimeRunRow { + type Cols = JumpHopRuntimeRunRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + JumpHopRuntimeRunRowCols { + run_id: __sdk::__query_builder::Col::new(table_name, "run_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + status: __sdk::__query_builder::Col::new(table_name, "status"), + started_at_ms: __sdk::__query_builder::Col::new(table_name, "started_at_ms"), + finished_at_ms: __sdk::__query_builder::Col::new(table_name, "finished_at_ms"), + current_platform_index: __sdk::__query_builder::Col::new( + table_name, + "current_platform_index", + ), + score: __sdk::__query_builder::Col::new(table_name, "score"), + combo: __sdk::__query_builder::Col::new(table_name, "combo"), + snapshot_json: __sdk::__query_builder::Col::new(table_name, "snapshot_json"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `JumpHopRuntimeRunRow`. +/// +/// Provides typed access to indexed columns for query building. +pub struct JumpHopRuntimeRunRowIxCols { + pub owner_user_id: __sdk::__query_builder::IxCol, + pub profile_id: __sdk::__query_builder::IxCol, + pub run_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for JumpHopRuntimeRunRow { + type IxCols = JumpHopRuntimeRunRowIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + JumpHopRuntimeRunRowIxCols { + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + profile_id: __sdk::__query_builder::IxCol::new(table_name, "profile_id"), + run_id: __sdk::__query_builder::IxCol::new(table_name, "run_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for JumpHopRuntimeRunRow {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_runtime_run_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_runtime_run_table.rs new file mode 100644 index 00000000..1fd4bab9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_runtime_run_table.rs @@ -0,0 +1,161 @@ +// 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::jump_hop_runtime_run_row_type::JumpHopRuntimeRunRow; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `jump_hop_runtime_run`. +/// +/// Obtain a handle from the [`JumpHopRuntimeRunTableAccess::jump_hop_runtime_run`] method on [`super::RemoteTables`], +/// like `ctx.db.jump_hop_runtime_run()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.jump_hop_runtime_run().on_insert(...)`. +pub struct JumpHopRuntimeRunTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `jump_hop_runtime_run`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait JumpHopRuntimeRunTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`JumpHopRuntimeRunTableHandle`], which mediates access to the table `jump_hop_runtime_run`. + fn jump_hop_runtime_run(&self) -> JumpHopRuntimeRunTableHandle<'_>; +} + +impl JumpHopRuntimeRunTableAccess for super::RemoteTables { + fn jump_hop_runtime_run(&self) -> JumpHopRuntimeRunTableHandle<'_> { + JumpHopRuntimeRunTableHandle { + imp: self + .imp + .get_table::("jump_hop_runtime_run"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct JumpHopRuntimeRunInsertCallbackId(__sdk::CallbackId); +pub struct JumpHopRuntimeRunDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for JumpHopRuntimeRunTableHandle<'ctx> { + type Row = JumpHopRuntimeRunRow; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = JumpHopRuntimeRunInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> JumpHopRuntimeRunInsertCallbackId { + JumpHopRuntimeRunInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: JumpHopRuntimeRunInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = JumpHopRuntimeRunDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> JumpHopRuntimeRunDeleteCallbackId { + JumpHopRuntimeRunDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: JumpHopRuntimeRunDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct JumpHopRuntimeRunUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for JumpHopRuntimeRunTableHandle<'ctx> { + type UpdateCallbackId = JumpHopRuntimeRunUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> JumpHopRuntimeRunUpdateCallbackId { + JumpHopRuntimeRunUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: JumpHopRuntimeRunUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `run_id` unique index on the table `jump_hop_runtime_run`, +/// which allows point queries on the field of the same name +/// via the [`JumpHopRuntimeRunRunIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.jump_hop_runtime_run().run_id().find(...)`. +pub struct JumpHopRuntimeRunRunIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> JumpHopRuntimeRunTableHandle<'ctx> { + /// Get a handle on the `run_id` unique index on the table `jump_hop_runtime_run`. + pub fn run_id(&self) -> JumpHopRuntimeRunRunIdUnique<'ctx> { + JumpHopRuntimeRunRunIdUnique { + imp: self.imp.get_unique_constraint::("run_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> JumpHopRuntimeRunRunIdUnique<'ctx> { + /// Find the subscribed row whose `run_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("jump_hop_runtime_run"); + _table.add_unique_constraint::("run_id", |row| &row.run_id); +} + +#[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 `JumpHopRuntimeRunRow`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait jump_hop_runtime_runQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `JumpHopRuntimeRunRow`. + fn jump_hop_runtime_run(&self) -> __sdk::__query_builder::Table; +} + +impl jump_hop_runtime_runQueryTableAccess for __sdk::QueryTableAccessor { + fn jump_hop_runtime_run(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("jump_hop_runtime_run") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_scoring_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_scoring_type.rs new file mode 100644 index 00000000..a33355b0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_scoring_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 JumpHopScoring { + pub charge_to_distance_ratio: f32, + pub max_charge_ms: u32, + pub hit_bonus: u32, + pub perfect_bonus: u32, +} + +impl __sdk::InModule for JumpHopScoring { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_asset_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_asset_snapshot_type.rs new file mode 100644 index 00000000..6874988f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_asset_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 JumpHopTileAssetSnapshot { + pub tile_type: String, + pub image_src: String, + pub image_object_key: String, + pub asset_object_id: String, + pub source_atlas_cell: String, + pub visual_width: u32, + pub visual_height: u32, + pub top_surface_radius: f32, + pub landing_radius: f32, +} + +impl __sdk::InModule for JumpHopTileAssetSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_type_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_type_type.rs new file mode 100644 index 00000000..f417ad5f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_type_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}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum JumpHopTileType { + Start, + + Normal, + + Target, + + Finish, + + Bonus, + + Accent, +} + +impl __sdk::InModule for JumpHopTileType { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_get_input_type.rs new file mode 100644 index 00000000..1e9fd0eb --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_get_input_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 JumpHopWorkGetInput { + pub profile_id: String, + pub owner_user_id: String, +} + +impl __sdk::InModule for JumpHopWorkGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_procedure_result_type.rs new file mode 100644 index 00000000..138ee818 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_procedure_result_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::jump_hop_work_snapshot_type::JumpHopWorkSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct JumpHopWorkProcedureResult { + pub ok: bool, + pub work: Option, + pub error_message: Option, +} + +impl __sdk::InModule for JumpHopWorkProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_profile_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_profile_row_type.rs new file mode 100644 index 00000000..b7bbd776 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_profile_row_type.rs @@ -0,0 +1,134 @@ +// 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 JumpHopWorkProfileRow { + pub profile_id: String, + pub work_id: String, + pub owner_user_id: String, + pub source_session_id: String, + pub author_display_name: String, + pub work_title: String, + pub work_description: String, + pub theme_tags_json: String, + pub difficulty: String, + pub style_preset: String, + pub character_prompt: String, + pub tile_prompt: String, + pub end_mood_prompt: String, + pub character_asset_json: String, + pub tile_atlas_asset_json: String, + pub tile_assets_json: String, + pub path_json: String, + pub cover_image_src: String, + pub cover_composite: String, + pub generation_status: String, + pub publication_status: String, + pub play_count: u32, + pub updated_at: __sdk::Timestamp, + pub published_at: Option<__sdk::Timestamp>, +} + +impl __sdk::InModule for JumpHopWorkProfileRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `JumpHopWorkProfileRow`. +/// +/// Provides typed access to columns for query building. +pub struct JumpHopWorkProfileRowCols { + pub profile_id: __sdk::__query_builder::Col, + pub work_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 theme_tags_json: __sdk::__query_builder::Col, + pub difficulty: __sdk::__query_builder::Col, + pub style_preset: __sdk::__query_builder::Col, + pub character_prompt: __sdk::__query_builder::Col, + pub tile_prompt: __sdk::__query_builder::Col, + pub end_mood_prompt: __sdk::__query_builder::Col, + pub character_asset_json: __sdk::__query_builder::Col, + pub tile_atlas_asset_json: __sdk::__query_builder::Col, + pub tile_assets_json: __sdk::__query_builder::Col, + pub path_json: __sdk::__query_builder::Col, + pub cover_image_src: __sdk::__query_builder::Col, + pub cover_composite: __sdk::__query_builder::Col, + pub generation_status: __sdk::__query_builder::Col, + pub publication_status: __sdk::__query_builder::Col, + pub play_count: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, + pub published_at: __sdk::__query_builder::Col>, +} + +impl __sdk::__query_builder::HasCols for JumpHopWorkProfileRow { + type Cols = JumpHopWorkProfileRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + JumpHopWorkProfileRowCols { + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + work_id: __sdk::__query_builder::Col::new(table_name, "work_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + source_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"), + theme_tags_json: __sdk::__query_builder::Col::new(table_name, "theme_tags_json"), + difficulty: __sdk::__query_builder::Col::new(table_name, "difficulty"), + style_preset: __sdk::__query_builder::Col::new(table_name, "style_preset"), + character_prompt: __sdk::__query_builder::Col::new(table_name, "character_prompt"), + tile_prompt: __sdk::__query_builder::Col::new(table_name, "tile_prompt"), + end_mood_prompt: __sdk::__query_builder::Col::new(table_name, "end_mood_prompt"), + character_asset_json: __sdk::__query_builder::Col::new( + table_name, + "character_asset_json", + ), + tile_atlas_asset_json: __sdk::__query_builder::Col::new( + table_name, + "tile_atlas_asset_json", + ), + tile_assets_json: __sdk::__query_builder::Col::new(table_name, "tile_assets_json"), + path_json: __sdk::__query_builder::Col::new(table_name, "path_json"), + cover_image_src: __sdk::__query_builder::Col::new(table_name, "cover_image_src"), + cover_composite: __sdk::__query_builder::Col::new(table_name, "cover_composite"), + generation_status: __sdk::__query_builder::Col::new(table_name, "generation_status"), + publication_status: __sdk::__query_builder::Col::new(table_name, "publication_status"), + play_count: __sdk::__query_builder::Col::new(table_name, "play_count"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + published_at: __sdk::__query_builder::Col::new(table_name, "published_at"), + } + } +} + +/// Indexed column accessor struct for the table `JumpHopWorkProfileRow`. +/// +/// Provides typed access to indexed columns for query building. +pub struct JumpHopWorkProfileRowIxCols { + pub owner_user_id: __sdk::__query_builder::IxCol, + pub profile_id: __sdk::__query_builder::IxCol, + pub publication_status: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for JumpHopWorkProfileRow { + type IxCols = JumpHopWorkProfileRowIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + JumpHopWorkProfileRowIxCols { + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + profile_id: __sdk::__query_builder::IxCol::new(table_name, "profile_id"), + publication_status: __sdk::__query_builder::IxCol::new( + table_name, + "publication_status", + ), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for JumpHopWorkProfileRow {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_profile_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_profile_table.rs new file mode 100644 index 00000000..cdc2c77f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_profile_table.rs @@ -0,0 +1,161 @@ +// 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::jump_hop_work_profile_row_type::JumpHopWorkProfileRow; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `jump_hop_work_profile`. +/// +/// Obtain a handle from the [`JumpHopWorkProfileTableAccess::jump_hop_work_profile`] method on [`super::RemoteTables`], +/// like `ctx.db.jump_hop_work_profile()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.jump_hop_work_profile().on_insert(...)`. +pub struct JumpHopWorkProfileTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `jump_hop_work_profile`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait JumpHopWorkProfileTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`JumpHopWorkProfileTableHandle`], which mediates access to the table `jump_hop_work_profile`. + fn jump_hop_work_profile(&self) -> JumpHopWorkProfileTableHandle<'_>; +} + +impl JumpHopWorkProfileTableAccess for super::RemoteTables { + fn jump_hop_work_profile(&self) -> JumpHopWorkProfileTableHandle<'_> { + JumpHopWorkProfileTableHandle { + imp: self + .imp + .get_table::("jump_hop_work_profile"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct JumpHopWorkProfileInsertCallbackId(__sdk::CallbackId); +pub struct JumpHopWorkProfileDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for JumpHopWorkProfileTableHandle<'ctx> { + type Row = JumpHopWorkProfileRow; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = JumpHopWorkProfileInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> JumpHopWorkProfileInsertCallbackId { + JumpHopWorkProfileInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: JumpHopWorkProfileInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = JumpHopWorkProfileDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> JumpHopWorkProfileDeleteCallbackId { + JumpHopWorkProfileDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: JumpHopWorkProfileDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct JumpHopWorkProfileUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for JumpHopWorkProfileTableHandle<'ctx> { + type UpdateCallbackId = JumpHopWorkProfileUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> JumpHopWorkProfileUpdateCallbackId { + JumpHopWorkProfileUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: JumpHopWorkProfileUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `profile_id` unique index on the table `jump_hop_work_profile`, +/// which allows point queries on the field of the same name +/// via the [`JumpHopWorkProfileProfileIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.jump_hop_work_profile().profile_id().find(...)`. +pub struct JumpHopWorkProfileProfileIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> JumpHopWorkProfileTableHandle<'ctx> { + /// Get a handle on the `profile_id` unique index on the table `jump_hop_work_profile`. + pub fn profile_id(&self) -> JumpHopWorkProfileProfileIdUnique<'ctx> { + JumpHopWorkProfileProfileIdUnique { + imp: self.imp.get_unique_constraint::("profile_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> JumpHopWorkProfileProfileIdUnique<'ctx> { + /// Find the subscribed row whose `profile_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("jump_hop_work_profile"); + _table.add_unique_constraint::("profile_id", |row| &row.profile_id); +} + +#[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 `JumpHopWorkProfileRow`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait jump_hop_work_profileQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `JumpHopWorkProfileRow`. + fn jump_hop_work_profile(&self) -> __sdk::__query_builder::Table; +} + +impl jump_hop_work_profileQueryTableAccess for __sdk::QueryTableAccessor { + fn jump_hop_work_profile(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("jump_hop_work_profile") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_publish_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_publish_input_type.rs new file mode 100644 index 00000000..dc24525d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_publish_input_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 JumpHopWorkPublishInput { + pub profile_id: String, + pub owner_user_id: String, + pub published_at_micros: i64, +} + +impl __sdk::InModule for JumpHopWorkPublishInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_snapshot_type.rs new file mode 100644 index 00000000..bda718d0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_snapshot_type.rs @@ -0,0 +1,43 @@ +// 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::jump_hop_character_asset_snapshot_type::JumpHopCharacterAssetSnapshot; +use super::jump_hop_path_type::JumpHopPath; +use super::jump_hop_tile_asset_snapshot_type::JumpHopTileAssetSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct JumpHopWorkSnapshot { + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: String, + pub author_display_name: String, + pub work_title: String, + pub work_description: String, + pub theme_tags: Vec, + pub difficulty: String, + pub style_preset: String, + pub character_prompt: String, + pub tile_prompt: String, + pub end_mood_prompt: Option, + pub character_asset: Option, + pub tile_atlas_asset: Option, + pub tile_assets: Vec, + pub path: JumpHopPath, + pub cover_image_src: String, + pub cover_composite: Option, + pub publication_status: String, + pub publish_ready: bool, + pub play_count: u32, + pub generation_status: String, + pub updated_at_micros: i64, + pub published_at_micros: Option, +} + +impl __sdk::InModule for JumpHopWorkSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_update_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_update_input_type.rs new file mode 100644 index 00000000..6e587c2b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_update_input_type.rs @@ -0,0 +1,24 @@ +// 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 JumpHopWorkUpdateInput { + pub profile_id: String, + pub owner_user_id: String, + pub work_title: String, + pub work_description: String, + pub theme_tags_json: String, + pub difficulty: Option, + pub style_preset: Option, + pub cover_image_src: Option, + pub cover_composite: Option, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for JumpHopWorkUpdateInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_works_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_works_list_input_type.rs new file mode 100644 index 00000000..c447cb67 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_works_list_input_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 JumpHopWorksListInput { + pub owner_user_id: String, + pub published_only: bool, +} + +impl __sdk::InModule for JumpHopWorksListInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_works_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_works_procedure_result_type.rs new file mode 100644 index 00000000..88d0daa9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/jump_hop_works_procedure_result_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::jump_hop_work_snapshot_type::JumpHopWorkSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct JumpHopWorksProcedureResult { + pub ok: bool, + pub items: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for JumpHopWorksProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_jump_hop_works_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_jump_hop_works_procedure.rs new file mode 100644 index 00000000..18b5cce5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_jump_hop_works_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::jump_hop_works_list_input_type::JumpHopWorksListInput; +use super::jump_hop_works_procedure_result_type::JumpHopWorksProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListJumpHopWorksArgs { + pub input: JumpHopWorksListInput, +} + +impl __sdk::InModule for ListJumpHopWorksArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_jump_hop_works`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_jump_hop_works { + fn list_jump_hop_works(&self, input: JumpHopWorksListInput) { + self.list_jump_hop_works_then(input, |_, _| {}); + } + + fn list_jump_hop_works_then( + &self, + input: JumpHopWorksListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl list_jump_hop_works for super::RemoteProcedures { + fn list_jump_hop_works_then( + &self, + input: JumpHopWorksListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, JumpHopWorksProcedureResult>( + "list_jump_hop_works", + ListJumpHopWorksArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/publish_jump_hop_work_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/publish_jump_hop_work_procedure.rs new file mode 100644 index 00000000..926aed9b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/publish_jump_hop_work_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::jump_hop_work_procedure_result_type::JumpHopWorkProcedureResult; +use super::jump_hop_work_publish_input_type::JumpHopWorkPublishInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct PublishJumpHopWorkArgs { + pub input: JumpHopWorkPublishInput, +} + +impl __sdk::InModule for PublishJumpHopWorkArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `publish_jump_hop_work`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait publish_jump_hop_work { + fn publish_jump_hop_work(&self, input: JumpHopWorkPublishInput) { + self.publish_jump_hop_work_then(input, |_, _| {}); + } + + fn publish_jump_hop_work_then( + &self, + input: JumpHopWorkPublishInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl publish_jump_hop_work for super::RemoteProcedures { + fn publish_jump_hop_work_then( + &self, + input: JumpHopWorkPublishInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, JumpHopWorkProcedureResult>( + "publish_jump_hop_work", + PublishJumpHopWorkArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/restart_jump_hop_run_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/restart_jump_hop_run_procedure.rs new file mode 100644 index 00000000..cde6daca --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/restart_jump_hop_run_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::jump_hop_run_procedure_result_type::JumpHopRunProcedureResult; +use super::jump_hop_run_restart_input_type::JumpHopRunRestartInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RestartJumpHopRunArgs { + pub input: JumpHopRunRestartInput, +} + +impl __sdk::InModule for RestartJumpHopRunArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `restart_jump_hop_run`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait restart_jump_hop_run { + fn restart_jump_hop_run(&self, input: JumpHopRunRestartInput) { + self.restart_jump_hop_run_then(input, |_, _| {}); + } + + fn restart_jump_hop_run_then( + &self, + input: JumpHopRunRestartInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl restart_jump_hop_run for super::RemoteProcedures { + fn restart_jump_hop_run_then( + &self, + input: JumpHopRunRestartInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, JumpHopRunProcedureResult>( + "restart_jump_hop_run", + RestartJumpHopRunArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/start_jump_hop_run_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/start_jump_hop_run_procedure.rs new file mode 100644 index 00000000..7a52fc9f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/start_jump_hop_run_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::jump_hop_run_procedure_result_type::JumpHopRunProcedureResult; +use super::jump_hop_run_start_input_type::JumpHopRunStartInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct StartJumpHopRunArgs { + pub input: JumpHopRunStartInput, +} + +impl __sdk::InModule for StartJumpHopRunArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `start_jump_hop_run`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait start_jump_hop_run { + fn start_jump_hop_run(&self, input: JumpHopRunStartInput) { + self.start_jump_hop_run_then(input, |_, _| {}); + } + + fn start_jump_hop_run_then( + &self, + input: JumpHopRunStartInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl start_jump_hop_run for super::RemoteProcedures { + fn start_jump_hop_run_then( + &self, + input: JumpHopRunStartInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, JumpHopRunProcedureResult>( + "start_jump_hop_run", + StartJumpHopRunArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/update_jump_hop_work_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/update_jump_hop_work_procedure.rs new file mode 100644 index 00000000..9186048b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/update_jump_hop_work_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::jump_hop_work_procedure_result_type::JumpHopWorkProcedureResult; +use super::jump_hop_work_update_input_type::JumpHopWorkUpdateInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct UpdateJumpHopWorkArgs { + pub input: JumpHopWorkUpdateInput, +} + +impl __sdk::InModule for UpdateJumpHopWorkArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `update_jump_hop_work`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait update_jump_hop_work { + fn update_jump_hop_work(&self, input: JumpHopWorkUpdateInput) { + self.update_jump_hop_work_then(input, |_, _| {}); + } + + fn update_jump_hop_work_then( + &self, + input: JumpHopWorkUpdateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl update_jump_hop_work for super::RemoteProcedures { + fn update_jump_hop_work_then( + &self, + input: JumpHopWorkUpdateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, JumpHopWorkProcedureResult>( + "update_jump_hop_work", + UpdateJumpHopWorkArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/runtime.rs b/server-rs/crates/spacetime-client/src/runtime.rs index 1b9429a7..08dbbf30 100644 --- a/server-rs/crates/spacetime-client/src/runtime.rs +++ b/server-rs/crates/spacetime-client/src/runtime.rs @@ -595,7 +595,18 @@ impl SpacetimeClient { let procedure_inputs = events .into_iter() - .map(crate::module_bindings::RuntimeTrackingEventInput::from) + .map(|event| crate::module_bindings::RuntimeTrackingEventInput { + event_id: event.event_id, + event_key: event.event_key, + scope_kind: map_runtime_tracking_scope_kind(event.scope_kind), + scope_id: event.scope_id, + user_id: event.user_id, + owner_user_id: event.owner_user_id, + profile_id: event.profile_id, + module_key: event.module_key, + metadata_json: event.metadata_json, + occurred_at_micros: event.occurred_at_micros, + }) .collect::>(); self.call_after_connect( diff --git a/server-rs/crates/spacetime-module/Cargo.toml b/server-rs/crates/spacetime-module/Cargo.toml index 17822404..16ff92e2 100644 --- a/server-rs/crates/spacetime-module/Cargo.toml +++ b/server-rs/crates/spacetime-module/Cargo.toml @@ -18,6 +18,7 @@ module-big-fish = { workspace = true, features = ["spacetime-types"] } module-combat = { workspace = true, features = ["spacetime-types"] } module-inventory = { workspace = true, features = ["spacetime-types"] } module-custom-world = { workspace = true, features = ["spacetime-types"] } +module-jump-hop = { workspace = true, features = ["spacetime-types"] } module-match3d = { workspace = true } module-npc = { workspace = true, features = ["spacetime-types"] } module-puzzle = { workspace = true, features = ["spacetime-types"] } diff --git a/server-rs/crates/spacetime-module/src/jump_hop.rs b/server-rs/crates/spacetime-module/src/jump_hop.rs new file mode 100644 index 00000000..d84c754c --- /dev/null +++ b/server-rs/crates/spacetime-module/src/jump_hop.rs @@ -0,0 +1,1165 @@ +pub(crate) mod tables; +mod types; + +pub use tables::*; +pub use types::*; + +use crate::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp}; +use module_jump_hop::{ + JumpHopDifficulty, JumpHopPath, JumpHopRunSnapshot, apply_jump, generate_jump_hop_path, + normalize_jump_hop_seed, parse_jump_hop_difficulty, restart_run, start_run, +}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use spacetimedb::AnonymousViewContext; + +#[spacetimedb::view(accessor = jump_hop_gallery_view, public)] +pub fn jump_hop_gallery_view(ctx: &AnonymousViewContext) -> Vec { + let mut items = ctx + .db + .jump_hop_work_profile() + .by_jump_hop_work_publication_status() + .filter(JUMP_HOP_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 +} + +#[spacetimedb::view(accessor = jump_hop_gallery_card_view, public)] +pub fn jump_hop_gallery_card_view(ctx: &AnonymousViewContext) -> Vec { + jump_hop_gallery_view(ctx) + .into_iter() + .map(|row| JumpHopGalleryCardViewRow { + public_work_code: row.work_id.clone(), + work_id: row.work_id, + profile_id: row.profile_id, + owner_user_id: row.owner_user_id, + author_display_name: row.author_display_name, + work_title: row.work_title, + work_description: row.work_description, + theme_tags: row.theme_tags, + difficulty: row.difficulty, + style_preset: row.style_preset, + cover_image_src: row.cover_image_src, + publication_status: row.publication_status, + play_count: row.play_count, + updated_at_micros: row.updated_at_micros, + published_at_micros: row.published_at_micros, + generation_status: row.generation_status, + }) + .collect() +} + +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct JumpHopGalleryViewRow { + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: String, + pub author_display_name: String, + pub work_title: String, + pub work_description: String, + pub theme_tags: Vec, + pub difficulty: String, + pub style_preset: String, + pub character_prompt: String, + pub tile_prompt: String, + pub end_mood_prompt: Option, + pub character_asset: Option, + pub tile_atlas_asset: Option, + pub tile_assets: Vec, + pub path: JumpHopPath, + pub cover_image_src: String, + pub cover_composite: Option, + pub publication_status: String, + pub publish_ready: bool, + pub play_count: u32, + pub generation_status: String, + pub updated_at_micros: i64, + pub published_at_micros: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct JumpHopGalleryCardViewRow { + pub public_work_code: String, + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub author_display_name: String, + pub work_title: String, + pub work_description: String, + pub theme_tags: Vec, + pub difficulty: String, + pub style_preset: String, + pub cover_image_src: String, + pub publication_status: String, + pub play_count: u32, + pub updated_at_micros: i64, + pub published_at_micros: Option, + pub generation_status: String, +} + +#[spacetimedb::procedure] +pub fn create_jump_hop_agent_session( + ctx: &mut ProcedureContext, + input: JumpHopAgentSessionCreateInput, +) -> JumpHopAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| create_jump_hop_agent_session_tx(tx, input.clone())) { + Ok(session) => session_result(session), + Err(message) => session_error(message), + } +} + +#[spacetimedb::procedure] +pub fn get_jump_hop_agent_session( + ctx: &mut ProcedureContext, + input: JumpHopAgentSessionGetInput, +) -> JumpHopAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| get_jump_hop_agent_session_tx(tx, input.clone())) { + Ok(session) => session_result(session), + Err(message) => session_error(message), + } +} + +#[spacetimedb::procedure] +pub fn compile_jump_hop_draft( + ctx: &mut ProcedureContext, + input: JumpHopDraftCompileInput, +) -> JumpHopAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| compile_jump_hop_draft_tx(tx, input.clone())) { + Ok(session) => session_result(session), + Err(message) => session_error(message), + } +} + +#[spacetimedb::procedure] +pub fn get_jump_hop_work_profile( + ctx: &mut ProcedureContext, + input: JumpHopWorkGetInput, +) -> JumpHopWorkProcedureResult { + match ctx.try_with_tx(|tx| get_jump_hop_work_profile_tx(tx, input.clone())) { + Ok(work) => work_result(work), + Err(message) => work_error(message), + } +} + +#[spacetimedb::procedure] +pub fn update_jump_hop_work( + ctx: &mut ProcedureContext, + input: JumpHopWorkUpdateInput, +) -> JumpHopWorkProcedureResult { + match ctx.try_with_tx(|tx| update_jump_hop_work_tx(tx, input.clone())) { + Ok(work) => work_result(work), + Err(message) => work_error(message), + } +} + +#[spacetimedb::procedure] +pub fn publish_jump_hop_work( + ctx: &mut ProcedureContext, + input: JumpHopWorkPublishInput, +) -> JumpHopWorkProcedureResult { + match ctx.try_with_tx(|tx| publish_jump_hop_work_tx(tx, input.clone())) { + Ok(work) => work_result(work), + Err(message) => work_error(message), + } +} + +#[spacetimedb::procedure] +pub fn list_jump_hop_works( + ctx: &mut ProcedureContext, + input: JumpHopWorksListInput, +) -> JumpHopWorksProcedureResult { + match ctx.try_with_tx(|tx| list_jump_hop_works_tx(tx, input.clone())) { + Ok(items) => JumpHopWorksProcedureResult { + ok: true, + items, + error_message: None, + }, + Err(message) => JumpHopWorksProcedureResult { + ok: false, + items: Vec::new(), + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn start_jump_hop_run( + ctx: &mut ProcedureContext, + input: JumpHopRunStartInput, +) -> JumpHopRunProcedureResult { + match ctx.try_with_tx(|tx| start_jump_hop_run_tx(tx, input.clone())) { + Ok(run) => run_result(run), + Err(message) => run_error(message), + } +} + +#[spacetimedb::procedure] +pub fn get_jump_hop_run( + ctx: &mut ProcedureContext, + input: JumpHopRunGetInput, +) -> JumpHopRunProcedureResult { + match ctx.try_with_tx(|tx| get_jump_hop_run_tx(tx, input.clone())) { + Ok(run) => run_result(run), + Err(message) => run_error(message), + } +} + +#[spacetimedb::procedure] +pub fn jump_hop_jump( + ctx: &mut ProcedureContext, + input: JumpHopRunJumpInput, +) -> JumpHopRunProcedureResult { + match ctx.try_with_tx(|tx| jump_hop_jump_tx(tx, input.clone())) { + Ok(run) => run_result(run), + Err(message) => run_error(message), + } +} + +#[spacetimedb::procedure] +pub fn restart_jump_hop_run( + ctx: &mut ProcedureContext, + input: JumpHopRunRestartInput, +) -> JumpHopRunProcedureResult { + match ctx.try_with_tx(|tx| restart_jump_hop_run_tx(tx, input.clone())) { + Ok(run) => run_result(run), + Err(message) => run_error(message), + } +} + +fn create_jump_hop_agent_session_tx( + ctx: &ReducerContext, + input: JumpHopAgentSessionCreateInput, +) -> Result { + require_non_empty(&input.session_id, "jump_hop session_id")?; + require_non_empty(&input.owner_user_id, "jump_hop owner_user_id")?; + if ctx + .db + .jump_hop_agent_session() + .session_id() + .find(&input.session_id) + .is_some() + { + return Err("jump_hop_agent_session.session_id 已存在".to_string()); + } + + let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros); + let config = input + .config_json + .as_deref() + .map(parse_config) + .transpose()? + .unwrap_or_else(|| default_config_from_seed(&input.seed_text)); + let draft = JumpHopDraftSnapshot { + template_id: JUMP_HOP_TEMPLATE_ID.to_string(), + template_name: JUMP_HOP_TEMPLATE_NAME.to_string(), + profile_id: None, + work_title: input.work_title.clone(), + work_description: input.work_description.clone(), + theme_tags: parse_tags(input.theme_tags_json.as_deref().unwrap_or("[]"))?, + difficulty: config.difficulty.clone(), + style_preset: config.style_preset.clone(), + character_prompt: config.character_prompt.clone(), + tile_prompt: config.tile_prompt.clone(), + end_mood_prompt: clean_optional(&config.end_mood_prompt), + character_asset: None, + tile_atlas_asset: None, + tile_assets: Vec::new(), + path: None, + cover_composite: None, + generation_status: JUMP_HOP_GENERATION_DRAFT.to_string(), + }; + ctx.db + .jump_hop_agent_session() + .insert(JumpHopAgentSessionRow { + session_id: input.session_id.clone(), + owner_user_id: input.owner_user_id.clone(), + seed_text: input.seed_text.trim().to_string(), + current_turn: 0, + progress_percent: 0, + stage: JUMP_HOP_STAGE_COLLECTING.to_string(), + config_json: to_json_string(&config), + draft_json: to_json_string(&draft), + last_assistant_reply: input.welcome_message_text.trim().to_string(), + published_profile_id: String::new(), + created_at, + updated_at: created_at, + }); + + get_jump_hop_agent_session_tx( + ctx, + JumpHopAgentSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + +fn get_jump_hop_agent_session_tx( + ctx: &ReducerContext, + input: JumpHopAgentSessionGetInput, +) -> Result { + let row = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?; + build_session_snapshot(&row) +} + +fn compile_jump_hop_draft_tx( + ctx: &ReducerContext, + input: JumpHopDraftCompileInput, +) -> Result { + require_non_empty(&input.profile_id, "jump_hop profile_id")?; + let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?; + let mut config = parse_config(&session.config_json)?; + apply_compile_overrides(&mut config, &input)?; + + let seed = normalize_jump_hop_seed(&input.seed_text, &session.seed_text); + let path = generate_jump_hop_path(&seed, parse_jump_hop_difficulty(&config.difficulty)); + let tags = parse_tags(input.theme_tags_json.as_deref().unwrap_or("[]"))?; + let draft = JumpHopDraftSnapshot { + template_id: JUMP_HOP_TEMPLATE_ID.to_string(), + template_name: JUMP_HOP_TEMPLATE_NAME.to_string(), + profile_id: Some(input.profile_id.clone()), + work_title: clean_string(&input.work_title, "跳一跳作品"), + work_description: input.work_description.trim().to_string(), + theme_tags: tags.clone(), + difficulty: config.difficulty.clone(), + style_preset: config.style_preset.clone(), + character_prompt: config.character_prompt.clone(), + tile_prompt: config.tile_prompt.clone(), + end_mood_prompt: clean_optional(&config.end_mood_prompt), + character_asset: input + .character_asset_json + .as_deref() + .map(parse_json) + .transpose()?, + tile_atlas_asset: input + .tile_atlas_asset_json + .as_deref() + .map(parse_json) + .transpose()?, + tile_assets: input + .tile_assets_json + .as_deref() + .map(parse_json) + .transpose()? + .unwrap_or_default(), + path: Some(path.clone()), + cover_composite: input.cover_composite.as_deref().and_then(clean_optional), + generation_status: input + .generation_status + .clone() + .unwrap_or_else(|| JUMP_HOP_GENERATION_READY.to_string()), + }; + let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros); + let row = JumpHopWorkProfileRow { + profile_id: input.profile_id.clone(), + work_id: input.profile_id.clone(), + owner_user_id: input.owner_user_id.clone(), + source_session_id: input.session_id.clone(), + author_display_name: clean_string(&input.author_display_name, "跳一跳玩家"), + work_title: draft.work_title.clone(), + work_description: draft.work_description.clone(), + theme_tags_json: to_json_string(&tags), + difficulty: draft.difficulty.clone(), + style_preset: draft.style_preset.clone(), + character_prompt: draft.character_prompt.clone(), + tile_prompt: draft.tile_prompt.clone(), + end_mood_prompt: draft.end_mood_prompt.clone().unwrap_or_default(), + character_asset_json: draft + .character_asset + .as_ref() + .map(to_json_string) + .unwrap_or_default(), + tile_atlas_asset_json: draft + .tile_atlas_asset + .as_ref() + .map(to_json_string) + .unwrap_or_default(), + tile_assets_json: to_json_string(&draft.tile_assets), + path_json: to_json_string(&path), + cover_image_src: draft.cover_composite.clone().unwrap_or_default(), + cover_composite: draft.cover_composite.clone().unwrap_or_default(), + generation_status: draft.generation_status.clone(), + publication_status: JUMP_HOP_PUBLICATION_DRAFT.to_string(), + play_count: 0, + updated_at: compiled_at, + published_at: None, + }; + upsert_work(ctx, row); + replace_session( + ctx, + &session, + JumpHopAgentSessionRow { + progress_percent: 100, + stage: JUMP_HOP_STAGE_DRAFT_COMPILED.to_string(), + config_json: to_json_string(&config), + draft_json: to_json_string(&draft), + published_profile_id: input.profile_id, + last_assistant_reply: "跳一跳草稿已生成,可以进入结果页试玩和发布。".to_string(), + updated_at: compiled_at, + ..clone_session(&session) + }, + ); + + get_jump_hop_agent_session_tx( + ctx, + JumpHopAgentSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + +fn get_jump_hop_work_profile_tx( + ctx: &ReducerContext, + input: JumpHopWorkGetInput, +) -> Result { + let row = find_work(ctx, &input.profile_id)?; + if !input.owner_user_id.trim().is_empty() && row.owner_user_id != input.owner_user_id { + return Err("无权访问该 jump_hop work".to_string()); + } + build_work_snapshot(&row) +} + +fn update_jump_hop_work_tx( + ctx: &ReducerContext, + input: JumpHopWorkUpdateInput, +) -> Result { + let row = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?; + let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + let mut next = clone_work(&row); + next.work_title = clean_string(&input.work_title, &row.work_title); + next.work_description = input.work_description.trim().to_string(); + next.theme_tags_json = input.theme_tags_json.clone(); + if let Some(difficulty) = input.difficulty.as_deref().and_then(clean_optional) { + next.difficulty = difficulty; + let path = generate_jump_hop_path( + &normalize_jump_hop_seed(&row.profile_id, &row.source_session_id), + parse_jump_hop_difficulty(&next.difficulty), + ); + next.path_json = to_json_string(&path); + } + if let Some(style_preset) = input.style_preset.as_deref().and_then(clean_optional) { + next.style_preset = style_preset; + } + if let Some(cover) = input.cover_image_src.as_deref().and_then(clean_optional) { + next.cover_image_src = cover; + } + if let Some(cover) = input.cover_composite.as_deref().and_then(clean_optional) { + next.cover_composite = cover; + } + next.updated_at = updated_at; + replace_work(ctx, &row, next); + let updated = find_work(ctx, &row.profile_id)?; + sync_session_from_work_update(ctx, &updated, updated_at)?; + build_work_snapshot(&updated) +} + +fn publish_jump_hop_work_tx( + ctx: &ReducerContext, + input: JumpHopWorkPublishInput, +) -> Result { + let row = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?; + let published_at = Timestamp::from_micros_since_unix_epoch(input.published_at_micros); + replace_work( + ctx, + &row, + JumpHopWorkProfileRow { + publication_status: JUMP_HOP_PUBLICATION_PUBLISHED.to_string(), + updated_at: published_at, + published_at: Some(published_at), + ..clone_work(&row) + }, + ); + if let Some(session) = ctx + .db + .jump_hop_agent_session() + .session_id() + .find(&row.source_session_id) + { + replace_session( + ctx, + &session, + JumpHopAgentSessionRow { + stage: JUMP_HOP_STAGE_PUBLISHED.to_string(), + updated_at: published_at, + ..clone_session(&session) + }, + ); + } + let updated = find_work(ctx, &row.profile_id)?; + build_work_snapshot(&updated) +} + +fn list_jump_hop_works_tx( + ctx: &ReducerContext, + input: JumpHopWorksListInput, +) -> Result, String> { + let mut rows = if input.owner_user_id.trim().is_empty() { + ctx.db.jump_hop_work_profile().iter().collect::>() + } else { + ctx.db + .jump_hop_work_profile() + .by_jump_hop_work_owner_user_id() + .filter(input.owner_user_id.as_str()) + .collect::>() + }; + if input.published_only { + rows.retain(|row| row.publication_status == JUMP_HOP_PUBLICATION_PUBLISHED); + } + rows.sort_by(|left, right| { + right + .updated_at + .cmp(&left.updated_at) + .then_with(|| left.profile_id.cmp(&right.profile_id)) + }); + rows.into_iter() + .map(|row| build_work_snapshot(&row)) + .collect() +} + +fn start_jump_hop_run_tx( + ctx: &ReducerContext, + input: JumpHopRunStartInput, +) -> Result { + require_non_empty(&input.run_id, "jump_hop run_id")?; + let work = find_work(ctx, &input.profile_id)?; + let path = parse_json::(&work.path_json)?; + let domain_run = start_run( + input.run_id.clone(), + input.owner_user_id.clone(), + input.profile_id.clone(), + path, + input.started_at_ms as u64, + ) + .map_err(|error| error.to_string())?; + let snapshot = domain_run; + upsert_run(ctx, &snapshot, input.started_at_ms); + increment_work_play_count(ctx, &work, input.started_at_ms); + insert_event( + ctx, + input.client_event_id, + input.owner_user_id, + input.profile_id, + input.run_id, + JUMP_HOP_EVENT_RUN_STARTED, + None, + input.started_at_ms, + ); + Ok(snapshot) +} + +fn get_jump_hop_run_tx( + ctx: &ReducerContext, + input: JumpHopRunGetInput, +) -> Result { + let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; + parse_json(&row.snapshot_json) +} + +fn jump_hop_jump_tx( + ctx: &ReducerContext, + input: JumpHopRunJumpInput, +) -> Result { + let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; + let snapshot = parse_json::(&row.snapshot_json)?; + let domain_next = apply_jump(&snapshot, input.charge_ms, input.jumped_at_ms as u64) + .map_err(|error| error.to_string())?; + let next = domain_next; + replace_run(ctx, &row, &next, input.jumped_at_ms); + insert_event( + ctx, + input.client_event_id, + input.owner_user_id, + next.profile_id.clone(), + input.run_id, + JUMP_HOP_EVENT_JUMP, + next.last_jump + .as_ref() + .map(|jump| jump.result.as_str().to_string()) + .or_else(|| Some(next.status.as_str().to_string())), + input.jumped_at_ms, + ); + Ok(next) +} + +fn restart_jump_hop_run_tx( + ctx: &ReducerContext, + input: JumpHopRunRestartInput, +) -> Result { + let source = find_owned_run(ctx, &input.source_run_id, &input.owner_user_id)?; + let snapshot = parse_json::(&source.snapshot_json)?; + let domain_next = restart_run( + &snapshot, + input.next_run_id.clone(), + input.restarted_at_ms as u64, + ) + .map_err(|error| error.to_string())?; + let next = domain_next; + upsert_run(ctx, &next, input.restarted_at_ms); + insert_event( + ctx, + input.client_action_id, + input.owner_user_id, + next.profile_id.clone(), + input.next_run_id, + JUMP_HOP_EVENT_RUN_RESTARTED, + None, + input.restarted_at_ms, + ); + Ok(next) +} + +fn build_gallery_view_row(row: &JumpHopWorkProfileRow) -> Result { + let work = build_work_snapshot(row)?; + Ok(JumpHopGalleryViewRow { + work_id: work.work_id, + profile_id: work.profile_id, + owner_user_id: work.owner_user_id, + source_session_id: work.source_session_id, + author_display_name: work.author_display_name, + work_title: work.work_title, + work_description: work.work_description, + theme_tags: work.theme_tags, + difficulty: work.difficulty, + style_preset: work.style_preset, + character_prompt: work.character_prompt, + tile_prompt: work.tile_prompt, + end_mood_prompt: work.end_mood_prompt, + character_asset: work.character_asset, + tile_atlas_asset: work.tile_atlas_asset, + tile_assets: work.tile_assets, + path: work.path, + cover_image_src: work.cover_image_src, + cover_composite: work.cover_composite, + publication_status: work.publication_status, + publish_ready: work.publish_ready, + play_count: work.play_count, + generation_status: work.generation_status, + updated_at_micros: work.updated_at_micros, + published_at_micros: work.published_at_micros, + }) +} + +fn build_session_snapshot( + row: &JumpHopAgentSessionRow, +) -> Result { + Ok(JumpHopAgentSessionSnapshot { + session_id: row.session_id.clone(), + owner_user_id: row.owner_user_id.clone(), + seed_text: row.seed_text.clone(), + current_turn: row.current_turn, + progress_percent: row.progress_percent, + stage: row.stage.clone(), + config: parse_config(&row.config_json)?, + draft: clean_optional(&row.draft_json) + .map(|value| parse_json(&value)) + .transpose()?, + last_assistant_reply: row.last_assistant_reply.clone(), + published_profile_id: clean_optional(&row.published_profile_id), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + }) +} + +fn build_work_snapshot(row: &JumpHopWorkProfileRow) -> Result { + let path = parse_json(&row.path_json)?; + Ok(JumpHopWorkSnapshot { + 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: row.work_title.clone(), + work_description: row.work_description.clone(), + theme_tags: parse_tags(&row.theme_tags_json)?, + difficulty: row.difficulty.clone(), + style_preset: row.style_preset.clone(), + character_prompt: row.character_prompt.clone(), + tile_prompt: row.tile_prompt.clone(), + end_mood_prompt: clean_optional(&row.end_mood_prompt), + character_asset: clean_optional(&row.character_asset_json) + .map(|value| parse_json(&value)) + .transpose()?, + tile_atlas_asset: clean_optional(&row.tile_atlas_asset_json) + .map(|value| parse_json(&value)) + .transpose()?, + tile_assets: parse_json_or_default(&row.tile_assets_json), + path, + cover_image_src: row.cover_image_src.clone(), + cover_composite: clean_optional(&row.cover_composite), + publication_status: row.publication_status.clone(), + publish_ready: is_publish_ready(row), + play_count: row.play_count, + generation_status: row.generation_status.clone(), + 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 sync_session_from_work_update( + ctx: &ReducerContext, + work: &JumpHopWorkProfileRow, + updated_at: Timestamp, +) -> Result<(), String> { + let Some(session) = ctx + .db + .jump_hop_agent_session() + .session_id() + .find(&work.source_session_id) + else { + return Ok(()); + }; + + let mut config = parse_config(&session.config_json)?; + config.theme_text = work.work_title.clone(); + config.difficulty = work.difficulty.clone(); + config.style_preset = work.style_preset.clone(); + config.character_prompt = work.character_prompt.clone(); + config.tile_prompt = work.tile_prompt.clone(); + config.end_mood_prompt = work.end_mood_prompt.clone(); + + let draft = JumpHopDraftSnapshot { + template_id: JUMP_HOP_TEMPLATE_ID.to_string(), + template_name: JUMP_HOP_TEMPLATE_NAME.to_string(), + profile_id: Some(work.profile_id.clone()), + work_title: work.work_title.clone(), + work_description: work.work_description.clone(), + theme_tags: parse_tags(&work.theme_tags_json)?, + difficulty: work.difficulty.clone(), + style_preset: work.style_preset.clone(), + character_prompt: work.character_prompt.clone(), + tile_prompt: work.tile_prompt.clone(), + end_mood_prompt: clean_optional(&work.end_mood_prompt), + character_asset: clean_optional(&work.character_asset_json) + .map(|value| parse_json(&value)) + .transpose()?, + tile_atlas_asset: clean_optional(&work.tile_atlas_asset_json) + .map(|value| parse_json(&value)) + .transpose()?, + tile_assets: parse_json_or_default(&work.tile_assets_json), + path: Some(parse_json(&work.path_json)?), + cover_composite: clean_optional(&work.cover_composite), + generation_status: work.generation_status.clone(), + }; + + replace_session( + ctx, + &session, + JumpHopAgentSessionRow { + config_json: to_json_string(&config), + draft_json: to_json_string(&draft), + updated_at, + ..clone_session(&session) + }, + ); + Ok(()) +} + +fn find_owned_session( + ctx: &ReducerContext, + session_id: &str, + owner_user_id: &str, +) -> Result { + let row = ctx + .db + .jump_hop_agent_session() + .session_id() + .find(&session_id.to_string()) + .ok_or_else(|| "jump_hop_agent_session 不存在".to_string())?; + if row.owner_user_id != owner_user_id { + return Err("无权访问该 jump_hop session".to_string()); + } + Ok(row) +} + +fn find_work(ctx: &ReducerContext, profile_id: &str) -> Result { + ctx.db + .jump_hop_work_profile() + .profile_id() + .find(&profile_id.to_string()) + .ok_or_else(|| "jump_hop_work_profile 不存在".to_string()) +} + +fn find_owned_work( + ctx: &ReducerContext, + profile_id: &str, + owner_user_id: &str, +) -> Result { + let row = find_work(ctx, profile_id)?; + if row.owner_user_id != owner_user_id { + return Err("无权访问该 jump_hop work".to_string()); + } + Ok(row) +} + +fn find_owned_run( + ctx: &ReducerContext, + run_id: &str, + owner_user_id: &str, +) -> Result { + let row = ctx + .db + .jump_hop_runtime_run() + .run_id() + .find(&run_id.to_string()) + .ok_or_else(|| "jump_hop_runtime_run 不存在".to_string())?; + if row.owner_user_id != owner_user_id { + return Err("无权访问该 jump_hop run".to_string()); + } + Ok(row) +} + +fn upsert_work(ctx: &ReducerContext, row: JumpHopWorkProfileRow) { + if let Some(old) = ctx + .db + .jump_hop_work_profile() + .profile_id() + .find(&row.profile_id) + { + ctx.db.jump_hop_work_profile().delete(old); + } + ctx.db.jump_hop_work_profile().insert(row); +} + +fn replace_work(ctx: &ReducerContext, old: &JumpHopWorkProfileRow, next: JumpHopWorkProfileRow) { + ctx.db.jump_hop_work_profile().delete(clone_work(old)); + ctx.db.jump_hop_work_profile().insert(next); +} + +fn replace_session( + ctx: &ReducerContext, + old: &JumpHopAgentSessionRow, + next: JumpHopAgentSessionRow, +) { + ctx.db.jump_hop_agent_session().delete(clone_session(old)); + ctx.db.jump_hop_agent_session().insert(next); +} + +fn upsert_run(ctx: &ReducerContext, snapshot: &JumpHopRunSnapshot, updated_at_ms: i64) { + if let Some(old) = ctx + .db + .jump_hop_runtime_run() + .run_id() + .find(&snapshot.run_id) + { + ctx.db.jump_hop_runtime_run().delete(old); + } + let created_at = Timestamp::from_micros_since_unix_epoch(updated_at_ms.saturating_mul(1000)); + ctx.db + .jump_hop_runtime_run() + .insert(run_row_from_snapshot(snapshot, created_at, created_at)); +} + +fn replace_run( + ctx: &ReducerContext, + old: &JumpHopRuntimeRunRow, + snapshot: &JumpHopRunSnapshot, + updated_at_ms: i64, +) { + ctx.db.jump_hop_runtime_run().delete(clone_run(old)); + ctx.db.jump_hop_runtime_run().insert(run_row_from_snapshot( + snapshot, + old.created_at, + Timestamp::from_micros_since_unix_epoch(updated_at_ms.saturating_mul(1000)), + )); +} + +fn run_row_from_snapshot( + snapshot: &JumpHopRunSnapshot, + created_at: Timestamp, + updated_at: Timestamp, +) -> JumpHopRuntimeRunRow { + JumpHopRuntimeRunRow { + run_id: snapshot.run_id.clone(), + owner_user_id: snapshot.owner_user_id.clone(), + profile_id: snapshot.profile_id.clone(), + status: snapshot.status.as_str().to_string(), + started_at_ms: snapshot.started_at_ms as i64, + finished_at_ms: snapshot + .finished_at_ms + .map(|value| value as i64) + .unwrap_or(0), + current_platform_index: snapshot.current_platform_index, + score: snapshot.score, + combo: snapshot.combo, + snapshot_json: to_json_string(snapshot), + created_at, + updated_at, + } +} + +fn increment_work_play_count(ctx: &ReducerContext, row: &JumpHopWorkProfileRow, played_at_ms: i64) { + replace_work( + ctx, + row, + JumpHopWorkProfileRow { + play_count: row.play_count.saturating_add(1), + updated_at: Timestamp::from_micros_since_unix_epoch(played_at_ms.saturating_mul(1000)), + ..clone_work(row) + }, + ); +} + +fn insert_event( + ctx: &ReducerContext, + event_id: String, + owner_user_id: String, + profile_id: String, + run_id: String, + event_type: &str, + result: Option, + occurred_at_ms: i64, +) { + let event_id = clean_optional(&event_id).unwrap_or_else(|| { + format!( + "jump-hop-event-{}-{}-{}", + run_id, event_type, occurred_at_ms + ) + }); + if ctx.db.jump_hop_event().event_id().find(&event_id).is_some() { + return; + } + ctx.db.jump_hop_event().insert(JumpHopEventRow { + event_id, + owner_user_id, + profile_id, + run_id, + event_type: event_type.to_string(), + result: result.unwrap_or_default(), + occurred_at: Timestamp::from_micros_since_unix_epoch(occurred_at_ms.saturating_mul(1000)), + }); +} + +fn is_publish_ready(row: &JumpHopWorkProfileRow) -> bool { + !row.work_title.trim().is_empty() + && !row.character_asset_json.trim().is_empty() + && !row.tile_atlas_asset_json.trim().is_empty() + && !row.tile_assets_json.trim().is_empty() + && !row.path_json.trim().is_empty() +} + +fn default_config_from_seed(seed_text: &str) -> JumpHopCreatorConfigSnapshot { + let seed = clean_string(seed_text, "跳一跳"); + JumpHopCreatorConfigSnapshot { + theme_text: seed.clone(), + difficulty: JumpHopDifficulty::Standard.as_str().to_string(), + style_preset: JUMP_HOP_STYLE_MINIMAL_BLOCKS.to_string(), + character_prompt: format!("{seed}的俯视角主角,透明背景,全身可见"), + tile_prompt: format!("{seed}的等距地块图集,包含起点、普通、目标和终点地块"), + end_mood_prompt: String::new(), + } +} + +fn apply_compile_overrides( + config: &mut JumpHopCreatorConfigSnapshot, + input: &JumpHopDraftCompileInput, +) -> Result<(), String> { + if let Some(value) = input.theme_text.as_deref().and_then(clean_optional) { + config.theme_text = value; + } + if let Some(value) = input.difficulty.as_deref().and_then(clean_optional) { + config.difficulty = value; + } + if let Some(value) = input.style_preset.as_deref().and_then(clean_optional) { + config.style_preset = value; + } + if let Some(value) = input.character_prompt.as_deref().and_then(clean_optional) { + config.character_prompt = value; + } + if let Some(value) = input.tile_prompt.as_deref().and_then(clean_optional) { + config.tile_prompt = value; + } + if let Some(value) = input.end_mood_prompt.as_deref().and_then(clean_optional) { + config.end_mood_prompt = value; + } + require_non_empty(&config.theme_text, "jump_hop theme_text")?; + require_non_empty(&config.character_prompt, "jump_hop character_prompt")?; + require_non_empty(&config.tile_prompt, "jump_hop tile_prompt")?; + Ok(()) +} + +fn require_non_empty(value: &str, label: &str) -> Result<(), String> { + if value.trim().is_empty() { + Err(format!("{label} 不能为空")) + } else { + Ok(()) + } +} + +fn clean_optional(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() { + None + } else { + Some(value.to_string()) + } +} + +fn clean_string(value: &str, fallback: &str) -> String { + clean_optional(value).unwrap_or_else(|| fallback.to_string()) +} + +fn parse_config(value: &str) -> Result { + parse_json(value) +} + +fn parse_tags(value: &str) -> Result, String> { + Ok(parse_json_or_default::>(value) + .into_iter() + .map(|tag| tag.trim().to_string()) + .filter(|tag| !tag.is_empty()) + .take(8) + .collect()) +} + +fn parse_json(value: &str) -> Result +where + T: DeserializeOwned, +{ + serde_json::from_str(value).map_err(|error| error.to_string()) +} + +fn parse_json_or_default(value: &str) -> T +where + T: DeserializeOwned + Default, +{ + serde_json::from_str(value).unwrap_or_default() +} + +fn to_json_string(value: &T) -> String +where + T: Serialize, +{ + serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string()) +} + +fn session_result(session: JumpHopAgentSessionSnapshot) -> JumpHopAgentSessionProcedureResult { + JumpHopAgentSessionProcedureResult { + ok: true, + session: Some(session), + error_message: None, + } +} + +fn session_error(message: String) -> JumpHopAgentSessionProcedureResult { + JumpHopAgentSessionProcedureResult { + ok: false, + session: None, + error_message: Some(message), + } +} + +fn work_result(work: JumpHopWorkSnapshot) -> JumpHopWorkProcedureResult { + JumpHopWorkProcedureResult { + ok: true, + work: Some(work), + error_message: None, + } +} + +fn work_error(message: String) -> JumpHopWorkProcedureResult { + JumpHopWorkProcedureResult { + ok: false, + work: None, + error_message: Some(message), + } +} + +fn run_result(run: JumpHopRunSnapshot) -> JumpHopRunProcedureResult { + JumpHopRunProcedureResult { + ok: true, + run: Some(run), + error_message: None, + } +} + +fn run_error(message: String) -> JumpHopRunProcedureResult { + JumpHopRunProcedureResult { + ok: false, + run: None, + error_message: Some(message), + } +} + +fn clone_session(row: &JumpHopAgentSessionRow) -> JumpHopAgentSessionRow { + JumpHopAgentSessionRow { + session_id: row.session_id.clone(), + owner_user_id: row.owner_user_id.clone(), + seed_text: row.seed_text.clone(), + current_turn: row.current_turn, + progress_percent: row.progress_percent, + stage: row.stage.clone(), + config_json: row.config_json.clone(), + draft_json: row.draft_json.clone(), + last_assistant_reply: row.last_assistant_reply.clone(), + published_profile_id: row.published_profile_id.clone(), + created_at: row.created_at, + updated_at: row.updated_at, + } +} + +fn clone_work(row: &JumpHopWorkProfileRow) -> JumpHopWorkProfileRow { + JumpHopWorkProfileRow { + profile_id: row.profile_id.clone(), + work_id: row.work_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: row.work_title.clone(), + work_description: row.work_description.clone(), + theme_tags_json: row.theme_tags_json.clone(), + difficulty: row.difficulty.clone(), + style_preset: row.style_preset.clone(), + character_prompt: row.character_prompt.clone(), + tile_prompt: row.tile_prompt.clone(), + end_mood_prompt: row.end_mood_prompt.clone(), + character_asset_json: row.character_asset_json.clone(), + tile_atlas_asset_json: row.tile_atlas_asset_json.clone(), + tile_assets_json: row.tile_assets_json.clone(), + path_json: row.path_json.clone(), + cover_image_src: row.cover_image_src.clone(), + cover_composite: row.cover_composite.clone(), + generation_status: row.generation_status.clone(), + publication_status: row.publication_status.clone(), + play_count: row.play_count, + updated_at: row.updated_at, + published_at: row.published_at, + } +} + +fn clone_run(row: &JumpHopRuntimeRunRow) -> JumpHopRuntimeRunRow { + JumpHopRuntimeRunRow { + run_id: row.run_id.clone(), + owner_user_id: row.owner_user_id.clone(), + profile_id: row.profile_id.clone(), + status: row.status.clone(), + started_at_ms: row.started_at_ms, + finished_at_ms: row.finished_at_ms, + current_platform_index: row.current_platform_index, + score: row.score, + combo: row.combo, + snapshot_json: row.snapshot_json.clone(), + created_at: row.created_at, + updated_at: row.updated_at, + } +} diff --git a/server-rs/crates/spacetime-module/src/jump_hop/tables.rs b/server-rs/crates/spacetime-module/src/jump_hop/tables.rs new file mode 100644 index 00000000..74ef94d6 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/jump_hop/tables.rs @@ -0,0 +1,91 @@ +use crate::*; + +#[spacetimedb::table( + accessor = jump_hop_agent_session, + index(accessor = by_jump_hop_agent_session_owner_user_id, btree(columns = [owner_user_id])) +)] +pub struct JumpHopAgentSessionRow { + #[primary_key] + pub(crate) session_id: String, + pub(crate) owner_user_id: String, + pub(crate) seed_text: String, + pub(crate) current_turn: u32, + pub(crate) progress_percent: u32, + pub(crate) stage: String, + pub(crate) config_json: String, + pub(crate) draft_json: String, + pub(crate) last_assistant_reply: String, + pub(crate) published_profile_id: String, + pub(crate) created_at: Timestamp, + pub(crate) updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = jump_hop_work_profile, + index(accessor = by_jump_hop_work_owner_user_id, btree(columns = [owner_user_id])), + index(accessor = by_jump_hop_work_publication_status, btree(columns = [publication_status])) +)] +pub struct JumpHopWorkProfileRow { + #[primary_key] + pub(crate) profile_id: String, + pub(crate) work_id: String, + pub(crate) owner_user_id: String, + pub(crate) source_session_id: String, + pub(crate) author_display_name: String, + pub(crate) work_title: String, + pub(crate) work_description: String, + pub(crate) theme_tags_json: String, + pub(crate) difficulty: String, + pub(crate) style_preset: String, + pub(crate) character_prompt: String, + pub(crate) tile_prompt: String, + pub(crate) end_mood_prompt: String, + pub(crate) character_asset_json: String, + pub(crate) tile_atlas_asset_json: String, + pub(crate) tile_assets_json: String, + pub(crate) path_json: String, + pub(crate) cover_image_src: String, + pub(crate) cover_composite: String, + pub(crate) generation_status: String, + pub(crate) publication_status: String, + pub(crate) play_count: u32, + pub(crate) updated_at: Timestamp, + pub(crate) published_at: Option, +} + +#[spacetimedb::table( + accessor = jump_hop_runtime_run, + index(accessor = by_jump_hop_run_owner_user_id, btree(columns = [owner_user_id])), + index(accessor = by_jump_hop_run_profile_id, btree(columns = [profile_id])) +)] +pub struct JumpHopRuntimeRunRow { + #[primary_key] + pub(crate) run_id: String, + pub(crate) owner_user_id: String, + pub(crate) profile_id: String, + pub(crate) status: String, + pub(crate) started_at_ms: i64, + pub(crate) finished_at_ms: i64, + pub(crate) current_platform_index: u32, + pub(crate) score: u32, + pub(crate) combo: u32, + pub(crate) snapshot_json: String, + pub(crate) created_at: Timestamp, + pub(crate) updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = jump_hop_event, + index(accessor = by_jump_hop_event_profile_id, btree(columns = [profile_id])), + index(accessor = by_jump_hop_event_run_id, btree(columns = [run_id])) +)] +pub struct JumpHopEventRow { + #[primary_key] + pub(crate) event_id: String, + pub(crate) owner_user_id: String, + pub(crate) profile_id: String, + pub(crate) run_id: String, + pub(crate) event_type: String, + pub(crate) result: String, + pub(crate) occurred_at: Timestamp, +} diff --git a/server-rs/crates/spacetime-module/src/jump_hop/types.rs b/server-rs/crates/spacetime-module/src/jump_hop/types.rs new file mode 100644 index 00000000..fe514a3d --- /dev/null +++ b/server-rs/crates/spacetime-module/src/jump_hop/types.rs @@ -0,0 +1,261 @@ +use crate::*; +use serde::{Deserialize, Serialize}; + +pub const JUMP_HOP_TEMPLATE_ID: &str = "jump-hop"; +pub const JUMP_HOP_TEMPLATE_NAME: &str = "跳一跳"; +pub const JUMP_HOP_STYLE_MINIMAL_BLOCKS: &str = "minimal-blocks"; +pub const JUMP_HOP_STAGE_COLLECTING: &str = "Collecting"; +pub const JUMP_HOP_STAGE_DRAFT_COMPILED: &str = "DraftCompiled"; +pub const JUMP_HOP_STAGE_PUBLISHED: &str = "Published"; +pub const JUMP_HOP_PUBLICATION_DRAFT: &str = "Draft"; +pub const JUMP_HOP_PUBLICATION_PUBLISHED: &str = "Published"; +pub const JUMP_HOP_GENERATION_DRAFT: &str = "draft"; +pub const JUMP_HOP_GENERATION_READY: &str = "ready"; +pub const JUMP_HOP_EVENT_RUN_STARTED: &str = "run-started"; +pub const JUMP_HOP_EVENT_RUN_RESTARTED: &str = "run-restarted"; +pub const JUMP_HOP_EVENT_JUMP: &str = "jump"; + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct JumpHopAgentSessionCreateInput { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub work_title: String, + pub work_description: String, + pub theme_tags_json: Option, + pub welcome_message_text: String, + pub config_json: Option, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct JumpHopAgentSessionGetInput { + pub session_id: String, + pub owner_user_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct JumpHopDraftCompileInput { + pub session_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub author_display_name: String, + pub seed_text: String, + pub work_title: String, + pub work_description: String, + pub theme_tags_json: Option, + pub theme_text: Option, + pub difficulty: Option, + pub style_preset: Option, + pub character_prompt: Option, + pub tile_prompt: Option, + pub end_mood_prompt: Option, + pub character_asset_json: Option, + pub tile_atlas_asset_json: Option, + pub tile_assets_json: Option, + pub cover_composite: Option, + pub generation_status: Option, + pub compiled_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct JumpHopWorkUpdateInput { + pub profile_id: String, + pub owner_user_id: String, + pub work_title: String, + pub work_description: String, + pub theme_tags_json: String, + pub difficulty: Option, + pub style_preset: Option, + pub cover_image_src: Option, + pub cover_composite: Option, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct JumpHopWorkPublishInput { + pub profile_id: String, + pub owner_user_id: String, + pub published_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct JumpHopWorksListInput { + pub owner_user_id: String, + pub published_only: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct JumpHopWorkGetInput { + pub profile_id: String, + pub owner_user_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct JumpHopRunStartInput { + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub client_event_id: String, + pub started_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct JumpHopRunGetInput { + pub run_id: String, + pub owner_user_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct JumpHopRunJumpInput { + pub run_id: String, + pub owner_user_id: String, + pub charge_ms: u32, + pub client_event_id: String, + pub jumped_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct JumpHopRunRestartInput { + pub source_run_id: String, + pub next_run_id: String, + pub owner_user_id: String, + pub client_action_id: String, + pub restarted_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct JumpHopAgentSessionProcedureResult { + pub ok: bool, + pub session: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct JumpHopWorkProcedureResult { + pub ok: bool, + pub work: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct JumpHopWorksProcedureResult { + pub ok: bool, + pub items: Vec, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct JumpHopRunProcedureResult { + pub ok: bool, + pub run: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopCreatorConfigSnapshot { + pub theme_text: String, + pub difficulty: String, + pub style_preset: String, + pub character_prompt: String, + pub tile_prompt: String, + #[serde(default)] + pub end_mood_prompt: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopCharacterAssetSnapshot { + pub asset_id: String, + pub image_src: String, + pub image_object_key: String, + pub asset_object_id: String, + pub generation_provider: String, + pub prompt: String, + pub width: u32, + pub height: u32, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, SpacetimeType)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopTileAssetSnapshot { + pub tile_type: String, + pub image_src: String, + pub image_object_key: String, + pub asset_object_id: String, + pub source_atlas_cell: String, + pub visual_width: u32, + pub visual_height: u32, + pub top_surface_radius: f32, + pub landing_radius: f32, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, SpacetimeType)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopDraftSnapshot { + pub template_id: String, + pub template_name: String, + pub profile_id: Option, + pub work_title: String, + pub work_description: String, + pub theme_tags: Vec, + pub difficulty: String, + pub style_preset: String, + pub character_prompt: String, + pub tile_prompt: String, + pub end_mood_prompt: Option, + pub character_asset: Option, + pub tile_atlas_asset: Option, + pub tile_assets: Vec, + pub path: Option, + pub cover_composite: Option, + pub generation_status: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, SpacetimeType)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopAgentSessionSnapshot { + 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: JumpHopCreatorConfigSnapshot, + pub draft: Option, + pub last_assistant_reply: String, + pub published_profile_id: Option, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, SpacetimeType)] +#[serde(rename_all = "camelCase")] +pub struct JumpHopWorkSnapshot { + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: String, + pub author_display_name: String, + pub work_title: String, + pub work_description: String, + pub theme_tags: Vec, + pub difficulty: String, + pub style_preset: String, + pub character_prompt: String, + pub tile_prompt: String, + pub end_mood_prompt: Option, + pub character_asset: Option, + pub tile_atlas_asset: Option, + pub tile_assets: Vec, + pub path: module_jump_hop::JumpHopPath, + pub cover_image_src: String, + pub cover_composite: Option, + pub publication_status: String, + pub publish_ready: bool, + pub play_count: u32, + pub generation_status: String, + pub updated_at_micros: i64, + pub published_at_micros: Option, +} diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index 0d207981..b89390a4 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -8,6 +8,7 @@ pub use module_big_fish::*; pub use module_combat::*; pub use module_custom_world::*; pub use module_inventory::*; +pub use module_jump_hop::*; pub use module_npc::*; pub use module_progression::*; pub use module_quest::*; @@ -29,6 +30,7 @@ mod custom_world; mod domain_types; mod entry; mod gameplay; +mod jump_hop; mod match3d; mod migration; mod puzzle; @@ -45,6 +47,7 @@ pub use custom_world::*; pub use domain_types::*; pub use entry::*; pub use gameplay::*; +pub use jump_hop::*; pub use match3d::*; pub use migration::*; pub use runtime::*; diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 8d7e78c3..d12566c6 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -12,6 +12,9 @@ use crate::bark_battle::tables::{ bark_battle_work_stats_projection, }; use crate::big_fish::big_fish_runtime_run; +use crate::jump_hop::tables::{ + jump_hop_agent_session, jump_hop_event, jump_hop_runtime_run, jump_hop_work_profile, +}; use crate::match3d::tables::{ match3d_agent_message, match3d_agent_session, match3d_runtime_run, match3d_work_profile, }; @@ -234,6 +237,10 @@ macro_rules! migration_tables { match3d_agent_message, match3d_work_profile, match3d_runtime_run, + jump_hop_agent_session, + jump_hop_work_profile, + jump_hop_runtime_run, + jump_hop_event, square_hole_agent_session, square_hole_agent_message, square_hole_work_profile, diff --git a/src/components/common/CreativeImageInputPanel.test.tsx b/src/components/common/CreativeImageInputPanel.test.tsx index 11c175e1..a3dbce18 100644 --- a/src/components/common/CreativeImageInputPanel.test.tsx +++ b/src/components/common/CreativeImageInputPanel.test.tsx @@ -143,3 +143,135 @@ test('creative image input panel confirms before removing uploaded image', () => fireEvent.click(within(dialog).getByRole('button', { name: '移除' })); expect(onMainImageRemove).toHaveBeenCalledTimes(1); }); + +test('creative image input panel supports a preview-only main image mode', () => { + const onSubmit = vi.fn(); + + render( + {}} + onMainImageRemove={() => {}} + onAiRedrawChange={() => {}} + onPromptChange={() => {}} + onSubmit={onSubmit} + />, + ); + + expect(screen.getByAltText('UI背景预览').getAttribute('src')).toBe( + '/generated-puzzle-assets/session/ui/background.png', + ); + expect(screen.queryByLabelText('上传拼图图片')).toBeNull(); + expect(screen.queryByRole('button', { name: '选择历史图片' })).toBeNull(); + expect(screen.queryByRole('switch', { name: 'AI重绘' })).toBeNull(); + expect(screen.getByLabelText('UI背景提示词')).toHaveProperty( + 'value', + '雨夜猫街竖屏拼图UI背景', + ); + + fireEvent.click(screen.getByRole('button', { name: '生成UI背景' })); + expect(onSubmit).toHaveBeenCalledTimes(1); +}); + +test('creative image input panel does not show empty upload hint over a non-removable image', () => { + render( + {}} + onMainImageRemove={() => {}} + onAiRedrawChange={() => {}} + onPromptChange={() => {}} + onSubmit={() => {}} + />, + ); + + expect(screen.getByAltText('拼图关卡图')).toBeTruthy(); + expect(screen.queryByText('上传图片/填写画面描述')).toBeNull(); + expect(screen.queryByRole('button', { name: '移除参考图' })).toBeNull(); +}); + +test('creative image input panel can show an image without exposing AI redraw controls', () => { + render( + {}} + onMainImageRemove={() => {}} + onAiRedrawChange={() => {}} + onPromptChange={() => {}} + onSubmit={() => {}} + />, + ); + + expect(screen.getByAltText('拼图关卡图')).toBeTruthy(); + expect(screen.queryByRole('switch', { name: 'AI重绘' })).toBeNull(); + expect(screen.getByLabelText('画面描述')).toBeTruthy(); +}); diff --git a/src/components/common/CreativeImageInputPanel.tsx b/src/components/common/CreativeImageInputPanel.tsx index 90a7a431..8116edb4 100644 --- a/src/components/common/CreativeImageInputPanel.tsx +++ b/src/components/common/CreativeImageInputPanel.tsx @@ -34,13 +34,19 @@ export type CreativeImageInputPanelProps = { className?: string; disabled?: boolean; isSubmitting?: boolean; + mainImageMode?: 'edit' | 'preview'; + canRemoveMainImage?: boolean; + canToggleAiRedraw?: boolean; uploadedImageSrc: string; uploadedImageAlt: string; + uploadedImageRefreshKey?: string | number | null; + mainImageMeta?: ReactNode; mainImageInputId: string; mainImageAccept?: string; promptTextareaId: string; prompt: string; promptLabel: string; + promptAriaLabel?: string; promptRows?: number; aiRedraw: boolean; promptReferenceImages: CreativeImageInputReferenceImage[]; @@ -69,13 +75,19 @@ export function CreativeImageInputPanel({ className = '', disabled = false, isSubmitting = false, + mainImageMode = 'edit', + canRemoveMainImage = true, + canToggleAiRedraw = true, uploadedImageSrc, uploadedImageAlt, + uploadedImageRefreshKey = null, + mainImageMeta = null, mainImageInputId, mainImageAccept = DEFAULT_IMAGE_ACCEPT, promptTextareaId, prompt, promptLabel, + promptAriaLabel, promptRows = 2, aiRedraw, promptReferenceImages, @@ -100,9 +112,10 @@ export function CreativeImageInputPanel({ useState(null); const [isRemoveImageConfirmOpen, setIsRemoveImageConfirmOpen] = useState(false); - const showPrompt = !uploadedImageSrc || aiRedraw; + const showPrompt = mainImageMode === 'preview' || !uploadedImageSrc || aiRedraw; const promptReferenceUploadDisabled = disabled || promptReferenceImages.length >= promptReferenceLimit; + const canEditMainImage = mainImageMode === 'edit'; useEffect(() => { if (uploadedImageSrc) { @@ -144,33 +157,40 @@ export function CreativeImageInputPanel({
- { - const file = event.currentTarget.files?.[0] ?? null; - event.currentTarget.value = ''; - if (file) { - onMainImageFileSelect(file); - } - }} - className="sr-only" - /> - + {canEditMainImage ? ( + <> + { + const file = event.currentTarget.files?.[0] ?? null; + event.currentTarget.value = ''; + if (file) { + onMainImageFileSelect(file); + } + }} + className="sr-only" + /> + + + ) : null} {uploadedImageSrc ? ( @@ -182,7 +202,7 @@ export function CreativeImageInputPanel({ )}
- {onHistoryClick ? ( + {canEditMainImage && onHistoryClick ? ( ) : null} - {uploadedImageSrc ? ( + {canEditMainImage && uploadedImageSrc && canToggleAiRedraw ? ( ) : null} - {uploadedImageSrc ? ( + {canEditMainImage && uploadedImageSrc && canRemoveMainImage ? ( - ) : ( + ) : canEditMainImage && !uploadedImageSrc ? ( - )} + ) : null}
+ {mainImageMeta ?
{mainImageMeta}
: null}
{showPrompt ? ( @@ -267,7 +288,7 @@ export function CreativeImageInputPanel({ placeholder="" onChange={(event) => onPromptChange(event.target.value)} className="h-[6rem] min-h-[6rem] w-full resize-none rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-14 text-base leading-6 text-[var(--platform-text-strong)] outline-none placeholder:text-zinc-400 sm:h-[7.5rem] sm:min-h-[7.5rem] lg:h-[9.25rem] lg:min-h-[9.25rem]" - aria-label={promptLabel} + aria-label={promptAriaLabel ?? promptLabel} /> {imageModelPicker} {!uploadedImageSrc && onPromptReferenceFilesSelect ? ( diff --git a/src/components/jump-hop-creation/JumpHopWorkspace.tsx b/src/components/jump-hop-creation/JumpHopWorkspace.tsx new file mode 100644 index 00000000..d5b31e63 --- /dev/null +++ b/src/components/jump-hop-creation/JumpHopWorkspace.tsx @@ -0,0 +1,278 @@ +import { ArrowLeft, Loader2, Send } from 'lucide-react'; +import { useMemo, useState } from 'react'; + +import type { + JumpHopDifficulty, + JumpHopSessionResponse, + JumpHopStylePreset, + JumpHopWorkspaceCreateRequest, +} from '../../../packages/shared/src/contracts/jumpHop'; +import { jumpHopClient } from '../../services/jump-hop/jumpHopClient'; + +type JumpHopWorkspaceProps = { + isBusy?: boolean; + error?: string | null; + onBack: () => void; + onSubmitted: ( + result: JumpHopSessionResponse, + payload: JumpHopWorkspaceCreateRequest, + ) => void; +}; + +type JumpHopWorkspaceFormState = { + workTitle: string; + workDescription: string; + themeTags: string; + difficulty: JumpHopDifficulty; + stylePreset: JumpHopStylePreset; + characterPrompt: string; + tilePrompt: string; + endMoodPrompt: string; +}; + +const DEFAULT_FORM_STATE: JumpHopWorkspaceFormState = { + workTitle: '', + workDescription: '', + themeTags: '', + difficulty: 'easy', + stylePreset: 'minimal-blocks', + characterPrompt: '', + tilePrompt: '', + endMoodPrompt: '', +}; + +export function JumpHopWorkspace({ + isBusy = false, + error = null, + onBack, + onSubmitted, +}: JumpHopWorkspaceProps) { + const [formState, setFormState] = useState(DEFAULT_FORM_STATE); + const [localError, setLocalError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const canSubmit = useMemo( + () => + Boolean( + formState.workTitle.trim() && + formState.workDescription.trim() && + formState.themeTags.trim() && + formState.characterPrompt.trim() && + formState.tilePrompt.trim(), + ), + [formState], + ); + + const handleSubmit = async () => { + if (!canSubmit || isSubmitting || isBusy) { + setLocalError('请先补全输入。'); + return; + } + + setIsSubmitting(true); + setLocalError(null); + + try { + const payload: JumpHopWorkspaceCreateRequest = { + templateId: 'jump-hop', + workTitle: formState.workTitle.trim(), + workDescription: formState.workDescription.trim(), + themeTags: formState.themeTags + .split(/[,,、\s]+/) + .map((item) => item.trim()) + .filter(Boolean), + difficulty: formState.difficulty, + stylePreset: formState.stylePreset, + characterPrompt: formState.characterPrompt.trim(), + tilePrompt: formState.tilePrompt.trim(), + endMoodPrompt: formState.endMoodPrompt.trim() || null, + }; + const response = await jumpHopClient.createSession(payload); + onSubmitted(response, payload); + } catch (caughtError) { + setLocalError( + caughtError instanceof Error ? caughtError.message : '创建草稿失败。', + ); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+ +
+ +
+ +