From 3931442249ac3e5af5afe5a8bfe1f715d77a1c0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Wed, 20 May 2026 12:12:00 +0800 Subject: [PATCH] Enforce Genarrative play-type SOP and update docs Rewrite Genarrative play-type integration guidance across .codex and .hermes to define a platform-level SOP: default to form/image workbench, unify single-image asset slots (CreativeImageInputPanel), standardize series-material sheet->cut->transparent->OSS pipeline, and forbid copying legacy chat/agent workflows as the default. Add decision-log entry freezing the SOP and a pitfalls note warning against direct reuse of old play tools. Update CONTEXT.md and docs/README.md, add a new PRD file, and apply related small server-side changes (module-auth, spacetime-client mappers and runtime) to align back-end code with the new contracts and flows. --- .hermes/shared-memory/decision-log.md | 24 + .hermes/shared-memory/pitfalls.md | 24 + docs/README.md | 2 +- ...³•创作】跳一跳俯视角玩法模æ¿PRD-2026-05-19.md | 485 +++ ...„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md | 40 +- ...å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md | 6 +- ...玩法创作】平å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md | 50 +- packages/shared/src/contracts/index.ts | 1 + packages/shared/src/contracts/jumpHop.ts | 262 ++ scripts/check-spacetime-schema-guard.mjs | 6 +- server-rs/Cargo.lock | 11 + server-rs/Cargo.toml | 2 + server-rs/crates/api-server/src/app.rs | 1 + .../api-server/src/creation_entry_config.rs | 6 + .../api-server/src/generated_asset_sheets.rs | 1665 ++++++++ server-rs/crates/api-server/src/jump_hop.rs | 447 +++ server-rs/crates/api-server/src/main.rs | 2 + server-rs/crates/api-server/src/match3d.rs | 7 +- .../crates/api-server/src/match3d/draft.rs | 9 +- .../api-server/src/match3d/item_assets.rs | 1259 +----- .../crates/api-server/src/match3d/mappers.rs | 11 +- .../crates/api-server/src/match3d/tests.rs | 3438 ++++++++--------- .../src/match3d/vector_engine_gemini.rs | 1 + .../crates/api-server/src/match3d/works.rs | 14 +- server-rs/crates/api-server/src/modules.rs | 1 + .../crates/api-server/src/modules/jump_hop.rs | 76 + .../crates/api-server/src/process_metrics.rs | 29 +- server-rs/crates/module-auth/src/lib.rs | 1 - server-rs/crates/module-jump-hop/Cargo.toml | 14 + .../crates/module-jump-hop/src/application.rs | 395 ++ .../crates/module-jump-hop/src/commands.rs | 18 + .../crates/module-jump-hop/src/domain.rs | 151 + .../crates/module-jump-hop/src/errors.rs | 27 + .../crates/module-jump-hop/src/events.rs | 23 + server-rs/crates/module-jump-hop/src/lib.rs | 11 + .../crates/module-runtime/src/application.rs | 11 + .../crates/shared-contracts/src/jump_hop.rs | 401 ++ server-rs/crates/shared-contracts/src/lib.rs | 1 + server-rs/crates/spacetime-client/Cargo.toml | 1 + .../crates/spacetime-client/src/jump_hop.rs | 1061 +++++ server-rs/crates/spacetime-client/src/lib.rs | 13 + .../crates/spacetime-client/src/mapper.rs | 24 +- .../spacetime-client/src/mapper/jump_hop.rs | 344 ++ .../spacetime-client/src/mapper/runtime.rs | 10 + .../spacetime-client/src/module_bindings.rs | 245 +- .../compile_jump_hop_draft_procedure.rs | 59 + ...create_jump_hop_agent_session_procedure.rs | 59 + ..._custom_world_agent_operation_procedure.rs | 1 + .../get_jump_hop_agent_session_procedure.rs | 59 + .../get_jump_hop_run_procedure.rs | 59 + .../get_jump_hop_work_profile_procedure.rs | 59 + ...ump_hop_agent_session_create_input_type.rs | 23 + .../jump_hop_agent_session_get_input_type.rs | 16 + ...hop_agent_session_procedure_result_type.rs | 19 + .../jump_hop_agent_session_row_type.rs | 90 + .../jump_hop_agent_session_snapshot_type.rs | 29 + .../jump_hop_agent_session_table.rs | 161 + .../jump_hop_character_asset_snapshot_type.rs | 22 + .../jump_hop_creator_config_snapshot_type.rs | 20 + .../jump_hop_difficulty_type.rs | 22 + .../jump_hop_draft_compile_input_type.rs | 34 + .../jump_hop_draft_snapshot_type.rs | 35 + .../jump_hop_event_row_type.rs | 71 + .../module_bindings/jump_hop_event_table.rs | 159 + .../jump_hop_gallery_card_view_row_type.rs | 82 + .../jump_hop_gallery_card_view_table.rs | 118 + .../jump_hop_gallery_view_row_type.rs | 116 + .../jump_hop_gallery_view_table.rs | 116 + .../jump_hop_jump_procedure.rs | 59 + .../jump_hop_jump_result_kind_type.rs | 22 + .../jump_hop_last_jump_type.rs | 22 + .../src/module_bindings/jump_hop_path_type.rs | 24 + .../module_bindings/jump_hop_platform_type.rs | 25 + .../jump_hop_run_get_input_type.rs | 16 + .../jump_hop_run_jump_input_type.rs | 19 + .../jump_hop_run_procedure_result_type.rs | 19 + .../jump_hop_run_restart_input_type.rs | 19 + .../jump_hop_run_snapshot_type.rs | 29 + .../jump_hop_run_start_input_type.rs | 19 + .../jump_hop_run_status_type.rs | 20 + .../jump_hop_runtime_run_row_type.rs | 89 + .../jump_hop_runtime_run_table.rs | 161 + .../module_bindings/jump_hop_scoring_type.rs | 18 + .../jump_hop_tile_asset_snapshot_type.rs | 23 + .../jump_hop_tile_type_type.rs | 26 + .../jump_hop_work_get_input_type.rs | 16 + .../jump_hop_work_procedure_result_type.rs | 19 + .../jump_hop_work_profile_row_type.rs | 134 + .../jump_hop_work_profile_table.rs | 161 + .../jump_hop_work_publish_input_type.rs | 17 + .../jump_hop_work_snapshot_type.rs | 43 + .../jump_hop_work_update_input_type.rs | 24 + .../jump_hop_works_list_input_type.rs | 16 + .../jump_hop_works_procedure_result_type.rs | 19 + .../list_jump_hop_works_procedure.rs | 59 + .../publish_jump_hop_work_procedure.rs | 59 + .../restart_jump_hop_run_procedure.rs | 59 + .../start_jump_hop_run_procedure.rs | 59 + .../update_jump_hop_work_procedure.rs | 59 + .../crates/spacetime-client/src/runtime.rs | 13 +- server-rs/crates/spacetime-module/Cargo.toml | 1 + .../crates/spacetime-module/src/jump_hop.rs | 1165 ++++++ .../spacetime-module/src/jump_hop/tables.rs | 91 + .../spacetime-module/src/jump_hop/types.rs | 261 ++ server-rs/crates/spacetime-module/src/lib.rs | 3 + .../crates/spacetime-module/src/migration.rs | 7 + .../common/CreativeImageInputPanel.test.tsx | 132 + .../common/CreativeImageInputPanel.tsx | 83 +- .../jump-hop-creation/JumpHopWorkspace.tsx | 278 ++ .../jump-hop-result/JumpHopResultView.tsx | 468 +++ .../jump-hop-runtime/JumpHopRuntimeShell.tsx | 731 ++++ .../PlatformEntryCreationTypeModal.tsx | 5 + .../PlatformEntryFlowShellImpl.tsx | 1093 +++++- .../platform-entry/PlatformWorkDetailView.tsx | 4 + .../platform-entry/platformEntryTypes.ts | 5 + .../puzzle-result/PuzzleResultView.test.tsx | 183 +- .../puzzle-result/PuzzleResultView.tsx | 391 +- src/components/rpg-entry/RpgEntryHomeView.tsx | 13 +- .../rpg-entry/rpgEntryWorldPresentation.ts | 98 + src/services/jump-hop/jumpHopClient.ts | 274 ++ .../miniGameDraftGenerationProgress.test.ts | 49 + .../miniGameDraftGenerationProgress.ts | 154 +- src/services/publicWorkCode.ts | 18 + 123 files changed, 15514 insertions(+), 3419 deletions(-) create mode 100644 docs/prd/ã€çŽ©æ³•åˆ›ä½œã€‘è·³ä¸€è·³ä¿¯è§†è§’çŽ©æ³•æ¨¡æ¿PRD-2026-05-19.md create mode 100644 packages/shared/src/contracts/jumpHop.ts create mode 100644 server-rs/crates/api-server/src/generated_asset_sheets.rs create mode 100644 server-rs/crates/api-server/src/jump_hop.rs create mode 100644 server-rs/crates/api-server/src/modules/jump_hop.rs create mode 100644 server-rs/crates/module-jump-hop/Cargo.toml create mode 100644 server-rs/crates/module-jump-hop/src/application.rs create mode 100644 server-rs/crates/module-jump-hop/src/commands.rs create mode 100644 server-rs/crates/module-jump-hop/src/domain.rs create mode 100644 server-rs/crates/module-jump-hop/src/errors.rs create mode 100644 server-rs/crates/module-jump-hop/src/events.rs create mode 100644 server-rs/crates/module-jump-hop/src/lib.rs create mode 100644 server-rs/crates/shared-contracts/src/jump_hop.rs create mode 100644 server-rs/crates/spacetime-client/src/jump_hop.rs create mode 100644 server-rs/crates/spacetime-client/src/mapper/jump_hop.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/compile_jump_hop_draft_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/create_jump_hop_agent_session_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_jump_hop_agent_session_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_jump_hop_run_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_jump_hop_work_profile_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_agent_session_create_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_agent_session_get_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_agent_session_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_agent_session_row_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_agent_session_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_agent_session_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_character_asset_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_creator_config_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_difficulty_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_draft_compile_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_draft_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_event_row_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_event_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_gallery_card_view_row_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_gallery_card_view_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_gallery_view_row_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_gallery_view_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_jump_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_jump_result_kind_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_last_jump_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_path_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_platform_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_get_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_jump_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_restart_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_start_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_run_status_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_runtime_run_row_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_runtime_run_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_scoring_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_asset_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_tile_type_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_get_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_profile_row_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_profile_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_publish_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_work_update_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_works_list_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/jump_hop_works_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/list_jump_hop_works_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/publish_jump_hop_work_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/restart_jump_hop_run_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/start_jump_hop_run_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/update_jump_hop_work_procedure.rs create mode 100644 server-rs/crates/spacetime-module/src/jump_hop.rs create mode 100644 server-rs/crates/spacetime-module/src/jump_hop/tables.rs create mode 100644 server-rs/crates/spacetime-module/src/jump_hop/types.rs create mode 100644 src/components/jump-hop-creation/JumpHopWorkspace.tsx create mode 100644 src/components/jump-hop-result/JumpHopResultView.tsx create mode 100644 src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx create mode 100644 src/services/jump-hop/jumpHopClient.ts 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 ( +
+
+ +
+ +
+ +