From 6cb553858ebd113ea3bbb7c21d57eb49c2d39c15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=94=E9=A6=99=E4=B8=B8=E5=AD=90?= <15518898337@163.com> Date: Thu, 30 Apr 2026 18:25:14 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=8A=93=E5=A4=A7?= =?UTF-8?q?=E9=B9=85prd=EF=BC=8C=E5=8D=B3=E6=97=B6=E8=A1=A8=E7=8E=B0?= =?UTF-8?q?=E7=94=B1=E5=90=8E=E7=AB=AF=E7=A7=BB=E8=87=B3=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md | 75 +++++++++++-------- 1 file changed, 45 insertions(+), 30 deletions(-) diff --git a/docs/prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md b/docs/prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md index eec8b96d..79998d53 100644 --- a/docs/prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md +++ b/docs/prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md @@ -72,7 +72,7 @@ 1. 不复用 RPG 的世界、角色、章节、剧情推进结构。 2. 不复用拼图的网格切图、交换、合并块和下一关推荐算法。 3. 不复用大鱼吃小鱼的实时吞噬、实体等级和摇杆移动规则。 -4. 不把 Match3D 运行规则写成前端本地真相源。 +4. 不把 Match3D 运行规则写成前端本地真相源,但局内即时反馈效果由前端负责呈现。 5. 不使用 `server-node` 或 PostgreSQL 作为新增玩法后端。 ## 3.3 独立玩法域要求 @@ -110,7 +110,7 @@ Match3D 必须形成独立玩法域,后续技术方案至少需要覆盖: 13. 清空圆形空间中全部物品即胜利。 14. 倒计时结束或备选栏满即失败。 15. 胜利 / 失败后展示结算界面。 -16. 点击判定、入槽、消除、失败、胜利必须由后端裁决。 +16. 点击、入槽、消除、失败、胜利的即时反馈效果由前端先行呈现,后端负责权威确认、状态落库和成绩可信性。 --- @@ -128,7 +128,7 @@ Match3D 必须形成独立玩法域,后续技术方案至少需要覆盖: 8. 不做真实物理碰撞结算。 9. 不做必须试玩通关才能发布的门槛。 10. 不把玩法规则说明长文默认写入 UI 面板。 -11. 不在前端即时完成规则裁决。 +11. 不把前端即时反馈当作最终规则真相。 12. 不使用 `server-node` 或 PostgreSQL 新增实现。 --- @@ -292,31 +292,36 @@ totalItemCount = clearCount * 3 圆形空间里的物品可以重叠、遮挡、堆叠。 -首版使用 2D 逻辑实现遮挡和点击判定: +首版使用 2D 逻辑实现遮挡和点击反馈: 1. 被完全遮挡的物品不允许点击。 2. 如果物品有局部露出,且露出区域可被点击选中,则允许点击。 -3. 具体露出区域判定使用 2D 逻辑的最优方案,不做真实 3D 遮挡。 +3. 前端基于后端下发的物品层级、位置、半径和可点击快照,执行即时命中检测与选中反馈。 +4. 后端收到点击意图后做权威确认;如果确认失败,前端必须回滚本次即时反馈。 +5. 具体露出区域判定使用 2D 逻辑的最优方案,不做真实 3D 遮挡。 ## 8.8 点击入槽 -玩家点击通过后,后端裁决该物品可选中。 - -前端播放飞行动画,把物品放入下方备选栏。 +玩家点击可见物品后,前端立即播放按压、选中和飞行动画,把物品表现为飞入下方备选栏。 飞行动画过程中,物品不再与其他物品产生碰撞。 +前端播放即时反馈的同时,必须向后端提交点击意图。后端确认后固化入槽结果;后端拒绝时,前端恢复物品位置和备选栏表现。 + ## 8.9 备选栏 下方备选栏固定为 `7` 个格子。 -1. 每次成功点击后,物品进入备选栏。 -2. 备选栏中每出现 `3` 个相同物品 id,自动消除并腾出格子。 -3. 如果备选栏满且无法消除,则判定关卡失败。 +1. 每次点击进入即时反馈流程后,前端先把物品表现为进入备选栏。 +2. 备选栏中每出现 `3` 个相同物品 id,前端立即播放自动消除效果并腾出格子。 +3. 后端确认后固化真实备选栏和消除结果;若后端返回状态不一致,前端必须以最新后端快照校正。 +4. 如果备选栏满且无法消除,前端可以立即展示失败过渡,最终失败状态以后端确认为准。 ## 8.10 胜利 -圆形空间内全部物品被消除后,播放胜利动画并展示胜利界面。 +圆形空间内全部物品被消除后,前端立即播放胜利动画。 + +正式胜利界面、使用时间和成绩记录以后端确认的运行态为准。 胜利结算页至少展示: @@ -332,6 +337,8 @@ totalItemCount = clearCount * 3 1. 倒计时结束。 2. 备选栏满。 +倒计时归零或备选栏满时,前端立即展示失败过渡;正式失败原因和完成进度以后端确认的运行态为准。 + 失败结算页至少展示: 1. 失败原因。 @@ -378,33 +385,38 @@ totalItemCount = clearCount * 3 1. 创建玩法草稿。 2. 编译运行时初始局面。 3. 生成物品序列与布局。 -4. 判断物品是否可点击。 -5. 处理点击入槽。 -6. 判断 `3` 个相同物品 id 消除。 -7. 判断备选栏满失败。 -8. 判断倒计时结束失败。 -9. 判断清空空间胜利。 -10. 记录成绩所需的基础数据。 +4. 下发前端即时反馈所需的物品位置、层级、半径、可点击快照和版本号。 +5. 权威确认玩家点击意图是否合法。 +6. 权威确认入槽结果。 +7. 权威确认 `3` 个相同物品 id 消除。 +8. 权威确认备选栏满失败。 +9. 权威确认倒计时结束失败。 +10. 权威确认清空空间胜利。 +11. 记录成绩所需的基础数据。 ## 10.2 前端职责 -前端只负责: +前端负责所有游戏过程中需要即时呈现的反馈效果: 1. 展示 Agent 创作界面。 2. 展示结果页和基础编辑表单。 3. 上传参考图片。 4. 展示运行态场景、物品、倒计时和备选栏。 -5. 发送玩家点击意图。 -6. 播放点击、飞入、消除、胜利和失败动画。 -7. 展示结算界面。 +5. 基于最新后端快照执行 2D 命中检测、悬停、按压和选中反馈。 +6. 发送玩家点击意图。 +7. 在等待后端确认期间,先行播放飞入、入槽、三消、腾格、胜利和失败过渡效果。 +8. 收到后端确认后,把本地表现校正到权威快照。 +9. 展示结算界面。 -前端不得自行完成规则裁决。 +前端可以做即时表现预判,但不得把预判结果作为最终规则真相或成绩来源。 ## 10.3 防作弊要求 -首版即按正式版本搭建规则裁决链路。 +首版即按正式版本搭建“前端即时反馈 + 后端权威确认”的链路。 -前端不可信任本地点击、消除、胜利或成绩结果;所有关键状态必须由后端裁决后下发。 +前端不可信任本地点击、消除、胜利或成绩结果;所有关键状态必须以后端确认后的快照为准。 + +为了保证手感,前端可以先行展示操作反馈;为了防作弊,发布成绩、结算状态、消除计数和运行态持久化必须以后端确认为准。 --- @@ -484,6 +496,8 @@ interface Match3DItemSnapshot { } ``` +`Flying` 可以作为前端表现态使用,不要求后端把飞行动画过程逐帧落库。后端只需要确认物品是否从 `InBoard` 进入 `InTray` 或 `Cleared`。 + ## 11.5 备选栏快照 ```ts @@ -672,7 +686,7 @@ Agent 每轮优先追问最影响 demo 生成的一个问题。 13. 倒计时结束或备选栏满后失败。 14. 胜利结算展示使用时间。 15. 失败结算展示完成进度和重新开始按钮。 -16. 关键规则由后端裁决,前端不本地判定胜负。 +16. 局内即时反馈由前端先行呈现,关键状态以后端确认快照校正。 17. 相关中文文档通过编码检查。 --- @@ -696,14 +710,15 @@ Agent 每轮优先追问最影响 demo 生成的一个问题。 1. 新增 `module-match3d` 规则。 2. 新增 SpacetimeDB 运行态表和 procedure。 -3. 实现开始、点击、消除、失败、胜利。 +3. 实现开始、点击确认、消除确认、失败确认、胜利确认。 ## 阶段 D:前端运行态 1. 展示圆形空间和 2D 物品。 2. 展示 `7` 格备选栏。 3. 接入点击接口和后端快照。 -4. 补飞入、消除、胜负动画。 +4. 补点击命中、飞入、入槽、消除、腾格、胜负过渡等即时反馈。 +5. 补后端确认失败时的前端回滚和快照校正。 ## 阶段 E:分发与成绩预留 @@ -715,4 +730,4 @@ Agent 每轮优先追问最影响 demo 生成的一个问题。 ## 17. 一句话结论 -Match3D 首版不是临时前端 demo,而是以“抓大鹅”模板为外壳、以后端规则裁决为真相源、以独立玩法域为工程边界的单局经典消除玩法链路;首轮先跑通题材创作、结果页、试玩、发布和单局清空胜负闭环。 +Match3D 首版不是临时前端 demo,而是以“抓大鹅”模板为外壳、以前端即时反馈保证手感、以后端权威确认保证规则可信、以独立玩法域为工程边界的单局经典消除玩法链路;首轮先跑通题材创作、结果页、试玩、发布和单局清空胜负闭环。 From 22f6e6f4e71eeef0ae01fcaf284d6308c7017186 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=94=E9=A6=99=E4=B8=B8=E5=AD=90?= <15518898337@163.com> Date: Thu, 30 Apr 2026 20:07:46 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E6=8A=93=E5=A4=A7=E9=B9=85A0=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...NTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md | 827 ++++++++++++++++++ docs/technical/README.md | 1 + 2 files changed, 828 insertions(+) create mode 100644 docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md diff --git a/docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md b/docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md new file mode 100644 index 00000000..18d7de4e --- /dev/null +++ b/docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md @@ -0,0 +1,827 @@ +# 抓大鹅 Match3D 创作与运行态最小落地技术方案 2026-04-30 + +## 1. 文档目的 + +本文件承接 PRD《AI 原生抓大鹅 Match3D 玩法创作工具与玩法系统 PRD》,冻结首版 demo 的最小可开发方案。 + +本轮目标不是先做一个纯前端临时小游戏,而是在当前平台内新增独立 `match3d` 玩法域,跑通下面这条最小主链: + +1. 平台创作入口选择“抓大鹅”。 +2. Agent 对话确认题材、需要消除次数和难度。 +3. 编译 Match3D 草稿。 +4. 进入结果页编辑游戏名称、标签和封面图。 +5. 发布前试玩,可随时停止并返回修改。 +6. 发布作品。 +7. 玩家进入单局运行态。 +8. 前端即时呈现点击、飞入、入槽、三消、腾格、胜负过渡。 +9. 后端权威确认点击、入槽、消除、失败、胜利和成绩。 + +本文是后续并行开发的工程合同。若实现过程中发现字段、路由、表结构或前后端职责需要变化,必须先更新本文,再进入对应编码分支。 + +--- + +## 2. 本轮明确不做 + +1. 不做多关卡链。 +2. 不做排行榜展示。 +3. 不做道具逻辑,只预留字段和扩展点。 +4. 不做真实 3D 模型和真实 3D 物理遮挡。 +5. 不做洗牌、重置、旋转、放大等局内操作。 +6. 不做必须试玩通关才能发布。 +7. 不做前端本地最终规则真相。 +8. 不接入 `server-node` 或 PostgreSQL。 +9. 不把 Match3D 挂到 RPG、拼图或大鱼吃小鱼旧命名下。 + +--- + +## 3. 分层边界 + +## 3.1 前端 + +前端继续使用当前 `React + TypeScript + Vite` 平台壳层,负责所有即时呈现的局内反馈: + +1. 创作入口、Agent 工作区、结果页、试玩和运行态 UI。 +2. 参考图片上传入口。 +3. 运行态圆形空间、2D 物品、倒计时和 `7` 格备选栏展示。 +4. 基于后端快照做 2D 命中检测、悬停、按压、选中反馈。 +5. 在等待后端确认期间,先行播放飞入、入槽、三消、腾格、胜利和失败过渡。 +6. 收到后端确认后,以权威快照校正本地表现。 +7. 后端拒绝或版本冲突时,回滚本次即时反馈。 + +前端禁止: + +1. 把本地即时反馈作为最终规则真相。 +2. 本地生成可提交成绩。 +3. 本地伪造胜利、失败、消除计数或运行态持久化结果。 +4. 在 UI 中默认展示长篇玩法规则说明。 + +## 3.2 api-server + +`server-rs/crates/api-server` 负责 Match3D 对外 HTTP facade: + +1. 鉴权、请求上下文、错误 envelope。 +2. 创作 Agent 的 LLM turn 编排。 +3. 参考图片上传复用现有资产/OSS 能力。 +4. 调用 `spacetime-client` 读写 Match3D 会话、作品和运行态。 +5. 对前端返回稳定 HTTP DTO,不泄露 SpacetimeDB 内部表结构。 + +## 3.3 SpacetimeDB + +`server-rs/crates/spacetime-module` 负责 Match3D 真相态: + +1. 存储 Agent session / message。 +2. 存储作品 profile。 +3. 存储运行态 run snapshot。 +4. 通过 procedure 同步返回会话、作品和运行态快照。 +5. 在 reducer/procedure 内保持确定性,不做网络、文件系统或外部模型调用。 + +## 3.4 纯领域 crate + +新增: + +```text +server-rs/crates/module-match3d +``` + +职责: + +1. 创作配置校验。 +2. 物品类型规划。 +3. 初始布局生成输入/输出模型。 +4. 2D 遮挡与可点击快照计算。 +5. 点击确认。 +6. 入槽与三消确认。 +7. 胜负确认。 +8. 成绩基础数据计算。 + +`module-match3d` 不直接依赖 Axum、不访问 OSS、不调用 LLM、不读写 SpacetimeDB 表。 + +--- + +## 4. 共享契约 + +## 4.1 TypeScript shared contracts + +新增: + +```text +packages/shared/src/contracts/match3dAgent.ts +packages/shared/src/contracts/match3dWorks.ts +packages/shared/src/contracts/match3dRuntime.ts +``` + +### `match3dAgent.ts` + +承载: + +1. `Match3DAgentSession` +2. `Match3DAgentMessage` +3. `Match3DCreatorConfig` +4. `Match3DCompileDraftRequest` +5. `Match3DCompileDraftResult` + +### `match3dWorks.ts` + +承载: + +1. `Match3DWorkProfile` +2. `Match3DWorkSummary` +3. `Match3DWorkUpdateRequest` +4. `Match3DPublishRequest` +5. `Match3DPublishResult` + +### `match3dRuntime.ts` + +承载: + +1. `Match3DRunSnapshot` +2. `Match3DItemSnapshot` +3. `Match3DTraySlot` +4. `Match3DStartRunRequest` +5. `Match3DClickItemRequest` +6. `Match3DClickItemResult` +7. `Match3DStopRunRequest` +8. `Match3DRestartRunRequest` + +## 4.2 Rust shared contracts + +新增: + +```text +server-rs/crates/shared-contracts/src/match3d_agent.rs +server-rs/crates/shared-contracts/src/match3d_works.rs +server-rs/crates/shared-contracts/src/match3d_runtime.rs +``` + +并在 `server-rs/crates/shared-contracts/src/lib.rs` 导出。 + +Rust DTO 只承载 HTTP contract 和跨 crate 稳定模型,不直接暴露 `module-match3d` 内部结构。 + +## 4.3 命名约束 + +1. 对外展示:抓大鹅。 +2. 工程域:`match3d`。 +3. TypeScript 类型前缀:`Match3D`。 +4. Rust 类型前缀:`Match3D`。 +5. HTTP path:`/api/creation/match3d/*` 与 `/api/runtime/match3d/*`。 +6. SpacetimeDB 表与 procedure 前缀:`match3d_`。 + +--- + +## 5. SpacetimeDB 表 + +首版保持最小闭环,复杂结构统一使用结构化字段 + `snapshot_json` / `draft_json`,避免过早拆出多张高耦合子表。 + +新增表属于安全 schema 演进;后续如果改字段,必须遵守 `SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`,不能直接删除、重排或改名已有列。表结构变更后必须同步对齐 `migration.rs`。 + +## 5.1 `match3d_agent_session` + +作用:保存 Match3D 创作 Agent 会话、配置草稿和发布指针。 + +字段: + +1. `session_id: String`,主键。 +2. `owner_user_id: String`,索引。 +3. `seed_text: String`,用户初始输入或自动配置摘要。 +4. `current_turn: u32`。 +5. `progress_percent: u32`。 +6. `stage: String`,建议值:`Collecting`、`ReadyToCompile`、`DraftCompiled`、`Published`。 +7. `config_json: String`,序列化 `Match3DCreatorConfig`。 +8. `draft_json: String`,序列化草稿结果。 +9. `last_assistant_reply: String`。 +10. `published_profile_id: String`,未发布为空字符串。 +11. `created_at: i64`。 +12. `updated_at: i64`。 + +## 5.2 `match3d_agent_message` + +作用:保存 Match3D 创作 Agent 消息流水。 + +字段: + +1. `message_id: String`,主键。 +2. `session_id: String`,索引。 +3. `role: String`,建议值:`user`、`assistant`、`system`。 +4. `kind: String`,建议值:`text`、`action`、`error`。 +5. `text: String`。 +6. `created_at: i64`。 + +## 5.3 `match3d_work_profile` + +作用:保存 Match3D 作品主表和发布状态。 + +字段: + +1. `profile_id: String`,主键。 +2. `owner_user_id: String`,索引。 +3. `source_session_id: String`。 +4. `author_display_name: String`。 +5. `game_name: String`。 +6. `theme_text: String`。 +7. `summary_text: String`。 +8. `tags_json: String`。 +9. `cover_image_src: String`。 +10. `cover_asset_id: String`。 +11. `clear_count: u32`。 +12. `difficulty: u32`。 +13. `config_json: String`。 +14. `publication_status: String`,建议值:`Draft`、`Published`。 +15. `play_count: u32`。 +16. `updated_at: i64`。 +17. `published_at: i64`,未发布为 `0`。 + +## 5.4 `match3d_runtime_run` + +作用:保存 Match3D 单局运行态快照和成绩基础数据。 + +字段: + +1. `run_id: String`,主键。 +2. `owner_user_id: String`,索引。 +3. `profile_id: String`,索引。 +4. `status: String`,建议值:`Running`、`Won`、`Failed`、`Stopped`。 +5. `snapshot_version: u32`。 +6. `started_at_ms: i64`。 +7. `duration_limit_ms: i64`,首版固定 `600000`。 +8. `finished_at_ms: i64`,未结束为 `0`。 +9. `elapsed_ms: i64`。 +10. `clear_count: u32`。 +11. `total_item_count: u32`。 +12. `cleared_item_count: u32`。 +13. `failure_reason: String`,建议值为空、`TimeUp`、`TrayFull`。 +14. `snapshot_json: String`,序列化 `Match3DRunSnapshot`。 +15. `created_at: i64`。 +16. `updated_at: i64`。 + +## 5.5 `match3d_play_record` + +首版可选,若本轮不做排行榜,可先不建表,只在 `match3d_runtime_run` 保留成绩字段。 + +若实现成绩历史,字段建议: + +1. `record_id: String`,主键。 +2. `profile_id: String`,索引。 +3. `owner_user_id: String`,索引。 +4. `run_id: String`。 +5. `status: String`。 +6. `elapsed_ms: i64`。 +7. `cleared_item_count: u32`。 +8. `total_item_count: u32`。 +9. `created_at: i64`。 + +--- + +## 6. SpacetimeDB procedure + +本轮全部使用 procedure 同步返回快照,避免 `api-server` 在写入后再读 private table。 + +## 6.1 创作链 + +1. `create_match3d_agent_session(input)` + 创建会话,写入初始配置或空配置,返回 session snapshot。 + +2. `get_match3d_agent_session(input)` + 获取会话、消息和当前 draft。 + +3. `submit_match3d_agent_message(input)` + 只写 user message,不调用 LLM,不生成 assistant 回复。 + +4. `finalize_match3d_agent_message_turn(input)` + 由 `api-server` LLM turn 完成后写入 assistant message、配置状态、进度和 `last_assistant_reply`。 + +5. `compile_match3d_draft(input)` + 校验题材、需要消除次数、难度,生成草稿和作品 draft profile。 + +## 6.2 作品链 + +1. `update_match3d_work(input)` + 更新游戏名称、标签、封面、题材、需要消除次数和难度。 + +2. `publish_match3d_work(input)` + 校验基础信息完整后发布作品,不要求试玩通关。 + +3. `list_match3d_works(input)` + 查询当前用户作品。 + +4. `get_match3d_work_detail(input)` + 查询作品详情,支持结果页恢复和作品详情页。 + +5. `delete_match3d_work(input)` + 可后置;若接入创作中心删除,需要与其他玩法卡片删除语义一致。 + +## 6.3 运行态链 + +1. `start_match3d_run(input)` + 基于作品配置生成单局快照,返回 `Match3DRunSnapshot`。 + +2. `get_match3d_run(input)` + 返回当前权威运行态快照。 + +3. `click_match3d_item(input)` + 根据 `run_id / item_instance_id / client_snapshot_version` 权威确认点击、入槽、三消、失败或胜利,返回新快照和确认结果。 + +4. `stop_match3d_run(input)` + 把运行态标记为 `Stopped`,供试玩中止和返回结果页使用。 + +5. `restart_match3d_run(input)` + 复用同一作品配置创建新 run,返回新快照。 + +6. `finish_match3d_time_up(input)` + 可选。若倒计时由前端触发,前端在倒计时归零时调用该 procedure,后端确认 `TimeUp`。也可以由 `click_match3d_item` 或 `get_match3d_run` 懒确认超时。 + +## 6.4 procedure 输入输出约束 + +1. 所有 mutation 输入必须带 `owner_user_id` 或由 `api-server` 注入用户上下文,SpacetimeDB 内部仍需以可信身份或 owner 字段校验归属。 +2. 运行态 mutation 必须携带 `client_snapshot_version`。 +3. 若版本不匹配,返回 `VersionConflict`,并携带最新快照。 +4. procedure 返回字符串化 JSON 时,`spacetime-client` 必须负责反序列化和错误归一化。 + +--- + +## 7. 运行态确认协议 + +Match3D 首版采用“前端即时反馈 + 后端权威确认”。 + +## 7.1 点击流程 + +```text +玩家点击物品 +-> 前端基于最新快照做 2D 命中检测 +-> 前端立即播放按压/选中/飞入表现 +-> 前端调用 click_match3d_item +-> 后端确认点击是否合法 +-> 后端返回新快照与确认结果 +-> 前端按确认结果固化或回滚表现 +``` + +## 7.2 点击请求 + +```ts +interface Match3DClickItemRequest { + runId: string; + itemInstanceId: string; + clientSnapshotVersion: number; + clientEventId: string; + clickedAtMs: number; +} +``` + +字段说明: + +1. `clientSnapshotVersion` 用于发现前端基于旧快照操作。 +2. `clientEventId` 用于前端去重和日志定位。 +3. `clickedAtMs` 只用于观测,不作为成绩可信时间源。 + +## 7.3 点击结果 + +```ts +type Match3DClickConfirmStatus = + | 'Accepted' + | 'RejectedNotClickable' + | 'RejectedAlreadyMoved' + | 'RejectedTrayFull' + | 'VersionConflict' + | 'RunFinished'; + +interface Match3DClickItemResult { + status: Match3DClickConfirmStatus; + run: Match3DRunSnapshot; + acceptedItemInstanceId?: string; + clearedItemInstanceIds: string[]; + failureReason?: 'TimeUp' | 'TrayFull'; +} +``` + +## 7.4 前端回滚规则 + +1. `Accepted`:固化飞入、入槽、消除或胜负表现。 +2. `RejectedNotClickable`:被点物品回到原位,备选栏恢复。 +3. `RejectedAlreadyMoved`:直接应用后端最新快照。 +4. `RejectedTrayFull`:应用后端失败快照。 +5. `VersionConflict`:取消当前局部动画,应用最新快照,允许用户继续操作。 +6. `RunFinished`:应用后端胜负快照,进入结算。 + +## 7.5 快照版本 + +1. 每次后端接受会改变运行态的操作,`snapshot_version` 必须递增。 +2. 前端所有即时反馈都基于某个明确版本。 +3. 前端同时只能有一个未确认的点击操作;首版不做多点击并发队列。 +4. 如果动画期间用户再次点击,前端应忽略或排队到当前确认完成后再处理;首版建议忽略。 + +--- + +## 8. 运行态快照 + +## 8.1 `Match3DRunSnapshot` + +```ts +interface Match3DRunSnapshot { + runId: string; + profileId: string; + status: 'Running' | 'Won' | 'Failed' | 'Stopped'; + snapshotVersion: number; + startedAtMs: number; + durationLimitMs: number; + serverNowMs: number; + remainingMs: number; + clearCount: number; + totalItemCount: number; + clearedItemCount: number; + traySlots: Match3DTraySlot[]; + items: Match3DItemSnapshot[]; + failureReason?: 'TimeUp' | 'TrayFull'; +} +``` + +说明: + +1. `serverNowMs` 用于前端校准倒计时。 +2. `remainingMs` 由后端按 `durationLimitMs` 和服务端时间计算。 +3. 前端可以本地递减倒计时,但归零失败必须调用后端确认或等待下一次后端确认。 + +## 8.2 `Match3DItemSnapshot` + +```ts +interface Match3DItemSnapshot { + itemInstanceId: string; + itemTypeId: string; + visualKey: string; + x: number; + y: number; + radius: number; + layer: number; + state: 'InBoard' | 'Flying' | 'InTray' | 'Cleared'; + clickable: boolean; +} +``` + +说明: + +1. `Flying` 可以作为前端表现态使用,不要求后端逐帧落库。 +2. 后端主要确认 `InBoard -> InTray -> Cleared` 的权威状态变化。 +3. `clickable` 是后端计算给前端的可点击快照,前端命中检测必须尊重它。 + +## 8.3 `Match3DTraySlot` + +```ts +interface Match3DTraySlot { + slotIndex: number; + itemInstanceId?: string; + itemTypeId?: string; + visualKey?: string; +} +``` + +## 8.4 2D 遮挡口径 + +首版不做真实物理遮挡。 + +建议后端按以下输入计算 `clickable`: + +1. 物品圆形或近似圆形碰撞范围。 +2. `layer` 越大越靠上。 +3. 被更高层物品覆盖到低于可点击阈值时,标记为不可点击。 +4. 阈值首版作为领域常量,后续体验后再参数化。 + +前端基于 `clickable` 和自身命中检测呈现即时反馈;后端仍在点击确认时再次校验。 + +--- + +## 9. 领域规则冻结 + +## 9.1 创作配置 + +```ts +interface Match3DCreatorConfig { + themeText: string; + referenceImageSrc?: string; + clearCount: number; + difficulty: number; +} +``` + +规则: + +1. `themeText` 必填。 +2. `clearCount` 必须为正整数。 +3. `difficulty` 范围 `1~10`。 +4. `referenceImageSrc` 首版只支持图片,不支持视频。 + +## 9.2 物品数量 + +```text +totalItemCount = clearCount * 3 +``` + +每种 `itemTypeId` 的数量必须是 `3` 的倍数。 + +## 9.3 demo 视觉素材 + +首版使用 `10` 种颜色形状组合素材。 + +1. `visualKey` 固定为内置素材 key。 +2. 题材主题先进入作品配置和 Agent 文案,不强制生成题材素材。 +3. 后续接入真实题材素材前,必须另补资产生成方案。 + +## 9.4 难度 + +首版 `difficulty` 只作为布局和生成算法参数。 + +后端需要保留参数入口,但难度公式先保持简洁: + +1. 难度越高,物品尺寸可整体略小。 +2. 难度越高,堆叠层级可略深。 +3. 难度越高,首屏可直接三消的可见组合可略少。 + +具体数值不在 A0 冻死,由 B1 领域 crate 分支给出首版常量并通过测试覆盖。 + +--- + +## 10. api-server HTTP facade + +## 10.1 创作链 + +```text +POST /api/creation/match3d/sessions +GET /api/creation/match3d/sessions/:sessionId +POST /api/creation/match3d/sessions/:sessionId/messages +POST /api/creation/match3d/sessions/:sessionId/messages/stream +POST /api/creation/match3d/sessions/:sessionId/compile +``` + +说明: + +1. 同步消息接口用于普通提交。 +2. 流式接口复用现有 Agent SSE 基建。 +3. `messages` 只写 user message,LLM 推理由 `api-server` 完成后 finalize 到 SpacetimeDB。 +4. `compile` 不生成额外素材,只生成 Match3D 草稿和作品 draft。 + +## 10.2 作品链 + +```text +PATCH /api/creation/match3d/works/:profileId +POST /api/creation/match3d/works/:profileId/publish +GET /api/creation/match3d/works +GET /api/creation/match3d/works/:profileId +``` + +首版发布不要求试玩通关。 + +## 10.3 运行态链 + +```text +POST /api/runtime/match3d/works/:profileId/runs +GET /api/runtime/match3d/runs/:runId +POST /api/runtime/match3d/runs/:runId/click +POST /api/runtime/match3d/runs/:runId/stop +POST /api/runtime/match3d/runs/:runId/restart +POST /api/runtime/match3d/runs/:runId/time-up +``` + +`time-up` 可后置;若不单独实现,`get` 或下一次 `click` 必须能懒确认超时失败。 + +## 10.4 错误语义 + +HTTP 层使用现有 API envelope。 + +建议错误码: + +1. `MATCH3D_SESSION_NOT_FOUND` +2. `MATCH3D_WORK_NOT_FOUND` +3. `MATCH3D_RUN_NOT_FOUND` +4. `MATCH3D_INVALID_CONFIG` +5. `MATCH3D_PUBLISH_BLOCKED` +6. `MATCH3D_RUN_VERSION_CONFLICT` +7. `MATCH3D_RUN_ALREADY_FINISHED` + +--- + +## 11. 前端落点 + +## 11.1 contracts 与 service + +新增: + +```text +src/services/match3d-creation/ +src/services/match3d-works/ +src/services/match3d-runtime/ +``` + +分别负责 Agent/草稿、作品/发布、运行态请求。 + +## 11.2 组件 + +新增: + +```text +src/components/match3d-creation/ +src/components/match3d-result/ +src/components/match3d-runtime/ +``` + +## 11.3 平台入口 + +需要接入: + +1. `src/components/platform-entry/platformEntryCreationTypes.ts` +2. `src/components/platform-entry/PlatformEntryCreationTypeModal.tsx` +3. `src/components/platform-entry/usePlatformCreationAgentFlowController.ts` + +入口展示: + +1. 名称:`抓大鹅` +2. 子标题:`经典消除玩法` + +## 11.4 运行态 UI + +首版运行态必须移动端优先: + +1. 圆形空间占据主要区域。 +2. 备选栏固定 `7` 格。 +3. 倒计时清晰但不遮挡物品。 +4. 物品点击区域稳定,不因动画造成布局跳动。 +5. 胜利/失败结算使用独立面板,不在当前面板下方展开。 + +## 11.5 本地 mock 口径 + +F3 运行态即时反馈分支可以先用本地 mock snapshot 开发,但必须满足: + +1. mock 类型来自 `packages/shared/src/contracts/match3dRuntime.ts`。 +2. mock 字段不得脱离 A0 文档。 +3. 接入真实 API 时删除或降级为测试 fixture。 + +--- + +## 12. 并行开发包 + +## 12.1 第二波并行 + +### B1 + B2:领域 crate 与 shared contracts + +写入范围: + +1. `server-rs/crates/module-match3d/` +2. `server-rs/Cargo.toml` +3. `server-rs/crates/shared-contracts/src/match3d_*.rs` +4. `packages/shared/src/contracts/match3d*.ts` + +交付: + +1. 领域规则单测。 +2. DTO 编译通过。 +3. 不接 SpacetimeDB。 + +### B3:SpacetimeDB 表与 procedure + +写入范围: + +1. `server-rs/crates/spacetime-module/src/match3d/` +2. `server-rs/crates/spacetime-module/src/lib.rs` +3. `server-rs/crates/spacetime-module/src/migration.rs` +4. 生成后的 bindings 由后续 B4 处理。 + +交付: + +1. 表和 procedure 定义。 +2. 与 `module-match3d` 规则接线。 +3. `spacetime build` 或仓库现有等价脚本通过。 + +### F1:创作入口与 Agent UI + +写入范围: + +1. `src/components/platform-entry/` +2. `src/components/match3d-creation/` +3. `src/services/match3d-creation/` + +交付: + +1. 平台入口可见。 +2. Agent 工作区能收集题材、需要消除次数和难度。 +3. 可用 mock client,等待 B5 接口。 + +### F3:运行态即时反馈 UI + +写入范围: + +1. `src/components/match3d-runtime/` +2. `src/services/match3d-runtime/` + +交付: + +1. 圆形空间、2D 物品、`7` 格备选栏。 +2. 点击命中、飞入、入槽、三消、腾格、胜负过渡。 +3. 后端确认失败时的回滚和快照校正逻辑。 +4. 先用 mock snapshot。 + +## 12.2 第三波并行 + +### B4 + B5:spacetime-client 与 api-server facade + +写入范围: + +1. `server-rs/crates/spacetime-client/src/match3d.rs` +2. `server-rs/crates/spacetime-client/src/lib.rs` +3. `server-rs/crates/api-server/src/match3d.rs` +4. `server-rs/crates/api-server/src/app.rs` +5. `server-rs/crates/api-server/src/main.rs` 如需注册模块 + +交付: + +1. HTTP facade 可调用 SpacetimeDB procedure。 +2. 创作、作品、运行态接口返回 shared-contract DTO。 +3. 后端定向测试通过。 + +### F2:结果页与发布 + +写入范围: + +1. `src/components/match3d-result/` +2. `src/services/match3d-works/` +3. 创作中心作品恢复相关最小接线。 + +交付: + +1. 编辑游戏名称、标签、封面图。 +2. 试玩入口。 +3. 发布入口。 + +### F4:平台分发最小接入 + +写入范围: + +1. 创作中心作品货架。 +2. 首页/分类/广场卡片映射。 +3. 作品详情启动运行态入口。 + +交付: + +1. 已发布 Match3D 作品可进入平台列表。 +2. 卡片可进入详情或运行态。 + +## 12.3 最后集成 + +### Q1:集成验收 + +交付: + +1. 创作到发布到试玩主链通过。 +2. 运行态点击、入槽、三消、失败、胜利通过。 +3. 移动端视口检查通过。 +4. `npm run api-server:maincloud` 通过。 +5. 对应测试与 `npm run check:encoding` 通过。 + +--- + +## 13. 合并顺序 + +建议合并顺序: + +1. A0:本文档。 +2. B1 + B2:领域 crate 与 shared contracts。 +3. B3:SpacetimeDB 表和 procedure。 +4. B4 + B5:spacetime-client 与 api-server facade。 +5. F1 / F2 / F3:前端创作、结果页、运行态。 +6. F4:平台分发。 +7. Q1:集成收口。 + +如果 F1/F3 先完成,应只以 mock client 保持可编译,不直接修改后端合同。 + +--- + +## 14. 验收命令 + +后续编码分支按改动范围执行。 + +文档分支: + +```powershell +npm run check:encoding -- docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md docs/technical/README.md +``` + +后端分支: + +```powershell +cargo test -p module-match3d +cargo test -p shared-contracts +npm run api-server:maincloud +npm run check:encoding +``` + +SpacetimeDB 分支按仓库现有发布脚本执行,并在需要生成绑定时使用 `spacetime generate` 或仓库封装脚本。不得手写生成文件。 + +前端分支: + +```powershell +npm run check:encoding +npm run typecheck +``` + +若新增定向测试,应补跑对应 `vitest`。 + +--- + +## 15. 一句话结论 + +Match3D 首版按独立玩法域落地:前端负责所有局内即时反馈以保证手感,后端通过 SpacetimeDB procedure 权威确认规则和成绩,api-server 只暴露稳定 HTTP facade,后续并行分支必须围绕本文冻结的 DTO、表、procedure 和路由推进。 diff --git a/docs/technical/README.md b/docs/technical/README.md index ff7e53d8..61bdb4d4 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -5,6 +5,7 @@ ## 文档列表 - [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md):冻结 SpacetimeDB 表结构变更约束、自动迁移可接受范围、冲突后的系统行为,以及保留旧数据的增量迁移流程;凡涉及 `spacetime publish`、表字段调整或 `migration.rs` 对齐时优先参考。 +- [MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md](./MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md):冻结抓大鹅 Match3D 首版 demo 的独立玩法域、表与 procedure、HTTP facade、前端即时反馈/后端权威确认协议,以及可并行开发包。 - [PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md](./PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md):记录平台首页底部 dock 在手机浏览器地址栏展开时脱离可见区域的根因,以及 `100dvh`、固定底部锚点和安全区占位的修复口径。 - [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md):记录 SpacetimeDB private 表迁移 JSON 导出/导入 procedure、迁移操作员授权、HTTP 413 分片导入、Jenkins 自动迁移回灌和导入脚本参数。 - [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md):记录 `Genarrative-Database-Export` / `Genarrative-Database-Import` 两条 SCM-backed 数据库迁移流水线参数、默认 dry-run、token 边界和 `CHUNK_SIZE` 413 规避参数。 From 08815d98bcc3095ddbc480be6530bb20c4538c6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=94=E9=A6=99=E4=B8=B8=E5=AD=90?= <15518898337@163.com> Date: Thu, 30 Apr 2026 21:01:36 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E6=8A=93=E5=A4=A7=E9=B9=85F3=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ..._DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md | 107 ++ docs/technical/README.md | 1 + docs/technical/SPACETIMEDB_TABLE_CATALOG.md | 48 + packages/shared/src/contracts/match3dAgent.ts | 119 ++ .../shared/src/contracts/match3dRuntime.ts | 125 ++ packages/shared/src/contracts/match3dWorks.ts | 45 + packages/shared/src/index.ts | 3 + server-rs/Cargo.lock | 9 + server-rs/Cargo.toml | 3 +- server-rs/crates/module-match3d/Cargo.toml | 14 + server-rs/crates/module-match3d/src/lib.rs | 996 ++++++++++ server-rs/crates/shared-contracts/src/lib.rs | 3 + .../shared-contracts/src/match3d_agent.rs | 137 ++ .../shared-contracts/src/match3d_runtime.rs | 115 ++ .../shared-contracts/src/match3d_works.rs | 89 + server-rs/crates/spacetime-module/Cargo.toml | 1 + server-rs/crates/spacetime-module/src/lib.rs | 6 +- .../spacetime-module/src/match3d/mod.rs | 1642 +++++++++++++++++ .../spacetime-module/src/match3d/tables.rs | 86 + .../spacetime-module/src/match3d/types.rs | 332 ++++ .../crates/spacetime-module/src/migration.rs | 7 + .../crates/spacetime-module/src/puzzle.rs | 17 +- src/Match3DPlaygroundApp.tsx | 61 + .../creation-agent/CreationAgentWorkspace.tsx | 110 +- .../Match3DAgentWorkspace.tsx | 215 +++ .../Match3DDraftReadyView.tsx | 105 ++ .../Match3DRuntimeShell.test.tsx | 68 + .../match3d-runtime/Match3DRuntimeShell.tsx | 454 +++++ src/components/match3d-runtime/index.ts | 1 + .../PlatformEntryCreationTypeModal.tsx | 5 + .../PlatformEntryFlowShellImpl.tsx | 165 +- .../platformEntryCreationTypes.ts | 8 + .../platform-entry/platformEntryTypes.ts | 2 + src/routing/appRoutes.test.ts | 6 + src/routing/appRoutes.tsx | 19 + src/services/match3d-creation/index.ts | 7 + .../match3d-creation/match3dCreationClient.ts | 361 ++++ src/services/match3d-runtime/index.ts | 8 + .../match3d-runtime/match3dLocalRuntime.ts | 409 ++++ 39 files changed, 5891 insertions(+), 18 deletions(-) create mode 100644 docs/technical/MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md create mode 100644 packages/shared/src/contracts/match3dAgent.ts create mode 100644 packages/shared/src/contracts/match3dRuntime.ts create mode 100644 packages/shared/src/contracts/match3dWorks.ts create mode 100644 server-rs/crates/module-match3d/Cargo.toml create mode 100644 server-rs/crates/module-match3d/src/lib.rs create mode 100644 server-rs/crates/shared-contracts/src/match3d_agent.rs create mode 100644 server-rs/crates/shared-contracts/src/match3d_runtime.rs create mode 100644 server-rs/crates/shared-contracts/src/match3d_works.rs create mode 100644 server-rs/crates/spacetime-module/src/match3d/mod.rs create mode 100644 server-rs/crates/spacetime-module/src/match3d/tables.rs create mode 100644 server-rs/crates/spacetime-module/src/match3d/types.rs create mode 100644 src/Match3DPlaygroundApp.tsx create mode 100644 src/components/match3d-creation/Match3DAgentWorkspace.tsx create mode 100644 src/components/match3d-creation/Match3DDraftReadyView.tsx create mode 100644 src/components/match3d-runtime/Match3DRuntimeShell.test.tsx create mode 100644 src/components/match3d-runtime/Match3DRuntimeShell.tsx create mode 100644 src/components/match3d-runtime/index.ts create mode 100644 src/services/match3d-creation/index.ts create mode 100644 src/services/match3d-creation/match3dCreationClient.ts create mode 100644 src/services/match3d-runtime/index.ts create mode 100644 src/services/match3d-runtime/match3dLocalRuntime.ts diff --git a/docs/technical/MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md b/docs/technical/MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md new file mode 100644 index 00000000..5ac406b4 --- /dev/null +++ b/docs/technical/MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md @@ -0,0 +1,107 @@ +# 抓大鹅 Match3D 领域规则与共享契约 Stage1 方案 + +日期:`2026-04-30` + +## 1. 文档目的 + +本文件承接 [MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md](./MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md),只冻结 B1 + B2 开发范围: + +1. 新增 `module-match3d` 纯领域 crate。 +2. 新增 Rust shared contracts。 +3. 新增 TypeScript shared contracts。 + +本阶段不实现 SpacetimeDB 表、procedure、`spacetime-client` 调用封装、`api-server` facade 和前端页面。 + +## 2. Stage1 边界 + +## 2.1 本阶段做 + +1. 领域层定义创作配置、作品草稿、作品 profile、运行态快照、物品、托盘、点击确认结果。 +2. 领域层提供纯函数: + - 校验创作配置 + - 编译默认草稿 + - 校验发布字段 + - 按确定性 seed 生成初始运行态 + - 刷新 2D 可点击快照 + - 确认点击、入槽、三消、胜利、托盘满失败 + - 确认倒计时失败 +3. Rust / TypeScript shared contracts 提供前后端对齐的请求与响应 DTO。 +4. 运行态采用“前端即时反馈 + 后端权威确认”契约: + - 前端可先播放点击、飞入、入槽、三消、腾格和胜负过渡。 + - 后端确认后返回权威快照。 + - 后端拒绝或快照版本不一致时,前端按权威快照回滚或校正。 + +## 2.2 本阶段不做 + +1. 不新增 SpacetimeDB 表。 +2. 不新增 SpacetimeDB procedure。 +3. 不生成新的 SpacetimeDB bindings。 +4. 不新增 `api-server` 路由。 +5. 不接入平台入口、结果页或运行态 UI。 +6. 不接入真实图片生成。 +7. 不做排行榜与后续关卡推荐。 + +## 3. 领域 crate 设计 + +新增: + +```text +server-rs/crates/module-match3d +``` + +该 crate 是纯领域层,不读写数据库,不访问网络,不依赖浏览器或文件系统。 + +核心类型: + +1. `Match3DCreatorConfig` +2. `Match3DResultDraft` +3. `Match3DWorkProfile` +4. `Match3DRunSnapshot` +5. `Match3DItemSnapshot` +6. `Match3DTraySlot` +7. `Match3DClickConfirmation` + +核心函数: + +1. `build_creator_config` +2. `compile_result_draft` +3. `validate_publish_requirements` +4. `create_work_profile` +5. `publish_work_profile` +6. `start_run_with_seed_at` +7. `confirm_click_at` +8. `resolve_run_timer_at` + +## 4. 即时反馈与权威确认 + +本阶段将点击处理明确拆成两层: + +1. 前端即时反馈层 + - 读取后端快照中的 `boardVersion`、物品位置、层级、半径和 `clickable`。 + - 本地做命中检测和动画。 + - 立即表现飞入、入槽、三消和胜负过渡。 + +2. 后端权威确认层 + - 校验 `runId`、`itemInstanceId`、运行态状态和物品是否仍可点击。 + - 重新计算入槽、三消、托盘满失败和胜利。 + - 返回最新 `Match3DRunSnapshot`。 + - 用 `boardVersion` 帮前端识别是否需要校正。 + +`Flying` 只作为前端表现态,不要求后端逐帧落库。后端只确认物品是否已从 `InBoard` 进入 `InTray` 或 `Cleared`。 + +## 5. 生成规则 Stage1 口径 + +1. `clearCount` 必须是正整数。 +2. `totalItemCount = clearCount * 3`。 +3. 难度范围为 `1~10`。 +4. 首版内置 `10` 种 demo 视觉 key。 +5. 当 `clearCount > 10` 时,复用视觉 key,并保证每种物品数量仍为 `3` 的倍数。 +6. 初始布局使用确定性 seed 生成圆形空间内的 2D 坐标。 +7. 可点击判定只做 2D 近似:若物品被更高层物品完全覆盖,则不可点击;否则可点击。 + +## 6. 验收 + +1. `cargo test -p module-match3d` 通过。 +2. `cargo test -p shared-contracts match3d` 通过。 +3. `npm run check:encoding` 覆盖新增中文文档和新增源码。 +4. 本阶段不要求运行 `npm run api-server:maincloud`,因为未修改后端运行服务入口、SpacetimeDB 表或 `api-server` facade。 diff --git a/docs/technical/README.md b/docs/technical/README.md index 61bdb4d4..32109e70 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -6,6 +6,7 @@ - [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md):冻结 SpacetimeDB 表结构变更约束、自动迁移可接受范围、冲突后的系统行为,以及保留旧数据的增量迁移流程;凡涉及 `spacetime publish`、表字段调整或 `migration.rs` 对齐时优先参考。 - [MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md](./MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md):冻结抓大鹅 Match3D 首版 demo 的独立玩法域、表与 procedure、HTTP facade、前端即时反馈/后端权威确认协议,以及可并行开发包。 +- [MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md](./MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md):冻结抓大鹅 Match3D B1+B2 的纯领域规则 crate、Rust/TypeScript shared contracts,以及 Stage1 不触碰 SpacetimeDB 表和 api-server 的边界。 - [PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md](./PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md):记录平台首页底部 dock 在手机浏览器地址栏展开时脱离可见区域的根因,以及 `100dvh`、固定底部锚点和安全区占位的修复口径。 - [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md):记录 SpacetimeDB private 表迁移 JSON 导出/导入 procedure、迁移操作员授权、HTTP 413 分片导入、Jenkins 自动迁移回灌和导入脚本参数。 - [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md):记录 `Genarrative-Database-Export` / `Genarrative-Database-Import` 两条 SCM-backed 数据库迁移流水线参数、默认 dry-run、token 边界和 `CHUNK_SIZE` 413 规避参数。 diff --git a/docs/technical/SPACETIMEDB_TABLE_CATALOG.md b/docs/technical/SPACETIMEDB_TABLE_CATALOG.md index 344597fd..713f4101 100644 --- a/docs/technical/SPACETIMEDB_TABLE_CATALOG.md +++ b/docs/technical/SPACETIMEDB_TABLE_CATALOG.md @@ -27,6 +27,7 @@ spacetime sql "SELECT * FROM custom_world_gallery_entry" | RPG 运行时 | `story_session`, `story_event`, `npc_state`, `inventory_slot`, `battle_state`, `treasure_record`, `quest_record`, `quest_log`, `player_progression`, `chapter_progression` | | 世界创作 | `custom_world_profile`, `custom_world_session`, `custom_world_agent_session`, `custom_world_agent_message`, `custom_world_agent_operation`, `custom_world_draft_card`, `custom_world_gallery_entry` | | 拼图 | `puzzle_agent_session`, `puzzle_agent_message`, `puzzle_work_profile`, `puzzle_runtime_run` | +| 抓大鹅 Match3D | `match3d_agent_session`, `match3d_agent_message`, `match3d_work_profile`, `match3d_runtime_run` | | 大鱼吃小鱼 | `big_fish_creation_session`, `big_fish_agent_message`, `big_fish_asset_slot`, `big_fish_runtime_run` | | 资产 | `asset_object`, `asset_entity_binding` | | AI 任务 | `ai_task`, `ai_task_stage`, `ai_text_chunk`, `ai_result_reference` | @@ -446,6 +447,53 @@ SELECT * FROM puzzle_runtime_run WHERE run_id = ''; SELECT * FROM puzzle_runtime_run WHERE owner_user_id = '' ORDER BY updated_at DESC; ``` +## 抓大鹅 Match3D 表 + +### `match3d_agent_session` + +- 作用:抓大鹅 Match3D 创作 Agent 会话表,保存种子、配置 JSON、草稿 JSON 和发布 profile 指针。 +- 结构:`session_id PK: String`, `owner_user_id: String`, `seed_text: String`, `current_turn: u32`, `progress_percent: u32`, `stage: String`, `config_json: String`, `draft_json: String`, `last_assistant_reply: String`, `published_profile_id: String`, `created_at: Timestamp`, `updated_at: Timestamp`。 +- 索引:`owner_user_id`。 + +```sql +SELECT * FROM match3d_agent_session WHERE session_id = ''; +SELECT * FROM match3d_agent_session WHERE owner_user_id = '' ORDER BY updated_at DESC; +``` + +### `match3d_agent_message` + +- 作用:抓大鹅 Match3D 创作 Agent 消息流水。 +- 结构:`message_id PK: String`, `session_id: String`, `role: String`, `kind: String`, `text: String`, `created_at: Timestamp`。 +- 索引:`session_id`。 + +```sql +SELECT * FROM match3d_agent_message WHERE session_id = '' ORDER BY created_at ASC; +``` + +### `match3d_work_profile` + +- 作用:抓大鹅 Match3D 作品主表,保存作品基础信息、配置、发布状态和游玩次数。 +- 结构:`profile_id PK: String`, `owner_user_id: String`, `source_session_id: String`, `author_display_name: String`, `game_name: String`, `theme_text: String`, `summary_text: String`, `tags_json: String`, `cover_image_src: String`, `cover_asset_id: String`, `clear_count: u32`, `difficulty: u32`, `config_json: String`, `publication_status: String`, `play_count: u32`, `updated_at: Timestamp`, `published_at: Option`。 +- 索引:`owner_user_id`, `publication_status`。 + +```sql +SELECT * FROM match3d_work_profile WHERE profile_id = ''; +SELECT * FROM match3d_work_profile WHERE owner_user_id = '' ORDER BY updated_at DESC; +SELECT * FROM match3d_work_profile WHERE publication_status = 'Published'; +``` + +### `match3d_runtime_run` + +- 作用:抓大鹅 Match3D 单局运行态表,保存权威快照、快照版本、胜负状态和成绩基础字段。 +- 结构:`run_id PK: String`, `owner_user_id: String`, `profile_id: String`, `status: String`, `snapshot_version: u32`, `started_at_ms: i64`, `duration_limit_ms: i64`, `finished_at_ms: i64`, `elapsed_ms: i64`, `clear_count: u32`, `total_item_count: u32`, `cleared_item_count: u32`, `failure_reason: String`, `snapshot_json: String`, `created_at: Timestamp`, `updated_at: Timestamp`。 +- 索引:`owner_user_id`, `profile_id`。 + +```sql +SELECT * FROM match3d_runtime_run WHERE run_id = ''; +SELECT * FROM match3d_runtime_run WHERE owner_user_id = '' ORDER BY updated_at DESC; +SELECT * FROM match3d_runtime_run WHERE profile_id = ''; +``` + ## 大鱼吃小鱼表 ### `big_fish_creation_session` diff --git a/packages/shared/src/contracts/match3dAgent.ts b/packages/shared/src/contracts/match3dAgent.ts new file mode 100644 index 00000000..924127e4 --- /dev/null +++ b/packages/shared/src/contracts/match3dAgent.ts @@ -0,0 +1,119 @@ +export type Match3DCreationStage = + | 'collecting_config' + | 'draft_ready' + | 'ready_to_publish' + | 'published' + | string; + +export type Match3DAgentMessageRole = 'user' | 'assistant' | 'system' | string; + +export type Match3DAgentMessageKind = + | 'chat' + | 'summary' + | 'action_result' + | 'warning' + | string; + +export type Match3DAnchorStatus = 'confirmed' | 'missing' | 'inferred' | 'locked' | string; + +export interface CreateMatch3DAgentSessionRequest { + seedText?: string; + themeText?: string; + referenceImageSrc?: string | null; + clearCount?: number; + difficulty?: number; +} + +export type CreateMatch3DSessionRequest = CreateMatch3DAgentSessionRequest; + +export interface SendMatch3DAgentMessageRequest { + clientMessageId: string; + text: string; + quickFillRequested?: boolean; + referenceImageSrc?: string | null; +} + +export type SendMatch3DMessageRequest = SendMatch3DAgentMessageRequest; + +export interface ExecuteMatch3DAgentActionRequest { + action: string; + gameName?: string; + summary?: string; + tags?: string[]; + coverImageSrc?: string | null; + clearCount?: number; + difficulty?: number; +} + +export type ExecuteMatch3DActionRequest = ExecuteMatch3DAgentActionRequest; + +export interface Match3DAnchorItemResponse { + key: string; + label: string; + value: string; + status: Match3DAnchorStatus; +} + +export interface Match3DAnchorPackResponse { + theme: Match3DAnchorItemResponse; + clearCount: Match3DAnchorItemResponse; + difficulty: Match3DAnchorItemResponse; +} + +export interface Match3DCreatorConfig { + themeText: string; + referenceImageSrc?: string | null; + clearCount: number; + difficulty: number; +} + +export interface Match3DResultDraft { + gameName: string; + themeText: string; + summaryText?: string; + summary?: string; + tags: string[]; + coverImageSrc?: string | null; + referenceImageSrc?: string | null; + clearCount: number; + difficulty: number; + totalItemCount?: number; + publishReady?: boolean; + blockers?: string[]; +} + +export interface Match3DAgentMessage { + id: string; + role: Match3DAgentMessageRole; + kind: Match3DAgentMessageKind; + text: string; + createdAt: string; +} + +export type Match3DAgentMessageResponse = Match3DAgentMessage; + +export interface Match3DAgentSessionSnapshot { + sessionId: string; + currentTurn: number; + progressPercent: number; + stage: Match3DCreationStage; + anchorPack: Match3DAnchorPackResponse; + config?: Match3DCreatorConfig | null; + draft?: Match3DResultDraft | null; + messages: Match3DAgentMessage[]; + lastAssistantReply?: string | null; + publishedProfileId?: string | null; + updatedAt: string; +} + +export interface Match3DAgentSessionResponse { + session: Match3DAgentSessionSnapshot; +} + +export type Match3DSessionResponse = Match3DAgentSessionResponse; + +export interface Match3DAgentActionResponse { + session: Match3DAgentSessionSnapshot; +} + +export type Match3DActionResponse = Match3DAgentActionResponse; diff --git a/packages/shared/src/contracts/match3dRuntime.ts b/packages/shared/src/contracts/match3dRuntime.ts new file mode 100644 index 00000000..67f70ea6 --- /dev/null +++ b/packages/shared/src/contracts/match3dRuntime.ts @@ -0,0 +1,125 @@ +export type Match3DRunStatus = + | 'running' + | 'won' + | 'failed' + | 'stopped' + | 'Running' + | 'Won' + | 'Failed' + | 'Stopped' + | string; +export type Match3DItemState = + | 'in_board' + | 'in_tray' + | 'cleared' + | 'InBoard' + | 'Flying' + | 'InTray' + | 'Cleared' + | string; +export type Match3DFailureReason = + | 'time_up' + | 'tray_full' + | 'TimeUp' + | 'TrayFull' + | string; +export type Match3DClickRejectReason = + | 'run_not_active' + | 'snapshot_version_mismatch' + | 'item_not_found' + | 'item_not_in_board' + | 'item_not_clickable' + | 'tray_full' + | string; + +export type Match3DClickConfirmStatus = + | 'Accepted' + | 'RejectedNotClickable' + | 'RejectedAlreadyMoved' + | 'RejectedTrayFull' + | 'VersionConflict' + | 'RunFinished'; + +export interface StartMatch3DRunRequest { + profileId: string; +} + +export interface Match3DClickItemRequest { + runId?: string; + itemInstanceId: string; + clientActionId?: string; + snapshotVersion?: number; + clientSnapshotVersion: number; + clientEventId: string; + clickedAtMs: number; +} + +export type ClickMatch3DItemRequest = Match3DClickItemRequest; + +export interface StopMatch3DRunRequest { + clientActionId: string; +} + +export interface Match3DItemSnapshot { + itemInstanceId: string; + itemTypeId: string; + visualKey: string; + x: number; + y: number; + radius: number; + layer: number; + state: Match3DItemState; + clickable: boolean; + traySlotIndex?: number | null; +} + +export interface Match3DTraySlot { + slotIndex: number; + itemInstanceId?: string | null; + itemTypeId?: string | null; + visualKey?: string | null; +} + +export interface Match3DRunSnapshot { + runId: string; + profileId: string; + ownerUserId?: string; + status: Match3DRunStatus; + snapshotVersion: number; + startedAtMs: number; + durationLimitMs: number; + serverNowMs?: number; + remainingMs: number; + clearCount: number; + totalItemCount: number; + clearedItemCount: number; + boardVersion?: number; + items: Match3DItemSnapshot[]; + traySlots: Match3DTraySlot[]; + failureReason?: Match3DFailureReason | null; + lastConfirmedActionId?: string | null; +} + +export interface Match3DClickConfirmation { + accepted: boolean; + rejectReason?: Match3DClickRejectReason | null; + enteredSlotIndex?: number | null; + clearedItemInstanceIds: string[]; + run: Match3DRunSnapshot; +} + +export interface Match3DClickItemResult { + status: Match3DClickConfirmStatus; + run: Match3DRunSnapshot; + acceptedItemInstanceId?: string; + clearedItemInstanceIds: string[]; + failureReason?: Match3DFailureReason | null; +} + +export interface Match3DRunResponse { + run: Match3DRunSnapshot; +} + +export interface Match3DClickResponse { + confirmation: Match3DClickConfirmation; +} diff --git a/packages/shared/src/contracts/match3dWorks.ts b/packages/shared/src/contracts/match3dWorks.ts new file mode 100644 index 00000000..1d5fce5d --- /dev/null +++ b/packages/shared/src/contracts/match3dWorks.ts @@ -0,0 +1,45 @@ +export type Match3DWorkPublicationStatus = 'draft' | 'published' | string; + +export interface PutMatch3DWorkRequest { + gameName: string; + summary: string; + tags: string[]; + coverImageSrc?: string | null; + referenceImageSrc?: string | null; + clearCount: number; + difficulty: number; +} + +export interface Match3DWorkSummary { + workId: string; + profileId: string; + ownerUserId: string; + sourceSessionId?: string | null; + gameName: string; + themeText: string; + summary: string; + tags: string[]; + coverImageSrc?: string | null; + referenceImageSrc?: string | null; + clearCount: number; + difficulty: number; + publicationStatus: Match3DWorkPublicationStatus; + playCount: number; + updatedAt: string; + publishedAt?: string | null; + publishReady: boolean; +} + +export interface Match3DWorkProfile extends Match3DWorkSummary {} + +export interface Match3DWorksResponse { + items: Match3DWorkSummary[]; +} + +export interface Match3DWorkDetailResponse { + item: Match3DWorkProfile; +} + +export interface Match3DWorkMutationResponse { + item: Match3DWorkProfile; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0744e6f2..80b647fe 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -11,6 +11,9 @@ export * from './contracts/rpgCreationFixtures'; export * from './contracts/rpgCreationPreview'; export * from './contracts/rpgCreationResultView'; export * from './contracts/rpgCreationWorkSummary'; +export * from './contracts/match3dAgent'; +export * from './contracts/match3dRuntime'; +export * from './contracts/match3dWorks'; export * from './contracts/puzzleAgentActions'; export * from './contracts/puzzleAgentDraft'; export * from './contracts/puzzleAgentSession'; diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 65d11bce..7d1879b2 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -1562,6 +1562,15 @@ dependencies = [ "spacetimedb", ] +[[package]] +name = "module-match3d" +version = "0.1.0" +dependencies = [ + "serde", + "shared-kernel", + "spacetimedb", +] + [[package]] name = "module-npc" version = "0.1.0" diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 97d39672..239a2566 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -15,6 +15,7 @@ members = [ "crates/module-combat", "crates/module-inventory", "crates/module-custom-world", + "crates/module-match3d", "crates/module-npc", "crates/module-puzzle", "crates/module-progression", @@ -52,4 +53,4 @@ incremental = true [profile.release] opt-level = 3 # 最大优化等级 lto = "thin" # 启用 Thin LTO,平衡编译时间和性能 -codegen-units = 1 # 减少并行代码生成单元,提升优化但增加编译时间 \ No newline at end of file +codegen-units = 1 # 减少并行代码生成单元,提升优化但增加编译时间 diff --git a/server-rs/crates/module-match3d/Cargo.toml b/server-rs/crates/module-match3d/Cargo.toml new file mode 100644 index 00000000..5e5042f3 --- /dev/null +++ b/server-rs/crates/module-match3d/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "module-match3d" +edition.workspace = true +version.workspace = true +license.workspace = true + +[features] +default = [] +spacetime-types = ["dep:spacetimedb"] + +[dependencies] +serde = { version = "1", features = ["derive"] } +shared-kernel = { path = "../shared-kernel" } +spacetimedb = { workspace = true, optional = true } diff --git a/server-rs/crates/module-match3d/src/lib.rs b/server-rs/crates/module-match3d/src/lib.rs new file mode 100644 index 00000000..8ecb2aaf --- /dev/null +++ b/server-rs/crates/module-match3d/src/lib.rs @@ -0,0 +1,996 @@ +use std::{error::Error, fmt}; + +use serde::{Deserialize, Serialize}; +use shared_kernel::{normalize_optional_string, normalize_required_string, normalize_string_list}; +#[cfg(feature = "spacetime-types")] +use spacetimedb::SpacetimeType; + +pub const MATCH3D_SESSION_ID_PREFIX: &str = "match3d-session-"; +pub const MATCH3D_MESSAGE_ID_PREFIX: &str = "match3d-message-"; +pub const MATCH3D_PROFILE_ID_PREFIX: &str = "match3d-profile-"; +pub const MATCH3D_WORK_ID_PREFIX: &str = "match3d-work-"; +pub const MATCH3D_RUN_ID_PREFIX: &str = "match3d-run-"; +pub const MATCH3D_TRAY_SLOT_COUNT: u32 = 7; +pub const MATCH3D_ITEMS_PER_CLEAR: u32 = 3; +pub const MATCH3D_MIN_DIFFICULTY: u32 = 1; +pub const MATCH3D_MAX_DIFFICULTY: u32 = 10; +pub const MATCH3D_DEFAULT_DURATION_LIMIT_MS: u64 = 10 * 60 * 1000; +pub const MATCH3D_BOARD_RADIUS: f32 = 1.0; + +const MATCH3D_DEMO_VISUAL_KEYS: [&str; 10] = [ + "red_circle", + "yellow_triangle", + "purple_diamond", + "green_square", + "blue_star", + "orange_hexagon", + "cyan_capsule", + "pink_heart", + "lime_leaf", + "white_moon", +]; + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Match3DCreationStage { + CollectingConfig, + DraftReady, + ReadyToPublish, + Published, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Match3DPublicationStatus { + Draft, + Published, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Match3DRunStatus { + Running, + Won, + Failed, + Stopped, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Match3DFailureReason { + TimeUp, + TrayFull, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Match3DItemState { + InBoard, + InTray, + Cleared, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Match3DClickRejectReason { + RunNotActive, + SnapshotVersionMismatch, + ItemNotFound, + ItemNotInBoard, + ItemNotClickable, + TrayFull, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Match3DCreatorConfig { + pub theme_text: String, + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Match3DResultDraft { + pub game_name: String, + pub theme_text: String, + pub summary: String, + pub tags: Vec, + pub cover_image_src: Option, + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, + pub publish_ready: bool, + pub blockers: Vec, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Match3DWorkProfile { + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: Option, + pub game_name: String, + pub theme_text: String, + pub summary: String, + pub tags: Vec, + pub cover_image_src: Option, + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, + pub publication_status: Match3DPublicationStatus, + pub play_count: u32, + pub updated_at_micros: i64, + pub published_at_micros: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Match3DItemSnapshot { + pub item_instance_id: String, + pub item_type_id: String, + pub visual_key: String, + pub x: f32, + pub y: f32, + pub radius: f32, + pub layer: u32, + pub state: Match3DItemState, + pub clickable: bool, + pub tray_slot_index: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Match3DTraySlot { + pub slot_index: u32, + pub item_instance_id: Option, + pub item_type_id: Option, + pub visual_key: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Match3DRunSnapshot { + pub run_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub status: Match3DRunStatus, + pub started_at_ms: u64, + pub duration_limit_ms: u64, + pub remaining_ms: u64, + pub clear_count: u32, + pub total_item_count: u32, + pub cleared_item_count: u32, + pub board_version: u64, + pub items: Vec, + pub tray_slots: Vec, + pub failure_reason: Option, + pub last_confirmed_action_id: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Match3DClickInput { + pub run_id: String, + pub owner_user_id: String, + pub item_instance_id: String, + pub client_action_id: String, + pub snapshot_version: u64, + pub clicked_at_ms: u64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Match3DClickConfirmation { + pub accepted: bool, + pub reject_reason: Option, + pub entered_slot_index: Option, + pub cleared_item_instance_ids: Vec, + pub run: Match3DRunSnapshot, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Match3DFieldError { + MissingText, + MissingOwnerUserId, + MissingProfileId, + MissingRunId, + MissingItemId, + InvalidClearCount, + InvalidDifficulty, +} + +impl fmt::Display for Match3DFieldError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingText => write!(f, "必填文本缺失"), + Self::MissingOwnerUserId => write!(f, "owner_user_id 缺失"), + Self::MissingProfileId => write!(f, "profile_id 缺失"), + Self::MissingRunId => write!(f, "run_id 缺失"), + Self::MissingItemId => write!(f, "item_instance_id 缺失"), + Self::InvalidClearCount => write!(f, "需要消除次数必须为正整数"), + Self::InvalidDifficulty => write!(f, "难度必须在 1 到 10 之间"), + } + } +} + +impl Error for Match3DFieldError {} + +impl Match3DCreationStage { + pub fn as_str(self) -> &'static str { + match self { + Self::CollectingConfig => "collecting_config", + Self::DraftReady => "draft_ready", + Self::ReadyToPublish => "ready_to_publish", + Self::Published => "published", + } + } +} + +impl Match3DPublicationStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Draft => "draft", + Self::Published => "published", + } + } +} + +impl Match3DRunStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Running => "running", + Self::Won => "won", + Self::Failed => "failed", + Self::Stopped => "stopped", + } + } +} + +impl Match3DFailureReason { + pub fn as_str(self) -> &'static str { + match self { + Self::TimeUp => "time_up", + Self::TrayFull => "tray_full", + } + } +} + +impl Match3DItemState { + pub fn as_str(self) -> &'static str { + match self { + Self::InBoard => "in_board", + Self::InTray => "in_tray", + Self::Cleared => "cleared", + } + } +} + +impl Match3DClickRejectReason { + pub fn as_str(self) -> &'static str { + match self { + Self::RunNotActive => "run_not_active", + Self::SnapshotVersionMismatch => "snapshot_version_mismatch", + Self::ItemNotFound => "item_not_found", + Self::ItemNotInBoard => "item_not_in_board", + Self::ItemNotClickable => "item_not_clickable", + Self::TrayFull => "tray_full", + } + } +} + +pub fn build_creator_config( + theme_text: &str, + reference_image_src: Option, + clear_count: u32, + difficulty: u32, +) -> Result { + let theme_text = normalize_required_string(theme_text).ok_or(Match3DFieldError::MissingText)?; + if clear_count == 0 { + return Err(Match3DFieldError::InvalidClearCount); + } + if !(MATCH3D_MIN_DIFFICULTY..=MATCH3D_MAX_DIFFICULTY).contains(&difficulty) { + return Err(Match3DFieldError::InvalidDifficulty); + } + + Ok(Match3DCreatorConfig { + theme_text, + reference_image_src: normalize_optional_string(reference_image_src), + clear_count, + difficulty, + }) +} + +pub fn compile_result_draft(config: &Match3DCreatorConfig) -> Match3DResultDraft { + let game_name = format!("{}抓大鹅", config.theme_text); + let summary = format!( + "{}主题,{} 次消除目标,难度 {}。", + config.theme_text, config.clear_count, config.difficulty + ); + let tags = default_tags_for_theme(&config.theme_text); + let blockers = validate_basic_publish_fields(&game_name, &summary, &tags); + + Match3DResultDraft { + game_name, + theme_text: config.theme_text.clone(), + summary, + tags, + cover_image_src: None, + reference_image_src: config.reference_image_src.clone(), + clear_count: config.clear_count, + difficulty: config.difficulty, + publish_ready: blockers.is_empty(), + blockers, + } +} + +pub fn validate_publish_requirements(draft: &Match3DResultDraft) -> Vec { + let mut blockers = validate_basic_publish_fields(&draft.game_name, &draft.summary, &draft.tags); + if draft.clear_count == 0 { + blockers.push("需要消除次数必须为正整数".to_string()); + } + if !(MATCH3D_MIN_DIFFICULTY..=MATCH3D_MAX_DIFFICULTY).contains(&draft.difficulty) { + blockers.push("难度必须在 1 到 10 之间".to_string()); + } + blockers +} + +pub fn create_work_profile( + work_id: String, + profile_id: String, + owner_user_id: String, + source_session_id: Option, + draft: &Match3DResultDraft, + updated_at_micros: i64, +) -> Result { + let work_id = normalize_required_string(work_id).ok_or(Match3DFieldError::MissingText)?; + let profile_id = + normalize_required_string(profile_id).ok_or(Match3DFieldError::MissingProfileId)?; + let owner_user_id = + normalize_required_string(owner_user_id).ok_or(Match3DFieldError::MissingOwnerUserId)?; + + Ok(Match3DWorkProfile { + work_id, + profile_id, + owner_user_id, + source_session_id: normalize_optional_string(source_session_id), + game_name: draft.game_name.clone(), + theme_text: draft.theme_text.clone(), + summary: draft.summary.clone(), + tags: normalize_string_list(draft.tags.clone()), + cover_image_src: draft.cover_image_src.clone(), + reference_image_src: draft.reference_image_src.clone(), + clear_count: draft.clear_count, + difficulty: draft.difficulty, + publication_status: Match3DPublicationStatus::Draft, + play_count: 0, + updated_at_micros, + published_at_micros: None, + }) +} + +pub fn publish_work_profile( + profile: &Match3DWorkProfile, + published_at_micros: i64, +) -> Result { + if profile.clear_count == 0 { + return Err(Match3DFieldError::InvalidClearCount); + } + if !(MATCH3D_MIN_DIFFICULTY..=MATCH3D_MAX_DIFFICULTY).contains(&profile.difficulty) { + return Err(Match3DFieldError::InvalidDifficulty); + } + + let mut next = profile.clone(); + next.publication_status = Match3DPublicationStatus::Published; + next.updated_at_micros = published_at_micros; + next.published_at_micros = Some(published_at_micros); + Ok(next) +} + +pub fn start_run_with_seed_at( + run_id: String, + owner_user_id: String, + profile_id: String, + config: &Match3DCreatorConfig, + seed: u64, + started_at_ms: u64, +) -> Result { + let run_id = normalize_required_string(run_id).ok_or(Match3DFieldError::MissingRunId)?; + let owner_user_id = + normalize_required_string(owner_user_id).ok_or(Match3DFieldError::MissingOwnerUserId)?; + let profile_id = + normalize_required_string(profile_id).ok_or(Match3DFieldError::MissingProfileId)?; + + let total_item_count = config + .clear_count + .checked_mul(MATCH3D_ITEMS_PER_CLEAR) + .ok_or(Match3DFieldError::InvalidClearCount)?; + let mut run = Match3DRunSnapshot { + run_id, + profile_id, + owner_user_id, + status: Match3DRunStatus::Running, + started_at_ms, + duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS, + remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS, + clear_count: config.clear_count, + total_item_count, + cleared_item_count: 0, + board_version: 1, + items: build_initial_items(config.clear_count, config.difficulty, seed), + tray_slots: empty_tray_slots(), + failure_reason: None, + last_confirmed_action_id: None, + }; + refresh_clickable_flags(&mut run); + Ok(run) +} + +pub fn confirm_click_at( + run: &Match3DRunSnapshot, + input: &Match3DClickInput, +) -> Result { + let item_instance_id = normalize_required_string(&input.item_instance_id) + .ok_or(Match3DFieldError::MissingItemId)?; + let client_action_id = normalize_required_string(&input.client_action_id) + .unwrap_or_else(|| "match3d-action-unknown".to_string()); + + let mut next = resolve_run_timer_at(run, input.clicked_at_ms); + if next.status != Match3DRunStatus::Running { + return Ok(rejected(next, Match3DClickRejectReason::RunNotActive)); + } + if input.snapshot_version != next.board_version { + return Ok(rejected( + next, + Match3DClickRejectReason::SnapshotVersionMismatch, + )); + } + + let Some(item_index) = next + .items + .iter() + .position(|item| item.item_instance_id == item_instance_id) + else { + return Ok(rejected(next, Match3DClickRejectReason::ItemNotFound)); + }; + + if next.items[item_index].state != Match3DItemState::InBoard { + return Ok(rejected(next, Match3DClickRejectReason::ItemNotInBoard)); + } + if !next.items[item_index].clickable { + return Ok(rejected(next, Match3DClickRejectReason::ItemNotClickable)); + } + + let Some(slot_index) = first_empty_slot_index(&next.tray_slots) else { + next = fail_run(next, Match3DFailureReason::TrayFull, client_action_id); + return Ok(rejected(next, Match3DClickRejectReason::TrayFull)); + }; + + let item_type_id = next.items[item_index].item_type_id.clone(); + next.items[item_index].state = Match3DItemState::InTray; + next.items[item_index].clickable = false; + next.items[item_index].tray_slot_index = Some(slot_index); + fill_tray_slot(&mut next.tray_slots, slot_index, &next.items[item_index]); + + let cleared_item_instance_ids = clear_first_triple(&mut next, &item_type_id); + compact_tray(&mut next); + next.cleared_item_count = next + .items + .iter() + .filter(|item| item.state == Match3DItemState::Cleared) + .count() as u32; + + if next.cleared_item_count >= next.total_item_count { + next.status = Match3DRunStatus::Won; + } else if first_empty_slot_index(&next.tray_slots).is_none() { + next.status = Match3DRunStatus::Failed; + next.failure_reason = Some(Match3DFailureReason::TrayFull); + } + + refresh_clickable_flags(&mut next); + next.board_version += 1; + next.last_confirmed_action_id = Some(client_action_id); + + Ok(Match3DClickConfirmation { + accepted: true, + reject_reason: None, + entered_slot_index: Some(slot_index), + cleared_item_instance_ids, + run: next, + }) +} + +pub fn resolve_run_timer_at(run: &Match3DRunSnapshot, now_ms: u64) -> Match3DRunSnapshot { + let mut next = run.clone(); + if next.status != Match3DRunStatus::Running { + return next; + } + let elapsed_ms = now_ms.saturating_sub(next.started_at_ms); + next.remaining_ms = next.duration_limit_ms.saturating_sub(elapsed_ms); + if next.remaining_ms == 0 { + next.status = Match3DRunStatus::Failed; + next.failure_reason = Some(Match3DFailureReason::TimeUp); + next.board_version += 1; + } + next +} + +pub fn stop_run_at(run: &Match3DRunSnapshot, stopped_action_id: String) -> Match3DRunSnapshot { + let mut next = run.clone(); + if next.status == Match3DRunStatus::Running { + next.status = Match3DRunStatus::Stopped; + next.board_version += 1; + next.last_confirmed_action_id = normalize_required_string(stopped_action_id); + } + next +} + +pub fn refresh_clickable_flags(run: &mut Match3DRunSnapshot) { + let board_items = run + .items + .iter() + .filter(|item| item.state == Match3DItemState::InBoard) + .cloned() + .collect::>(); + + for item in &mut run.items { + if item.state != Match3DItemState::InBoard { + item.clickable = false; + continue; + } + + item.clickable = !board_items.iter().any(|cover| { + cover.layer > item.layer + && fully_covers(cover.x, cover.y, cover.radius, item.x, item.y, item.radius) + }); + } +} + +fn build_initial_items(clear_count: u32, difficulty: u32, seed: u64) -> Vec { + let mut rng = DeterministicRng::new(seed ^ ((clear_count as u64) << 32) ^ difficulty as u64); + let radius = resolve_item_radius(difficulty); + let mut items = Vec::with_capacity((clear_count * MATCH3D_ITEMS_PER_CLEAR) as usize); + + for clear_index in 0..clear_count { + let visual_index = (clear_index as usize) % MATCH3D_DEMO_VISUAL_KEYS.len(); + let item_type_id = format!("match3d-type-{:02}", visual_index + 1); + let visual_key = MATCH3D_DEMO_VISUAL_KEYS[visual_index].to_string(); + + for copy_index in 0..MATCH3D_ITEMS_PER_CLEAR { + let (x, y) = random_point_in_circle(&mut rng, MATCH3D_BOARD_RADIUS - radius); + let instance_index = clear_index * MATCH3D_ITEMS_PER_CLEAR + copy_index; + items.push(Match3DItemSnapshot { + item_instance_id: format!("match3d-item-{instance_index:04}"), + item_type_id: item_type_id.clone(), + visual_key: visual_key.clone(), + x, + y, + radius, + layer: instance_index, + state: Match3DItemState::InBoard, + clickable: true, + tray_slot_index: None, + }); + } + } + + // 洗牌只改变层级顺序,不改变每组三个的可通关性。 + for index in (1..items.len()).rev() { + let swap_index = (rng.next_u32() as usize) % (index + 1); + items.swap(index, swap_index); + } + for (layer, item) in items.iter_mut().enumerate() { + item.layer = layer as u32; + } + + items +} + +fn resolve_item_radius(difficulty: u32) -> f32 { + let clamped = difficulty.clamp(MATCH3D_MIN_DIFFICULTY, MATCH3D_MAX_DIFFICULTY); + let radius = 0.105 - (clamped as f32 - 1.0) * 0.0055; + radius.max(0.052) +} + +fn random_point_in_circle(rng: &mut DeterministicRng, max_radius: f32) -> (f32, f32) { + for _ in 0..24 { + let x = rng.next_unit_signed() * max_radius; + let y = rng.next_unit_signed() * max_radius; + if x * x + y * y <= max_radius * max_radius { + return (x, y); + } + } + (0.0, 0.0) +} + +fn fully_covers( + cover_x: f32, + cover_y: f32, + cover_radius: f32, + item_x: f32, + item_y: f32, + item_radius: f32, +) -> bool { + let dx = cover_x - item_x; + let dy = cover_y - item_y; + let distance = (dx * dx + dy * dy).sqrt(); + distance + item_radius <= cover_radius * 0.96 +} + +fn empty_tray_slots() -> Vec { + (0..MATCH3D_TRAY_SLOT_COUNT) + .map(|slot_index| Match3DTraySlot { + slot_index, + item_instance_id: None, + item_type_id: None, + visual_key: None, + }) + .collect() +} + +fn first_empty_slot_index(slots: &[Match3DTraySlot]) -> Option { + slots + .iter() + .find(|slot| slot.item_instance_id.is_none()) + .map(|slot| slot.slot_index) +} + +fn fill_tray_slot(slots: &mut [Match3DTraySlot], slot_index: u32, item: &Match3DItemSnapshot) { + if let Some(slot) = slots.iter_mut().find(|slot| slot.slot_index == slot_index) { + slot.item_instance_id = Some(item.item_instance_id.clone()); + slot.item_type_id = Some(item.item_type_id.clone()); + slot.visual_key = Some(item.visual_key.clone()); + } +} + +fn clear_first_triple(run: &mut Match3DRunSnapshot, item_type_id: &str) -> Vec { + let matched_slot_item_ids = run + .tray_slots + .iter() + .filter(|slot| slot.item_type_id.as_deref() == Some(item_type_id)) + .filter_map(|slot| slot.item_instance_id.clone()) + .take(MATCH3D_ITEMS_PER_CLEAR as usize) + .collect::>(); + + if matched_slot_item_ids.len() < MATCH3D_ITEMS_PER_CLEAR as usize { + return Vec::new(); + } + + for item in &mut run.items { + if matched_slot_item_ids.contains(&item.item_instance_id) { + item.state = Match3DItemState::Cleared; + item.clickable = false; + item.tray_slot_index = None; + } + } + for slot in &mut run.tray_slots { + if slot + .item_instance_id + .as_ref() + .is_some_and(|id| matched_slot_item_ids.contains(id)) + { + slot.item_instance_id = None; + slot.item_type_id = None; + slot.visual_key = None; + } + } + + matched_slot_item_ids +} + +fn compact_tray(run: &mut Match3DRunSnapshot) { + let mut occupied = run + .tray_slots + .iter() + .filter_map(|slot| { + Some(( + slot.item_instance_id.clone()?, + slot.item_type_id.clone()?, + slot.visual_key.clone()?, + )) + }) + .collect::>(); + + for slot in &mut run.tray_slots { + slot.item_instance_id = None; + slot.item_type_id = None; + slot.visual_key = None; + } + + for (slot_index, (item_instance_id, item_type_id, visual_key)) in occupied.drain(..).enumerate() + { + let slot_index = slot_index as u32; + if let Some(slot) = run + .tray_slots + .iter_mut() + .find(|slot| slot.slot_index == slot_index) + { + slot.item_instance_id = Some(item_instance_id.clone()); + slot.item_type_id = Some(item_type_id); + slot.visual_key = Some(visual_key); + } + if let Some(item) = run + .items + .iter_mut() + .find(|item| item.item_instance_id == item_instance_id) + { + item.tray_slot_index = Some(slot_index); + } + } +} + +fn fail_run( + mut run: Match3DRunSnapshot, + reason: Match3DFailureReason, + action_id: String, +) -> Match3DRunSnapshot { + run.status = Match3DRunStatus::Failed; + run.failure_reason = Some(reason); + run.board_version += 1; + run.last_confirmed_action_id = Some(action_id); + run +} + +fn rejected( + run: Match3DRunSnapshot, + reject_reason: Match3DClickRejectReason, +) -> Match3DClickConfirmation { + Match3DClickConfirmation { + accepted: false, + reject_reason: Some(reject_reason), + entered_slot_index: None, + cleared_item_instance_ids: Vec::new(), + run, + } +} + +fn validate_basic_publish_fields(game_name: &str, summary: &str, tags: &[String]) -> Vec { + let mut blockers = Vec::new(); + if normalize_required_string(game_name).is_none() { + blockers.push("游戏名称不能为空".to_string()); + } + if normalize_required_string(summary).is_none() { + blockers.push("简介不能为空".to_string()); + } + let normalized_tags = normalize_string_list(tags.to_vec()); + if normalized_tags.is_empty() { + blockers.push("至少需要 1 个标签".to_string()); + } + blockers +} + +fn default_tags_for_theme(theme_text: &str) -> Vec { + let mut tags = vec![ + "抓大鹅".to_string(), + "经典消除".to_string(), + theme_text.to_string(), + ]; + tags.sort(); + tags.dedup(); + tags +} + +struct DeterministicRng { + state: u64, +} + +impl DeterministicRng { + fn new(seed: u64) -> Self { + Self { state: seed.max(1) } + } + + fn next_u32(&mut self) -> u32 { + let mut value = self.state; + value ^= value << 13; + value ^= value >> 7; + value ^= value << 17; + self.state = value; + (value >> 32) as u32 + } + + fn next_unit_signed(&mut self) -> f32 { + let value = self.next_u32() as f32 / u32::MAX as f32; + value * 2.0 - 1.0 + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + + fn test_config(clear_count: u32) -> Match3DCreatorConfig { + build_creator_config("水果", None, clear_count, 4).expect("config should be valid") + } + + fn manual_item(id: &str, type_id: &str, slot: Option) -> Match3DItemSnapshot { + Match3DItemSnapshot { + item_instance_id: id.to_string(), + item_type_id: type_id.to_string(), + visual_key: type_id.to_string(), + x: 0.0, + y: 0.0, + radius: 0.08, + layer: 0, + state: if slot.is_some() { + Match3DItemState::InTray + } else { + Match3DItemState::InBoard + }, + clickable: slot.is_none(), + tray_slot_index: slot, + } + } + + #[test] + fn creator_config_requires_positive_clear_count() { + let error = build_creator_config("水果", None, 0, 3).expect_err("zero should fail"); + assert_eq!(error, Match3DFieldError::InvalidClearCount); + } + + #[test] + fn initial_run_generates_triples() { + let run = start_run_with_seed_at( + "run-1".to_string(), + "user-1".to_string(), + "profile-1".to_string(), + &test_config(12), + 42, + 1_000, + ) + .expect("run should start"); + + assert_eq!(run.total_item_count, 36); + let mut counts = BTreeMap::::new(); + for item in &run.items { + *counts.entry(item.item_type_id.clone()).or_default() += 1; + } + assert!(counts.values().all(|count| count % 3 == 0)); + } + + #[test] + fn clicking_three_same_items_clears_and_wins() { + let mut run = start_run_with_seed_at( + "run-1".to_string(), + "user-1".to_string(), + "profile-1".to_string(), + &test_config(1), + 7, + 10_000, + ) + .expect("run should start"); + for item in &mut run.items { + item.clickable = true; + } + + let ids = run + .items + .iter() + .map(|item| item.item_instance_id.clone()) + .collect::>(); + + for (index, item_id) in ids.iter().enumerate() { + let input = Match3DClickInput { + run_id: run.run_id.clone(), + owner_user_id: run.owner_user_id.clone(), + item_instance_id: item_id.clone(), + client_action_id: format!("action-{index}"), + snapshot_version: run.board_version, + clicked_at_ms: 11_000 + index as u64, + }; + run = confirm_click_at(&run, &input) + .expect("click should confirm") + .run; + } + + assert_eq!(run.status, Match3DRunStatus::Won); + assert_eq!(run.cleared_item_count, 3); + assert!( + run.tray_slots + .iter() + .all(|slot| slot.item_instance_id.is_none()) + ); + } + + #[test] + fn tray_full_fails_when_no_triple_can_clear() { + let mut run = Match3DRunSnapshot { + run_id: "run-full".to_string(), + profile_id: "profile-1".to_string(), + owner_user_id: "user-1".to_string(), + status: Match3DRunStatus::Running, + started_at_ms: 0, + duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS, + remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS, + clear_count: 3, + total_item_count: 9, + cleared_item_count: 0, + board_version: 1, + items: (0..8) + .map(|index| manual_item(&format!("item-{index}"), &format!("type-{index}"), None)) + .collect(), + tray_slots: empty_tray_slots(), + failure_reason: None, + last_confirmed_action_id: None, + }; + + for index in 0..7 { + let input = Match3DClickInput { + run_id: run.run_id.clone(), + owner_user_id: run.owner_user_id.clone(), + item_instance_id: format!("item-{index}"), + client_action_id: format!("action-{index}"), + snapshot_version: run.board_version, + clicked_at_ms: 1_000 + index, + }; + run = confirm_click_at(&run, &input) + .expect("click should confirm") + .run; + } + + assert_eq!(run.status, Match3DRunStatus::Failed); + assert_eq!(run.failure_reason, Some(Match3DFailureReason::TrayFull)); + } + + #[test] + fn timer_expiration_fails_running_run() { + let run = start_run_with_seed_at( + "run-1".to_string(), + "user-1".to_string(), + "profile-1".to_string(), + &test_config(2), + 9, + 1_000, + ) + .expect("run should start"); + + let expired = resolve_run_timer_at(&run, 1_000 + MATCH3D_DEFAULT_DURATION_LIMIT_MS); + + assert_eq!(expired.status, Match3DRunStatus::Failed); + assert_eq!(expired.failure_reason, Some(Match3DFailureReason::TimeUp)); + } + + #[test] + fn fully_covered_item_is_not_clickable() { + let mut run = Match3DRunSnapshot { + run_id: "run-cover".to_string(), + profile_id: "profile-1".to_string(), + owner_user_id: "user-1".to_string(), + status: Match3DRunStatus::Running, + started_at_ms: 0, + duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS, + remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS, + clear_count: 1, + total_item_count: 2, + cleared_item_count: 0, + board_version: 1, + items: vec![ + Match3DItemSnapshot { + layer: 0, + radius: 0.04, + ..manual_item("bottom", "type-a", None) + }, + Match3DItemSnapshot { + layer: 1, + radius: 0.08, + ..manual_item("top", "type-b", None) + }, + ], + tray_slots: empty_tray_slots(), + failure_reason: None, + last_confirmed_action_id: None, + }; + + refresh_clickable_flags(&mut run); + + let bottom = run + .items + .iter() + .find(|item| item.item_instance_id == "bottom") + .expect("bottom item should exist"); + assert!(!bottom.clickable); + } +} diff --git a/server-rs/crates/shared-contracts/src/lib.rs b/server-rs/crates/shared-contracts/src/lib.rs index c54e622c..e71ba254 100644 --- a/server-rs/crates/shared-contracts/src/lib.rs +++ b/server-rs/crates/shared-contracts/src/lib.rs @@ -7,6 +7,9 @@ pub mod big_fish; pub mod big_fish_works; pub mod creation_agent_document_input; pub mod llm; +pub mod match3d_agent; +pub mod match3d_runtime; +pub mod match3d_works; pub mod puzzle_agent; pub mod puzzle_gallery; pub mod puzzle_runtime; diff --git a/server-rs/crates/shared-contracts/src/match3d_agent.rs b/server-rs/crates/shared-contracts/src/match3d_agent.rs new file mode 100644 index 00000000..dd44c097 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/match3d_agent.rs @@ -0,0 +1,137 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreateMatch3DAgentSessionRequest { + #[serde(default)] + pub seed_text: Option, + #[serde(default)] + pub theme_text: Option, + #[serde(default)] + pub reference_image_src: Option, + #[serde(default)] + pub clear_count: Option, + #[serde(default)] + pub difficulty: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SendMatch3DAgentMessageRequest { + pub client_message_id: String, + pub text: String, + #[serde(default)] + pub quick_fill_requested: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ExecuteMatch3DAgentActionRequest { + pub action: String, + #[serde(default)] + pub game_name: Option, + #[serde(default)] + pub summary: Option, + #[serde(default)] + pub tags: Option>, + #[serde(default)] + pub cover_image_src: Option, + #[serde(default)] + pub clear_count: Option, + #[serde(default)] + pub difficulty: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DCreatorConfigResponse { + pub theme_text: String, + #[serde(default)] + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DResultDraftResponse { + pub game_name: String, + pub theme_text: String, + pub summary: String, + pub tags: Vec, + #[serde(default)] + pub cover_image_src: Option, + #[serde(default)] + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, + pub publish_ready: bool, + pub blockers: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DAgentMessageResponse { + pub id: String, + pub role: String, + pub kind: String, + pub text: String, + pub created_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DAgentSessionSnapshotResponse { + pub session_id: String, + pub current_turn: u32, + pub progress_percent: u32, + pub stage: String, + #[serde(default)] + pub config: Option, + #[serde(default)] + pub draft: Option, + pub messages: Vec, + #[serde(default)] + pub last_assistant_reply: Option, + #[serde(default)] + pub published_profile_id: Option, + pub updated_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DAgentSessionResponse { + pub session: Match3DAgentSessionSnapshotResponse, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DAgentActionResponse { + pub session: Match3DAgentSessionSnapshotResponse, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn create_match3d_session_request_uses_camel_case() { + let payload = serde_json::to_value(CreateMatch3DAgentSessionRequest { + seed_text: Some("水果消除".to_string()), + theme_text: Some("水果".to_string()), + reference_image_src: Some("data:image/png;base64,abc".to_string()), + clear_count: Some(4), + difficulty: Some(3), + }) + .expect("payload should serialize"); + + assert_eq!(payload["seedText"], json!("水果消除")); + assert_eq!(payload["themeText"], json!("水果")); + assert_eq!( + payload["referenceImageSrc"], + json!("data:image/png;base64,abc") + ); + assert_eq!(payload["clearCount"], json!(4)); + } +} diff --git a/server-rs/crates/shared-contracts/src/match3d_runtime.rs b/server-rs/crates/shared-contracts/src/match3d_runtime.rs new file mode 100644 index 00000000..fb088e95 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/match3d_runtime.rs @@ -0,0 +1,115 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct StartMatch3DRunRequest { + pub profile_id: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ClickMatch3DItemRequest { + pub item_instance_id: String, + pub client_action_id: String, + pub snapshot_version: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct StopMatch3DRunRequest { + pub client_action_id: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DItemSnapshotResponse { + pub item_instance_id: String, + pub item_type_id: String, + pub visual_key: String, + pub x: f32, + pub y: f32, + pub radius: f32, + pub layer: u32, + pub state: String, + pub clickable: bool, + #[serde(default)] + pub tray_slot_index: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DTraySlotResponse { + pub slot_index: u32, + #[serde(default)] + pub item_instance_id: Option, + #[serde(default)] + pub item_type_id: Option, + #[serde(default)] + pub visual_key: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DRunSnapshotResponse { + pub run_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub status: String, + pub started_at_ms: u64, + pub duration_limit_ms: u64, + pub remaining_ms: u64, + pub clear_count: u32, + pub total_item_count: u32, + pub cleared_item_count: u32, + pub board_version: u64, + pub items: Vec, + pub tray_slots: Vec, + #[serde(default)] + pub failure_reason: Option, + #[serde(default)] + pub last_confirmed_action_id: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DClickConfirmationResponse { + pub accepted: bool, + #[serde(default)] + pub reject_reason: Option, + #[serde(default)] + pub entered_slot_index: Option, + pub cleared_item_instance_ids: Vec, + pub run: Match3DRunSnapshotResponse, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DRunResponse { + pub run: Match3DRunSnapshotResponse, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DClickResponse { + pub confirmation: Match3DClickConfirmationResponse, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn click_match3d_item_request_uses_camel_case() { + let payload = serde_json::to_value(ClickMatch3DItemRequest { + item_instance_id: "item-1".to_string(), + client_action_id: "action-1".to_string(), + snapshot_version: 7, + }) + .expect("payload should serialize"); + + assert_eq!(payload["itemInstanceId"], json!("item-1")); + assert_eq!(payload["clientActionId"], json!("action-1")); + assert_eq!(payload["snapshotVersion"], json!(7)); + } +} diff --git a/server-rs/crates/shared-contracts/src/match3d_works.rs b/server-rs/crates/shared-contracts/src/match3d_works.rs new file mode 100644 index 00000000..a55d1f84 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/match3d_works.rs @@ -0,0 +1,89 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PutMatch3DWorkRequest { + pub game_name: String, + pub summary: String, + pub tags: Vec, + #[serde(default)] + pub cover_image_src: Option, + #[serde(default)] + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DWorkSummaryResponse { + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + #[serde(default)] + pub source_session_id: Option, + pub game_name: String, + pub theme_text: String, + pub summary: String, + pub tags: Vec, + #[serde(default)] + pub cover_image_src: Option, + #[serde(default)] + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, + pub publication_status: String, + pub play_count: u32, + pub updated_at: String, + #[serde(default)] + pub published_at: Option, + pub publish_ready: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DWorkProfileResponse { + #[serde(flatten)] + pub summary: Match3DWorkSummaryResponse, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DWorksResponse { + pub items: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DWorkDetailResponse { + pub item: Match3DWorkProfileResponse, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DWorkMutationResponse { + pub item: Match3DWorkProfileResponse, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn match3d_work_request_uses_camel_case() { + let payload = serde_json::to_value(PutMatch3DWorkRequest { + game_name: "水果抓大鹅".to_string(), + summary: "水果主题".to_string(), + tags: vec!["水果".to_string()], + cover_image_src: None, + reference_image_src: None, + clear_count: 4, + difficulty: 5, + }) + .expect("payload should serialize"); + + assert_eq!(payload["gameName"], json!("水果抓大鹅")); + assert_eq!(payload["clearCount"], json!(4)); + } +} diff --git a/server-rs/crates/spacetime-module/Cargo.toml b/server-rs/crates/spacetime-module/Cargo.toml index 62749ac7..2ae5c223 100644 --- a/server-rs/crates/spacetime-module/Cargo.toml +++ b/server-rs/crates/spacetime-module/Cargo.toml @@ -18,6 +18,7 @@ module-big-fish = { path = "../module-big-fish", default-features = false, featu module-combat = { path = "../module-combat", default-features = false, features = ["spacetime-types"] } module-inventory = { path = "../module-inventory", default-features = false, features = ["spacetime-types"] } module-custom-world = { path = "../module-custom-world", default-features = false, features = ["spacetime-types"] } +module-match3d = { path = "../module-match3d", default-features = false } module-npc = { path = "../module-npc", default-features = false, features = ["spacetime-types"] } module-puzzle = { path = "../module-puzzle", default-features = false, features = ["spacetime-types"] } module-progression = { path = "../module-progression", default-features = false, features = ["spacetime-types"] } diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index c7003157..fe5874c8 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -31,6 +31,7 @@ mod auth; mod big_fish; mod domain_types; mod entry; +mod match3d; mod migration; mod puzzle; mod runtime; @@ -41,6 +42,7 @@ pub use auth::*; pub use big_fish::*; pub use domain_types::*; pub use entry::*; +pub use match3d::*; pub use migration::*; pub use runtime::*; @@ -2856,7 +2858,9 @@ fn list_custom_world_profile_snapshots( Ok(entries) } -fn build_custom_world_profile_list_snapshot(row: &CustomWorldProfile) -> CustomWorldProfileSnapshot { +fn build_custom_world_profile_list_snapshot( + row: &CustomWorldProfile, +) -> CustomWorldProfileSnapshot { let mut snapshot = build_custom_world_profile_snapshot(row); snapshot.profile_payload_json = build_custom_world_profile_list_payload_json(row); snapshot diff --git a/server-rs/crates/spacetime-module/src/match3d/mod.rs b/server-rs/crates/spacetime-module/src/match3d/mod.rs new file mode 100644 index 00000000..40a4f463 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/match3d/mod.rs @@ -0,0 +1,1642 @@ +pub(crate) mod tables; +mod types; + +pub use tables::*; +pub use types::*; + +use crate::*; +use module_match3d::{ + Match3DClickInput as DomainMatch3DClickInput, + Match3DClickRejectReason as DomainMatch3DClickRejectReason, + Match3DCreatorConfig as DomainMatch3DCreatorConfig, + Match3DFailureReason as DomainMatch3DFailureReason, + Match3DItemSnapshot as DomainMatch3DItemSnapshot, Match3DItemState as DomainMatch3DItemState, + Match3DRunSnapshot as DomainMatch3DRunSnapshot, Match3DRunStatus as DomainMatch3DRunStatus, + Match3DTraySlot as DomainMatch3DTraySlot, confirm_click_at as confirm_domain_click_at, + resolve_run_timer_at as resolve_domain_run_timer_at, start_run_with_seed_at, + stop_run_at as stop_domain_run_at, +}; +use serde::Serialize; +use serde::de::DeserializeOwned; + +#[spacetimedb::procedure] +pub fn create_match3d_agent_session( + ctx: &mut ProcedureContext, + input: Match3DAgentSessionCreateInput, +) -> Match3DAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| create_match3d_agent_session_tx(tx, input.clone())) { + Ok(session) => session_result(session), + Err(message) => session_error(message), + } +} + +#[spacetimedb::procedure] +pub fn get_match3d_agent_session( + ctx: &mut ProcedureContext, + input: Match3DAgentSessionGetInput, +) -> Match3DAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| get_match3d_agent_session_tx(tx, input.clone())) { + Ok(session) => session_result(session), + Err(message) => session_error(message), + } +} + +#[spacetimedb::procedure] +pub fn submit_match3d_agent_message( + ctx: &mut ProcedureContext, + input: Match3DAgentMessageSubmitInput, +) -> Match3DAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| submit_match3d_agent_message_tx(tx, input.clone())) { + Ok(session) => session_result(session), + Err(message) => session_error(message), + } +} + +#[spacetimedb::procedure] +pub fn finalize_match3d_agent_message_turn( + ctx: &mut ProcedureContext, + input: Match3DAgentMessageFinalizeInput, +) -> Match3DAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| finalize_match3d_agent_message_turn_tx(tx, input.clone())) { + Ok(session) => session_result(session), + Err(message) => session_error(message), + } +} + +#[spacetimedb::procedure] +pub fn compile_match3d_draft( + ctx: &mut ProcedureContext, + input: Match3DDraftCompileInput, +) -> Match3DAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| compile_match3d_draft_tx(tx, input.clone())) { + Ok(session) => session_result(session), + Err(message) => session_error(message), + } +} + +#[spacetimedb::procedure] +pub fn update_match3d_work( + ctx: &mut ProcedureContext, + input: Match3DWorkUpdateInput, +) -> Match3DWorkProcedureResult { + match ctx.try_with_tx(|tx| update_match3d_work_tx(tx, input.clone())) { + Ok(work) => work_result(work), + Err(message) => work_error(message), + } +} + +#[spacetimedb::procedure] +pub fn publish_match3d_work( + ctx: &mut ProcedureContext, + input: Match3DWorkPublishInput, +) -> Match3DWorkProcedureResult { + match ctx.try_with_tx(|tx| publish_match3d_work_tx(tx, input.clone())) { + Ok(work) => work_result(work), + Err(message) => work_error(message), + } +} + +#[spacetimedb::procedure] +pub fn list_match3d_works( + ctx: &mut ProcedureContext, + input: Match3DWorksListInput, +) -> Match3DWorksProcedureResult { + match ctx.try_with_tx(|tx| list_match3d_works_tx(tx, input.clone())) { + Ok(items) => Match3DWorksProcedureResult { + ok: true, + items_json: Some(to_json_string(&items)), + error_message: None, + }, + Err(message) => Match3DWorksProcedureResult { + ok: false, + items_json: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn get_match3d_work_detail( + ctx: &mut ProcedureContext, + input: Match3DWorkGetInput, +) -> Match3DWorkProcedureResult { + match ctx.try_with_tx(|tx| get_match3d_work_detail_tx(tx, input.clone())) { + Ok(work) => work_result(work), + Err(message) => work_error(message), + } +} + +#[spacetimedb::procedure] +pub fn delete_match3d_work( + ctx: &mut ProcedureContext, + input: Match3DWorkDeleteInput, +) -> Match3DWorksProcedureResult { + match ctx.try_with_tx(|tx| delete_match3d_work_tx(tx, input.clone())) { + Ok(items) => Match3DWorksProcedureResult { + ok: true, + items_json: Some(to_json_string(&items)), + error_message: None, + }, + Err(message) => Match3DWorksProcedureResult { + ok: false, + items_json: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn start_match3d_run( + ctx: &mut ProcedureContext, + input: Match3DRunStartInput, +) -> Match3DRunProcedureResult { + match ctx.try_with_tx(|tx| start_match3d_run_tx(tx, input.clone())) { + Ok(run) => run_result(run), + Err(message) => run_error(message), + } +} + +#[spacetimedb::procedure] +pub fn get_match3d_run( + ctx: &mut ProcedureContext, + input: Match3DRunGetInput, +) -> Match3DRunProcedureResult { + match ctx.try_with_tx(|tx| get_match3d_run_tx(tx, input.clone())) { + Ok(run) => run_result(run), + Err(message) => run_error(message), + } +} + +#[spacetimedb::procedure] +pub fn click_match3d_item( + ctx: &mut ProcedureContext, + input: Match3DRunClickInput, +) -> Match3DClickItemProcedureResult { + match ctx.try_with_tx(|tx| click_match3d_item_tx(tx, input.clone())) { + Ok(result) => result, + Err(message) => Match3DClickItemProcedureResult { + ok: false, + status: MATCH3D_CLICK_REJECTED_NOT_CLICKABLE.to_string(), + run_json: None, + accepted_item_instance_id: None, + cleared_item_instance_ids: Vec::new(), + failure_reason: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn stop_match3d_run( + ctx: &mut ProcedureContext, + input: Match3DRunStopInput, +) -> Match3DRunProcedureResult { + match ctx.try_with_tx(|tx| stop_match3d_run_tx(tx, input.clone())) { + Ok(run) => run_result(run), + Err(message) => run_error(message), + } +} + +#[spacetimedb::procedure] +pub fn restart_match3d_run( + ctx: &mut ProcedureContext, + input: Match3DRunRestartInput, +) -> Match3DRunProcedureResult { + match ctx.try_with_tx(|tx| restart_match3d_run_tx(tx, input.clone())) { + Ok(run) => run_result(run), + Err(message) => run_error(message), + } +} + +#[spacetimedb::procedure] +pub fn finish_match3d_time_up( + ctx: &mut ProcedureContext, + input: Match3DRunTimeUpInput, +) -> Match3DRunProcedureResult { + match ctx.try_with_tx(|tx| finish_match3d_time_up_tx(tx, input.clone())) { + Ok(run) => run_result(run), + Err(message) => run_error(message), + } +} + +fn create_match3d_agent_session_tx( + ctx: &ReducerContext, + input: Match3DAgentSessionCreateInput, +) -> Result { + require_non_empty(&input.session_id, "match3d session_id")?; + require_non_empty(&input.owner_user_id, "match3d owner_user_id")?; + require_non_empty(&input.welcome_message_id, "match3d welcome_message_id")?; + if ctx + .db + .match3d_agent_session() + .session_id() + .find(&input.session_id) + .is_some() + { + return Err("match3d_agent_session.session_id 已存在".to_string()); + } + if ctx + .db + .match3d_agent_message() + .message_id() + .find(&input.welcome_message_id) + .is_some() + { + return Err("match3d_agent_message.message_id 已存在".to_string()); + } + + let config = input + .config_json + .as_deref() + .map(parse_config) + .transpose()? + .unwrap_or_else(|| default_config_from_seed(&input.seed_text)); + validate_config(&config)?; + let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros); + let welcome = input.welcome_message_text.trim(); + + ctx.db + .match3d_agent_session() + .insert(Match3DAgentSessionRow { + 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: MATCH3D_STAGE_COLLECTING.to_string(), + config_json: to_json_string(&config), + draft_json: String::new(), + last_assistant_reply: welcome.to_string(), + published_profile_id: String::new(), + created_at, + updated_at: created_at, + }); + ctx.db + .match3d_agent_message() + .insert(Match3DAgentMessageRow { + message_id: input.welcome_message_id, + session_id: input.session_id.clone(), + role: MATCH3D_ROLE_ASSISTANT.to_string(), + kind: MATCH3D_KIND_TEXT.to_string(), + text: welcome.to_string(), + created_at, + }); + + get_match3d_agent_session_tx( + ctx, + Match3DAgentSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + +fn get_match3d_agent_session_tx( + ctx: &ReducerContext, + input: Match3DAgentSessionGetInput, +) -> Result { + let row = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?; + build_session_snapshot(ctx, &row) +} + +fn submit_match3d_agent_message_tx( + ctx: &ReducerContext, + input: Match3DAgentMessageSubmitInput, +) -> Result { + require_non_empty(&input.user_message_id, "match3d user_message_id")?; + require_non_empty(&input.user_message_text, "match3d user_message_text")?; + let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?; + if ctx + .db + .match3d_agent_message() + .message_id() + .find(&input.user_message_id) + .is_some() + { + return Err("match3d_agent_message.user_message_id 已存在".to_string()); + } + + let submitted_at = Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros); + ctx.db + .match3d_agent_message() + .insert(Match3DAgentMessageRow { + message_id: input.user_message_id, + session_id: input.session_id.clone(), + role: MATCH3D_ROLE_USER.to_string(), + kind: MATCH3D_KIND_TEXT.to_string(), + text: input.user_message_text.trim().to_string(), + created_at: submitted_at, + }); + replace_session( + ctx, + &session, + Match3DAgentSessionRow { + updated_at: submitted_at, + ..clone_session(&session) + }, + ); + + get_match3d_agent_session_tx( + ctx, + Match3DAgentSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + +fn finalize_match3d_agent_message_turn_tx( + ctx: &ReducerContext, + input: Match3DAgentMessageFinalizeInput, +) -> Result { + let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?; + let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + if let Some(message) = input + .error_message + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + replace_session( + ctx, + &session, + Match3DAgentSessionRow { + updated_at, + ..clone_session(&session) + }, + ); + return Err(message.to_string()); + } + + let next_config = input + .config_json + .as_deref() + .map(parse_config) + .transpose()? + .unwrap_or_else(|| parse_config_or_default(&session.config_json)); + validate_config(&next_config)?; + let assistant_text = input + .assistant_reply_text + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(&session.last_assistant_reply) + .to_string(); + if let Some(message_id) = input + .assistant_message_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if ctx + .db + .match3d_agent_message() + .message_id() + .find(&message_id.to_string()) + .is_some() + { + return Err("match3d_agent_message.assistant_message_id 已存在".to_string()); + } + ctx.db + .match3d_agent_message() + .insert(Match3DAgentMessageRow { + message_id: message_id.to_string(), + session_id: input.session_id.clone(), + role: MATCH3D_ROLE_ASSISTANT.to_string(), + kind: MATCH3D_KIND_TEXT.to_string(), + text: assistant_text.clone(), + created_at: updated_at, + }); + } + + let next_stage = normalize_stage(&input.stage); + replace_session( + ctx, + &session, + Match3DAgentSessionRow { + current_turn: session.current_turn.saturating_add(1), + progress_percent: input.progress_percent.min(100), + stage: next_stage, + config_json: to_json_string(&next_config), + last_assistant_reply: assistant_text, + updated_at, + ..clone_session(&session) + }, + ); + + get_match3d_agent_session_tx( + ctx, + Match3DAgentSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + +fn compile_match3d_draft_tx( + ctx: &ReducerContext, + input: Match3DDraftCompileInput, +) -> Result { + require_non_empty(&input.profile_id, "match3d profile_id")?; + let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?; + let config = parse_config(&session.config_json)?; + validate_config(&config)?; + let tags = input + .tags_json + .as_deref() + .map(parse_tags) + .transpose()? + .filter(|items| !items.is_empty()) + .unwrap_or_else(|| default_tags(&config.theme_text)); + let game_name = + clean_optional(&input.game_name).unwrap_or_else(|| format!("{}抓大鹅", config.theme_text)); + let summary_text = clean_optional(&input.summary_text) + .unwrap_or_else(|| format!("{}主题的经典消除玩法。", config.theme_text)); + let draft = Match3DDraftSnapshot { + profile_id: input.profile_id.clone(), + game_name: game_name.clone(), + theme_text: config.theme_text.clone(), + summary_text: summary_text.clone(), + tags: tags.clone(), + clear_count: config.clear_count, + difficulty: config.difficulty, + }; + let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros); + let work = Match3DWorkProfileRow { + profile_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, "陶泥主"), + game_name, + theme_text: config.theme_text.clone(), + summary_text, + tags_json: to_json_string(&tags), + cover_image_src: clean_optional(&input.cover_image_src).unwrap_or_default(), + cover_asset_id: clean_optional(&input.cover_asset_id).unwrap_or_default(), + clear_count: config.clear_count, + difficulty: config.difficulty, + config_json: to_json_string(&config), + publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(), + play_count: 0, + updated_at: compiled_at, + published_at: None, + }; + upsert_work(ctx, work); + replace_session( + ctx, + &session, + Match3DAgentSessionRow { + progress_percent: 80, + stage: MATCH3D_STAGE_DRAFT_COMPILED.to_string(), + 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_match3d_agent_session_tx( + ctx, + Match3DAgentSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + +fn update_match3d_work_tx( + ctx: &ReducerContext, + input: Match3DWorkUpdateInput, +) -> Result { + let current = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?; + let tags = parse_tags(&input.tags_json)?; + let config = Match3DCreatorConfigSnapshot { + theme_text: clean_string(&input.theme_text, "经典消除"), + reference_image_src: parse_config_or_default(¤t.config_json).reference_image_src, + clear_count: input.clear_count, + difficulty: input.difficulty, + }; + validate_config(&config)?; + let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + let next = Match3DWorkProfileRow { + profile_id: current.profile_id.clone(), + owner_user_id: current.owner_user_id.clone(), + source_session_id: current.source_session_id.clone(), + author_display_name: current.author_display_name.clone(), + game_name: clean_string(&input.game_name, "未命名抓大鹅"), + theme_text: config.theme_text.clone(), + summary_text: clean_string(&input.summary_text, "经典消除玩法"), + tags_json: to_json_string(&tags), + cover_image_src: input.cover_image_src.trim().to_string(), + cover_asset_id: input.cover_asset_id.trim().to_string(), + clear_count: config.clear_count, + difficulty: config.difficulty, + config_json: to_json_string(&config), + publication_status: current.publication_status.clone(), + play_count: current.play_count, + updated_at, + published_at: current.published_at, + }; + let snapshot = build_work_snapshot(&next)?; + replace_work(ctx, ¤t, next); + Ok(snapshot) +} + +fn publish_match3d_work_tx( + ctx: &ReducerContext, + input: Match3DWorkPublishInput, +) -> Result { + let current = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?; + validate_publishable_work(¤t)?; + let published_at = Timestamp::from_micros_since_unix_epoch(input.published_at_micros); + let next = Match3DWorkProfileRow { + publication_status: MATCH3D_PUBLICATION_PUBLISHED.to_string(), + updated_at: published_at, + published_at: Some(published_at), + ..clone_work(¤t) + }; + let snapshot = build_work_snapshot(&next)?; + if !next.source_session_id.is_empty() { + if let Some(session) = ctx + .db + .match3d_agent_session() + .session_id() + .find(&next.source_session_id) + .filter(|row| row.owner_user_id == input.owner_user_id) + { + replace_session( + ctx, + &session, + Match3DAgentSessionRow { + progress_percent: 100, + stage: MATCH3D_STAGE_PUBLISHED.to_string(), + published_profile_id: next.profile_id.clone(), + updated_at: published_at, + ..clone_session(&session) + }, + ); + } + } + replace_work(ctx, ¤t, next); + Ok(snapshot) +} + +fn list_match3d_works_tx( + ctx: &ReducerContext, + input: Match3DWorksListInput, +) -> Result, String> { + let mut items = ctx + .db + .match3d_work_profile() + .iter() + .filter(|row| { + if input.published_only { + row.publication_status == MATCH3D_PUBLICATION_PUBLISHED + } else { + row.owner_user_id == input.owner_user_id + } + }) + .map(|row| build_work_snapshot(&row)) + .collect::, _>>()?; + items.sort_by(|left, right| { + right + .updated_at_micros + .cmp(&left.updated_at_micros) + .then_with(|| left.profile_id.cmp(&right.profile_id)) + }); + Ok(items) +} + +fn get_match3d_work_detail_tx( + ctx: &ReducerContext, + input: Match3DWorkGetInput, +) -> Result { + let row = ctx + .db + .match3d_work_profile() + .profile_id() + .find(&input.profile_id) + .filter(|row| { + row.owner_user_id == input.owner_user_id + || row.publication_status == MATCH3D_PUBLICATION_PUBLISHED + }) + .ok_or_else(|| "match3d_work_profile 不存在".to_string())?; + build_work_snapshot(&row) +} + +fn delete_match3d_work_tx( + ctx: &ReducerContext, + input: Match3DWorkDeleteInput, +) -> Result, String> { + let work = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?; + ctx.db + .match3d_work_profile() + .profile_id() + .delete(&work.profile_id); + for run in ctx + .db + .match3d_runtime_run() + .iter() + .filter(|row| { + row.profile_id == input.profile_id && row.owner_user_id == input.owner_user_id + }) + .collect::>() + { + ctx.db.match3d_runtime_run().run_id().delete(&run.run_id); + } + list_match3d_works_tx( + ctx, + Match3DWorksListInput { + owner_user_id: input.owner_user_id, + published_only: false, + }, + ) +} + +fn start_match3d_run_tx( + ctx: &ReducerContext, + input: Match3DRunStartInput, +) -> Result { + require_non_empty(&input.run_id, "match3d run_id")?; + if ctx + .db + .match3d_runtime_run() + .run_id() + .find(&input.run_id) + .is_some() + { + return Err("match3d_runtime_run.run_id 已存在".to_string()); + } + let work = ctx + .db + .match3d_work_profile() + .profile_id() + .find(&input.profile_id) + .filter(|row| { + row.owner_user_id == input.owner_user_id + || row.publication_status == MATCH3D_PUBLICATION_PUBLISHED + }) + .ok_or_else(|| "match3d_work_profile 不存在".to_string())?; + let started_at_ms = if input.started_at_ms > 0 { + input.started_at_ms + } else { + current_server_ms(ctx) + }; + let mut snapshot = build_initial_run_snapshot(&input.run_id, &work, started_at_ms); + snapshot.server_now_ms = current_server_ms(ctx); + snapshot.remaining_ms = compute_remaining_ms(&snapshot, snapshot.server_now_ms); + let now = ctx.timestamp; + ctx.db.match3d_runtime_run().insert(row_from_snapshot( + &input.owner_user_id, + &snapshot, + now, + now, + )); + + Ok(snapshot) +} + +fn get_match3d_run_tx( + ctx: &ReducerContext, + input: Match3DRunGetInput, +) -> Result { + let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; + let snapshot = deserialize_snapshot(&row.snapshot_json)?; + let next = confirm_time_up_if_needed(ctx, &row, snapshot, current_server_ms(ctx))?; + Ok(next) +} + +fn click_match3d_item_tx( + ctx: &ReducerContext, + input: Match3DRunClickInput, +) -> Result { + let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; + let snapshot = deserialize_snapshot(&row.snapshot_json)?; + let server_now_ms = current_server_ms(ctx); + let snapshot = confirm_time_up_if_needed(ctx, &row, snapshot, server_now_ms)?; + if snapshot.status != MATCH3D_RUN_RUNNING { + return Ok(click_result( + MATCH3D_CLICK_RUN_FINISHED, + snapshot, + None, + Vec::new(), + )); + } + if snapshot.snapshot_version != input.client_snapshot_version { + return Ok(click_result( + MATCH3D_CLICK_VERSION_CONFLICT, + snapshot, + None, + Vec::new(), + )); + } + let domain_run = domain_snapshot_from_snapshot(&snapshot, &row.owner_user_id); + let confirmation = confirm_domain_click_at( + &domain_run, + &DomainMatch3DClickInput { + run_id: input.run_id.clone(), + owner_user_id: input.owner_user_id.clone(), + item_instance_id: input.item_instance_id.clone(), + client_action_id: clean_string(&input.client_event_id, "match3d-action"), + snapshot_version: input.client_snapshot_version as u64, + clicked_at_ms: to_u64_ms(server_now_ms), + }, + ) + .map_err(|error| error.to_string())?; + let next = snapshot_from_domain(&confirmation.run, server_now_ms); + let status = if confirmation.accepted { + MATCH3D_CLICK_ACCEPTED + } else { + match confirmation.reject_reason { + Some(DomainMatch3DClickRejectReason::RunNotActive) => MATCH3D_CLICK_RUN_FINISHED, + Some(DomainMatch3DClickRejectReason::SnapshotVersionMismatch) => { + MATCH3D_CLICK_VERSION_CONFLICT + } + Some(DomainMatch3DClickRejectReason::ItemNotFound) + | Some(DomainMatch3DClickRejectReason::ItemNotInBoard) => { + MATCH3D_CLICK_REJECTED_ALREADY_MOVED + } + Some(DomainMatch3DClickRejectReason::ItemNotClickable) => { + MATCH3D_CLICK_REJECTED_NOT_CLICKABLE + } + Some(DomainMatch3DClickRejectReason::TrayFull) => MATCH3D_CLICK_REJECTED_TRAY_FULL, + None => MATCH3D_CLICK_REJECTED_NOT_CLICKABLE, + } + }; + if confirmation.accepted + || status == MATCH3D_CLICK_REJECTED_TRAY_FULL + || next.status != snapshot.status + || next.snapshot_version != snapshot.snapshot_version + { + persist_snapshot(ctx, &row, &next, server_now_ms); + } + Ok(click_result( + status, + next, + confirmation + .accepted + .then_some(input.item_instance_id), + confirmation.cleared_item_instance_ids, + )) +} + +fn stop_match3d_run_tx( + ctx: &ReducerContext, + input: Match3DRunStopInput, +) -> Result { + let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; + let stopped_at_ms = input.stopped_at_ms.max(current_server_ms(ctx)); + let snapshot = deserialize_snapshot(&row.snapshot_json)?; + let domain_run = domain_snapshot_from_snapshot(&snapshot, &row.owner_user_id); + let domain_run = stop_domain_run_at(&domain_run, "match3d-stop".to_string()); + let next = snapshot_from_domain(&domain_run, stopped_at_ms); + persist_snapshot(ctx, &row, &next, stopped_at_ms); + Ok(next) +} + +fn restart_match3d_run_tx( + ctx: &ReducerContext, + input: Match3DRunRestartInput, +) -> Result { + let source = find_owned_run(ctx, &input.source_run_id, &input.owner_user_id)?; + start_match3d_run_tx( + ctx, + Match3DRunStartInput { + run_id: input.next_run_id, + owner_user_id: input.owner_user_id, + profile_id: source.profile_id, + started_at_ms: input.restarted_at_ms, + }, + ) +} + +fn finish_match3d_time_up_tx( + ctx: &ReducerContext, + input: Match3DRunTimeUpInput, +) -> Result { + let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; + let snapshot = deserialize_snapshot(&row.snapshot_json)?; + let finished_at_ms = input.finished_at_ms.max(current_server_ms(ctx)); + let domain_run = domain_snapshot_from_snapshot(&snapshot, &row.owner_user_id); + let domain_run = resolve_domain_run_timer_at(&domain_run, to_u64_ms(finished_at_ms)); + let next = snapshot_from_domain(&domain_run, finished_at_ms); + persist_snapshot(ctx, &row, &next, finished_at_ms); + Ok(next) +} + +fn find_owned_session( + ctx: &ReducerContext, + session_id: &str, + owner_user_id: &str, +) -> Result { + require_non_empty(session_id, "match3d session_id")?; + require_non_empty(owner_user_id, "match3d owner_user_id")?; + ctx.db + .match3d_agent_session() + .session_id() + .find(&session_id.to_string()) + .filter(|row| row.owner_user_id == owner_user_id) + .ok_or_else(|| "match3d_agent_session 不存在".to_string()) +} + +fn find_owned_work( + ctx: &ReducerContext, + profile_id: &str, + owner_user_id: &str, +) -> Result { + require_non_empty(profile_id, "match3d profile_id")?; + require_non_empty(owner_user_id, "match3d owner_user_id")?; + ctx.db + .match3d_work_profile() + .profile_id() + .find(&profile_id.to_string()) + .filter(|row| row.owner_user_id == owner_user_id) + .ok_or_else(|| "match3d_work_profile 不存在".to_string()) +} + +fn find_owned_run( + ctx: &ReducerContext, + run_id: &str, + owner_user_id: &str, +) -> Result { + require_non_empty(run_id, "match3d run_id")?; + require_non_empty(owner_user_id, "match3d owner_user_id")?; + ctx.db + .match3d_runtime_run() + .run_id() + .find(&run_id.to_string()) + .filter(|row| row.owner_user_id == owner_user_id) + .ok_or_else(|| "match3d_runtime_run 不存在".to_string()) +} + +fn build_session_snapshot( + ctx: &ReducerContext, + row: &Match3DAgentSessionRow, +) -> Result { + let mut messages = ctx + .db + .match3d_agent_message() + .iter() + .filter(|message| message.session_id == row.session_id) + .map(|message| Match3DAgentMessageSnapshot { + message_id: message.message_id, + session_id: message.session_id, + role: message.role, + kind: message.kind, + text: message.text, + created_at_micros: message.created_at.to_micros_since_unix_epoch(), + }) + .collect::>(); + messages.sort_by(|left, right| { + left.created_at_micros + .cmp(&right.created_at_micros) + .then_with(|| left.message_id.cmp(&right.message_id)) + }); + let config = parse_config(&row.config_json)?; + let draft = if row.draft_json.trim().is_empty() { + None + } else { + Some(parse_json::( + &row.draft_json, + "match3d draft_json", + )?) + }; + + Ok(Match3DAgentSessionSnapshot { + 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, + draft, + messages, + last_assistant_reply: row.last_assistant_reply.clone(), + published_profile_id: empty_to_none(&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: &Match3DWorkProfileRow) -> Result { + let config = parse_config(&row.config_json)?; + let tags = parse_tags(&row.tags_json)?; + Ok(Match3DWorkSnapshot { + profile_id: row.profile_id.clone(), + owner_user_id: row.owner_user_id.clone(), + source_session_id: row.source_session_id.clone(), + author_display_name: row.author_display_name.clone(), + game_name: row.game_name.clone(), + theme_text: row.theme_text.clone(), + summary_text: row.summary_text.clone(), + tags, + cover_image_src: row.cover_image_src.clone(), + cover_asset_id: row.cover_asset_id.clone(), + clear_count: row.clear_count, + difficulty: row.difficulty, + config, + publication_status: row.publication_status.clone(), + publish_ready: is_work_publish_ready(row), + play_count: row.play_count, + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + published_at_micros: row + .published_at + .map(|value| value.to_micros_since_unix_epoch()), + }) +} + +fn build_initial_run_snapshot( + run_id: &str, + work: &Match3DWorkProfileRow, + started_at_ms: i64, +) -> Match3DRunSnapshot { + let config = parse_config_or_default(&work.config_json); + let domain_config = + domain_config_from_snapshot(&config).unwrap_or_else(|_| fallback_domain_config()); + let domain_started_at_ms = to_u64_ms(started_at_ms); + let seed = deterministic_run_seed(run_id, &work.profile_id, work.clear_count, work.difficulty); + let domain_run = start_run_with_seed_at( + run_id.to_string(), + work.owner_user_id.clone(), + work.profile_id.clone(), + &domain_config, + seed, + domain_started_at_ms, + ) + .unwrap_or_else(|_| { + DomainMatch3DRunSnapshot { + run_id: run_id.to_string(), + profile_id: work.profile_id.clone(), + owner_user_id: work.owner_user_id.clone(), + status: DomainMatch3DRunStatus::Running, + started_at_ms: domain_started_at_ms, + duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS as u64, + remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS as u64, + clear_count: work.clear_count.max(1), + total_item_count: work.clear_count.max(1).saturating_mul(3), + cleared_item_count: 0, + board_version: 1, + items: Vec::new(), + tray_slots: Vec::new(), + failure_reason: None, + last_confirmed_action_id: None, + } + }); + snapshot_from_domain(&domain_run, started_at_ms) +} + +fn fallback_domain_config() -> DomainMatch3DCreatorConfig { + DomainMatch3DCreatorConfig { + theme_text: "经典消除".to_string(), + reference_image_src: None, + clear_count: 1, + difficulty: 3, + } +} + +fn confirm_time_up_if_needed( + ctx: &ReducerContext, + row: &Match3DRuntimeRunRow, + snapshot: Match3DRunSnapshot, + server_now_ms: i64, +) -> Result { + if snapshot.status != MATCH3D_RUN_RUNNING || compute_remaining_ms(&snapshot, server_now_ms) > 0 + { + let mut next = snapshot; + next.server_now_ms = server_now_ms; + next.remaining_ms = compute_remaining_ms(&next, server_now_ms); + return Ok(next); + } + let domain_run = domain_snapshot_from_snapshot(&snapshot, &row.owner_user_id); + let domain_run = resolve_domain_run_timer_at(&domain_run, to_u64_ms(server_now_ms)); + let next = snapshot_from_domain(&domain_run, server_now_ms); + persist_snapshot(ctx, row, &next, server_now_ms); + Ok(next) +} + +fn persist_snapshot( + ctx: &ReducerContext, + row: &Match3DRuntimeRunRow, + snapshot: &Match3DRunSnapshot, + server_now_ms: i64, +) { + let updated_at = Timestamp::from_micros_since_unix_epoch(server_now_ms.saturating_mul(1000)); + let next = row_from_snapshot(&row.owner_user_id, snapshot, row.created_at, updated_at); + ctx.db.match3d_runtime_run().run_id().delete(&row.run_id); + ctx.db.match3d_runtime_run().insert(next); +} + +fn row_from_snapshot( + owner_user_id: &str, + snapshot: &Match3DRunSnapshot, + created_at: Timestamp, + updated_at: Timestamp, +) -> Match3DRuntimeRunRow { + let finished_at_ms = if snapshot.status == MATCH3D_RUN_RUNNING { + 0 + } else { + snapshot.server_now_ms + }; + let elapsed_ms = if finished_at_ms > 0 { + finished_at_ms.saturating_sub(snapshot.started_at_ms) + } else { + snapshot + .server_now_ms + .saturating_sub(snapshot.started_at_ms) + }; + Match3DRuntimeRunRow { + run_id: snapshot.run_id.clone(), + owner_user_id: owner_user_id.to_string(), + profile_id: snapshot.profile_id.clone(), + status: snapshot.status.clone(), + snapshot_version: snapshot.snapshot_version, + started_at_ms: snapshot.started_at_ms, + duration_limit_ms: snapshot.duration_limit_ms, + finished_at_ms, + elapsed_ms, + clear_count: snapshot.clear_count, + total_item_count: snapshot.total_item_count, + cleared_item_count: snapshot.cleared_item_count, + failure_reason: snapshot.failure_reason.clone().unwrap_or_default(), + snapshot_json: to_json_string(snapshot), + created_at, + updated_at, + } +} + +fn click_result( + status: &str, + snapshot: Match3DRunSnapshot, + accepted_item_instance_id: Option, + cleared_item_instance_ids: Vec, +) -> Match3DClickItemProcedureResult { + Match3DClickItemProcedureResult { + ok: true, + status: status.to_string(), + run_json: Some(to_json_string(&snapshot)), + accepted_item_instance_id, + cleared_item_instance_ids, + failure_reason: snapshot.failure_reason, + error_message: None, + } +} + +fn upsert_work(ctx: &ReducerContext, work: Match3DWorkProfileRow) { + if ctx + .db + .match3d_work_profile() + .profile_id() + .find(&work.profile_id) + .is_some() + { + ctx.db + .match3d_work_profile() + .profile_id() + .delete(&work.profile_id); + } + ctx.db.match3d_work_profile().insert(work); +} + +fn replace_session( + ctx: &ReducerContext, + current: &Match3DAgentSessionRow, + next: Match3DAgentSessionRow, +) { + ctx.db + .match3d_agent_session() + .session_id() + .delete(¤t.session_id); + ctx.db.match3d_agent_session().insert(next); +} + +fn replace_work( + ctx: &ReducerContext, + current: &Match3DWorkProfileRow, + next: Match3DWorkProfileRow, +) { + ctx.db + .match3d_work_profile() + .profile_id() + .delete(¤t.profile_id); + ctx.db.match3d_work_profile().insert(next); +} + +fn clone_session(row: &Match3DAgentSessionRow) -> Match3DAgentSessionRow { + Match3DAgentSessionRow { + 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: &Match3DWorkProfileRow) -> Match3DWorkProfileRow { + Match3DWorkProfileRow { + profile_id: row.profile_id.clone(), + owner_user_id: row.owner_user_id.clone(), + source_session_id: row.source_session_id.clone(), + author_display_name: row.author_display_name.clone(), + game_name: row.game_name.clone(), + theme_text: row.theme_text.clone(), + summary_text: row.summary_text.clone(), + tags_json: row.tags_json.clone(), + cover_image_src: row.cover_image_src.clone(), + cover_asset_id: row.cover_asset_id.clone(), + clear_count: row.clear_count, + difficulty: row.difficulty, + config_json: row.config_json.clone(), + publication_status: row.publication_status.clone(), + play_count: row.play_count, + updated_at: row.updated_at, + published_at: row.published_at, + } +} + +fn validate_config(config: &Match3DCreatorConfigSnapshot) -> Result<(), String> { + domain_config_from_snapshot(config) + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +fn validate_publishable_work(row: &Match3DWorkProfileRow) -> Result<(), String> { + if row.game_name.trim().is_empty() { + return Err("match3d 发布需要填写游戏名称".to_string()); + } + if row.cover_image_src.trim().is_empty() { + return Err("match3d 发布需要封面图".to_string()); + } + if parse_tags(&row.tags_json)?.is_empty() { + return Err("match3d 发布需要至少 1 个标签".to_string()); + } + validate_config(&parse_config(&row.config_json)?) +} + +fn is_work_publish_ready(row: &Match3DWorkProfileRow) -> bool { + validate_publishable_work(row).is_ok() +} + +fn default_config_from_seed(seed_text: &str) -> Match3DCreatorConfigSnapshot { + Match3DCreatorConfigSnapshot { + theme_text: clean_string(seed_text, "经典消除"), + reference_image_src: None, + clear_count: 12, + difficulty: 3, + } +} + +fn parse_config_or_default(value: &str) -> Match3DCreatorConfigSnapshot { + parse_config(value).unwrap_or_else(|_| default_config_from_seed("经典消除")) +} + +fn parse_config(value: &str) -> Result { + parse_json(value, "match3d config_json").map(|mut config: Match3DCreatorConfigSnapshot| { + config.theme_text = clean_string(&config.theme_text, "经典消除"); + config.difficulty = config + .difficulty + .clamp(MATCH3D_MIN_DIFFICULTY, MATCH3D_MAX_DIFFICULTY); + config + }) +} + +fn parse_tags(value: &str) -> Result, String> { + let parsed = parse_json::>(value, "match3d tags_json")?; + Ok(normalize_tags(parsed)) +} + +fn default_tags(theme_text: &str) -> Vec { + normalize_tags(vec![ + theme_text.to_string(), + "抓大鹅".to_string(), + "消除".to_string(), + ]) +} + +fn normalize_tags(tags: Vec) -> Vec { + let mut result = Vec::new(); + for tag in tags { + let trimmed = tag.trim(); + if !trimmed.is_empty() && !result.iter().any(|item: &String| item == trimmed) { + result.push(trimmed.to_string()); + } + if result.len() >= 6 { + break; + } + } + result +} + +fn normalize_stage(value: &str) -> String { + match value.trim() { + MATCH3D_STAGE_READY_TO_COMPILE => MATCH3D_STAGE_READY_TO_COMPILE.to_string(), + MATCH3D_STAGE_DRAFT_COMPILED => MATCH3D_STAGE_DRAFT_COMPILED.to_string(), + MATCH3D_STAGE_PUBLISHED => MATCH3D_STAGE_PUBLISHED.to_string(), + _ => MATCH3D_STAGE_COLLECTING.to_string(), + } +} + +fn domain_config_from_snapshot( + config: &Match3DCreatorConfigSnapshot, +) -> Result { + module_match3d::build_creator_config( + &config.theme_text, + config.reference_image_src.clone(), + config.clear_count, + config.difficulty, + ) +} + +fn snapshot_from_domain( + run: &DomainMatch3DRunSnapshot, + server_now_ms: i64, +) -> Match3DRunSnapshot { + Match3DRunSnapshot { + run_id: run.run_id.clone(), + profile_id: run.profile_id.clone(), + status: domain_status_to_text(run.status).to_string(), + snapshot_version: run.board_version.min(u32::MAX as u64) as u32, + started_at_ms: run.started_at_ms.min(i64::MAX as u64) as i64, + duration_limit_ms: run.duration_limit_ms.min(i64::MAX as u64) as i64, + server_now_ms, + remaining_ms: run.remaining_ms.min(i64::MAX as u64) as i64, + clear_count: run.clear_count, + total_item_count: run.total_item_count, + cleared_item_count: run.cleared_item_count, + tray_slots: run + .tray_slots + .iter() + .map(snapshot_tray_slot_from_domain) + .collect(), + items: run.items.iter().map(snapshot_item_from_domain).collect(), + failure_reason: run.failure_reason.map(domain_failure_to_text).map(str::to_string), + } +} + +fn domain_snapshot_from_snapshot( + snapshot: &Match3DRunSnapshot, + owner_user_id: &str, +) -> DomainMatch3DRunSnapshot { + DomainMatch3DRunSnapshot { + run_id: snapshot.run_id.clone(), + profile_id: snapshot.profile_id.clone(), + owner_user_id: owner_user_id.to_string(), + status: domain_status_from_text(&snapshot.status), + started_at_ms: to_u64_ms(snapshot.started_at_ms), + duration_limit_ms: to_u64_ms(snapshot.duration_limit_ms), + remaining_ms: to_u64_ms(snapshot.remaining_ms), + clear_count: snapshot.clear_count, + total_item_count: snapshot.total_item_count, + cleared_item_count: snapshot.cleared_item_count, + board_version: snapshot.snapshot_version as u64, + items: snapshot.items.iter().map(domain_item_from_snapshot).collect(), + tray_slots: snapshot + .tray_slots + .iter() + .map(domain_tray_slot_from_snapshot) + .collect(), + failure_reason: snapshot + .failure_reason + .as_deref() + .map(domain_failure_from_text), + last_confirmed_action_id: None, + } +} + +fn snapshot_item_from_domain(item: &DomainMatch3DItemSnapshot) -> Match3DItemSnapshot { + Match3DItemSnapshot { + item_instance_id: item.item_instance_id.clone(), + item_type_id: item.item_type_id.clone(), + visual_key: item.visual_key.clone(), + x: item.x, + y: item.y, + radius: item.radius, + layer: item.layer, + state: domain_item_state_to_text(item.state).to_string(), + clickable: item.clickable, + } +} + +fn domain_item_from_snapshot(item: &Match3DItemSnapshot) -> DomainMatch3DItemSnapshot { + DomainMatch3DItemSnapshot { + item_instance_id: item.item_instance_id.clone(), + item_type_id: item.item_type_id.clone(), + visual_key: item.visual_key.clone(), + x: item.x, + y: item.y, + radius: item.radius, + layer: item.layer, + state: domain_item_state_from_text(&item.state), + clickable: item.clickable, + tray_slot_index: None, + } +} + +fn snapshot_tray_slot_from_domain(slot: &DomainMatch3DTraySlot) -> Match3DTraySlotSnapshot { + Match3DTraySlotSnapshot { + slot_index: slot.slot_index, + item_instance_id: slot.item_instance_id.clone(), + item_type_id: slot.item_type_id.clone(), + visual_key: slot.visual_key.clone(), + } +} + +fn domain_tray_slot_from_snapshot(slot: &Match3DTraySlotSnapshot) -> DomainMatch3DTraySlot { + DomainMatch3DTraySlot { + slot_index: slot.slot_index, + item_instance_id: slot.item_instance_id.clone(), + item_type_id: slot.item_type_id.clone(), + visual_key: slot.visual_key.clone(), + } +} + +fn domain_status_to_text(status: DomainMatch3DRunStatus) -> &'static str { + match status { + DomainMatch3DRunStatus::Running => MATCH3D_RUN_RUNNING, + DomainMatch3DRunStatus::Won => MATCH3D_RUN_WON, + DomainMatch3DRunStatus::Failed => MATCH3D_RUN_FAILED, + DomainMatch3DRunStatus::Stopped => MATCH3D_RUN_STOPPED, + } +} + +fn domain_status_from_text(value: &str) -> DomainMatch3DRunStatus { + match value { + MATCH3D_RUN_WON | "won" => DomainMatch3DRunStatus::Won, + MATCH3D_RUN_FAILED | "failed" => DomainMatch3DRunStatus::Failed, + MATCH3D_RUN_STOPPED | "stopped" => DomainMatch3DRunStatus::Stopped, + _ => DomainMatch3DRunStatus::Running, + } +} + +fn domain_failure_to_text(reason: DomainMatch3DFailureReason) -> &'static str { + match reason { + DomainMatch3DFailureReason::TimeUp => MATCH3D_FAILURE_TIME_UP, + DomainMatch3DFailureReason::TrayFull => MATCH3D_FAILURE_TRAY_FULL, + } +} + +fn domain_failure_from_text(value: &str) -> DomainMatch3DFailureReason { + match value { + MATCH3D_FAILURE_TRAY_FULL | "tray_full" => DomainMatch3DFailureReason::TrayFull, + _ => DomainMatch3DFailureReason::TimeUp, + } +} + +fn domain_item_state_to_text(state: DomainMatch3DItemState) -> &'static str { + match state { + DomainMatch3DItemState::InBoard => MATCH3D_ITEM_IN_BOARD, + DomainMatch3DItemState::InTray => MATCH3D_ITEM_IN_TRAY, + DomainMatch3DItemState::Cleared => MATCH3D_ITEM_CLEARED, + } +} + +fn domain_item_state_from_text(value: &str) -> DomainMatch3DItemState { + match value { + MATCH3D_ITEM_IN_TRAY | "in_tray" => DomainMatch3DItemState::InTray, + MATCH3D_ITEM_CLEARED | "cleared" => DomainMatch3DItemState::Cleared, + _ => DomainMatch3DItemState::InBoard, + } +} + +fn deterministic_run_seed( + run_id: &str, + profile_id: &str, + clear_count: u32, + difficulty: u32, +) -> u64 { + let mut seed = 0xcbf2_9ce4_8422_2325_u64; + for byte in run_id.bytes().chain(profile_id.bytes()) { + seed ^= byte as u64; + seed = seed.wrapping_mul(0x0000_0100_0000_01b3); + } + seed ^ ((clear_count as u64) << 32) ^ difficulty as u64 +} + +fn to_u64_ms(value: i64) -> u64 { + value.max(0) as u64 +} + +fn compute_remaining_ms(snapshot: &Match3DRunSnapshot, server_now_ms: i64) -> i64 { + snapshot + .duration_limit_ms + .saturating_sub(server_now_ms.saturating_sub(snapshot.started_at_ms)) + .max(0) +} + +fn current_server_ms(ctx: &ReducerContext) -> i64 { + ctx.timestamp + .to_micros_since_unix_epoch() + .saturating_div(1000) +} + +fn clean_optional(value: &Option) -> Option { + value + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn clean_string(value: &str, fallback: &str) -> String { + let trimmed = value.trim(); + if trimmed.is_empty() { + fallback.to_string() + } else { + trimmed.to_string() + } +} + +fn empty_to_none(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn require_non_empty(value: &str, label: &str) -> Result<(), String> { + if value.trim().is_empty() { + Err(format!("{label} 不能为空")) + } else { + Ok(()) + } +} + +fn parse_json(value: &str, label: &str) -> Result { + serde_json::from_str(value).map_err(|error| format!("{label} 非法: {error}")) +} + +fn deserialize_snapshot(value: &str) -> Result { + parse_json(value, "match3d snapshot_json") +} + +fn to_json_string(value: &T) -> String { + serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string()) +} + +fn session_result(session: Match3DAgentSessionSnapshot) -> Match3DAgentSessionProcedureResult { + Match3DAgentSessionProcedureResult { + ok: true, + session_json: Some(to_json_string(&session)), + error_message: None, + } +} + +fn session_error(message: String) -> Match3DAgentSessionProcedureResult { + Match3DAgentSessionProcedureResult { + ok: false, + session_json: None, + error_message: Some(message), + } +} + +fn work_result(work: Match3DWorkSnapshot) -> Match3DWorkProcedureResult { + Match3DWorkProcedureResult { + ok: true, + work_json: Some(to_json_string(&work)), + error_message: None, + } +} + +fn work_error(message: String) -> Match3DWorkProcedureResult { + Match3DWorkProcedureResult { + ok: false, + work_json: None, + error_message: Some(message), + } +} + +fn run_result(run: Match3DRunSnapshot) -> Match3DRunProcedureResult { + Match3DRunProcedureResult { + ok: true, + run_json: Some(to_json_string(&run)), + error_message: None, + } +} + +fn run_error(message: String) -> Match3DRunProcedureResult { + Match3DRunProcedureResult { + ok: false, + run_json: None, + error_message: Some(message), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn match3d_total_items_follow_clear_count() { + let work = Match3DWorkProfileRow { + profile_id: "profile-1".to_string(), + owner_user_id: "user-1".to_string(), + source_session_id: "session-1".to_string(), + author_display_name: "作者".to_string(), + game_name: "水果抓大鹅".to_string(), + theme_text: "水果".to_string(), + summary_text: "水果主题".to_string(), + tags_json: "[\"水果\"]".to_string(), + cover_image_src: "/cover.png".to_string(), + cover_asset_id: String::new(), + clear_count: 4, + difficulty: 3, + config_json: to_json_string(&Match3DCreatorConfigSnapshot { + theme_text: "水果".to_string(), + reference_image_src: None, + clear_count: 4, + difficulty: 3, + }), + publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(), + play_count: 0, + updated_at: Timestamp::from_micros_since_unix_epoch(1), + published_at: None, + }; + let snapshot = build_initial_run_snapshot("run-1", &work, 10); + assert_eq!(snapshot.total_item_count, 12); + assert_eq!(snapshot.items.len(), 12); + } + + #[test] + fn match3d_domain_click_bridge_clears_three_items() { + let snapshot = Match3DRunSnapshot { + run_id: "run-1".to_string(), + profile_id: "profile-1".to_string(), + status: MATCH3D_RUN_RUNNING.to_string(), + snapshot_version: 1, + started_at_ms: 0, + duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS, + server_now_ms: 0, + remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS, + clear_count: 1, + total_item_count: 3, + cleared_item_count: 0, + tray_slots: (0..MATCH3D_TRAY_SLOT_COUNT) + .map(|slot_index| Match3DTraySlotSnapshot { + slot_index, + item_instance_id: (slot_index < 2).then(|| format!("item-{slot_index}")), + item_type_id: (slot_index < 3).then(|| "type-1".to_string()), + visual_key: (slot_index < 3).then(|| "visual-1".to_string()), + }) + .collect(), + items: (0..3) + .map(|index| Match3DItemSnapshot { + item_instance_id: format!("item-{index}"), + item_type_id: "type-1".to_string(), + visual_key: "visual-1".to_string(), + x: 0.0, + y: 0.0, + radius: 0.1, + layer: index, + state: if index < 2 { + MATCH3D_ITEM_IN_TRAY.to_string() + } else { + MATCH3D_ITEM_IN_BOARD.to_string() + }, + clickable: index == 2, + }) + .collect(), + failure_reason: None, + }; + + let domain_run = domain_snapshot_from_snapshot(&snapshot, "user-1"); + let confirmation = confirm_domain_click_at( + &domain_run, + &DomainMatch3DClickInput { + run_id: "run-1".to_string(), + owner_user_id: "user-1".to_string(), + item_instance_id: "item-2".to_string(), + client_action_id: "action-1".to_string(), + snapshot_version: 1, + clicked_at_ms: 10, + }, + ) + .expect("domain click should be confirmed"); + let next = snapshot_from_domain(&confirmation.run, 10); + + assert!(confirmation.accepted); + assert_eq!(confirmation.cleared_item_instance_ids.len(), 3); + assert!( + next + .tray_slots + .iter() + .all(|slot| slot.item_instance_id.is_none()) + ); + assert!( + next + .items + .iter() + .all(|item| item.state == MATCH3D_ITEM_CLEARED) + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/match3d/tables.rs b/server-rs/crates/spacetime-module/src/match3d/tables.rs new file mode 100644 index 00000000..2c9ece38 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/match3d/tables.rs @@ -0,0 +1,86 @@ +use crate::*; + +#[spacetimedb::table( + accessor = match3d_agent_session, + index(accessor = by_match3d_agent_session_owner_user_id, btree(columns = [owner_user_id])) +)] +pub struct Match3DAgentSessionRow { + #[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 = match3d_agent_message, + index(accessor = by_match3d_agent_message_session_id, btree(columns = [session_id])) +)] +pub struct Match3DAgentMessageRow { + #[primary_key] + pub(crate) message_id: String, + pub(crate) session_id: String, + pub(crate) role: String, + pub(crate) kind: String, + pub(crate) text: String, + pub(crate) created_at: Timestamp, +} + +#[spacetimedb::table( + accessor = match3d_work_profile, + index(accessor = by_match3d_work_owner_user_id, btree(columns = [owner_user_id])), + index(accessor = by_match3d_work_publication_status, btree(columns = [publication_status])) +)] +pub struct Match3DWorkProfileRow { + #[primary_key] + pub(crate) profile_id: String, + pub(crate) owner_user_id: String, + pub(crate) source_session_id: String, + pub(crate) author_display_name: String, + pub(crate) game_name: String, + pub(crate) theme_text: String, + pub(crate) summary_text: String, + pub(crate) tags_json: String, + pub(crate) cover_image_src: String, + pub(crate) cover_asset_id: String, + pub(crate) clear_count: u32, + pub(crate) difficulty: u32, + pub(crate) config_json: String, + pub(crate) publication_status: String, + pub(crate) play_count: u32, + pub(crate) updated_at: Timestamp, + pub(crate) published_at: Option, +} + +#[spacetimedb::table( + accessor = match3d_runtime_run, + index(accessor = by_match3d_run_owner_user_id, btree(columns = [owner_user_id])), + index(accessor = by_match3d_run_profile_id, btree(columns = [profile_id])) +)] +pub struct Match3DRuntimeRunRow { + #[primary_key] + pub(crate) run_id: String, + pub(crate) owner_user_id: String, + pub(crate) profile_id: String, + pub(crate) status: String, + pub(crate) snapshot_version: u32, + pub(crate) started_at_ms: i64, + pub(crate) duration_limit_ms: i64, + pub(crate) finished_at_ms: i64, + pub(crate) elapsed_ms: i64, + pub(crate) clear_count: u32, + pub(crate) total_item_count: u32, + pub(crate) cleared_item_count: u32, + pub(crate) failure_reason: String, + pub(crate) snapshot_json: String, + pub(crate) created_at: Timestamp, + pub(crate) updated_at: Timestamp, +} diff --git a/server-rs/crates/spacetime-module/src/match3d/types.rs b/server-rs/crates/spacetime-module/src/match3d/types.rs new file mode 100644 index 00000000..17d6dbf2 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/match3d/types.rs @@ -0,0 +1,332 @@ +use crate::*; +use serde::{Deserialize, Serialize}; + +pub const MATCH3D_DEFAULT_DURATION_LIMIT_MS: i64 = 600_000; +pub const MATCH3D_TRAY_SLOT_COUNT: u32 = 7; +pub const MATCH3D_VISUAL_VARIANT_COUNT: u32 = 10; +pub const MATCH3D_MIN_DIFFICULTY: u32 = 1; +pub const MATCH3D_MAX_DIFFICULTY: u32 = 10; + +pub const MATCH3D_STAGE_COLLECTING: &str = "Collecting"; +pub const MATCH3D_STAGE_READY_TO_COMPILE: &str = "ReadyToCompile"; +pub const MATCH3D_STAGE_DRAFT_COMPILED: &str = "DraftCompiled"; +pub const MATCH3D_STAGE_PUBLISHED: &str = "Published"; + +pub const MATCH3D_ROLE_USER: &str = "user"; +pub const MATCH3D_ROLE_ASSISTANT: &str = "assistant"; +pub const MATCH3D_KIND_TEXT: &str = "text"; + +pub const MATCH3D_PUBLICATION_DRAFT: &str = "Draft"; +pub const MATCH3D_PUBLICATION_PUBLISHED: &str = "Published"; + +pub const MATCH3D_RUN_RUNNING: &str = "Running"; +pub const MATCH3D_RUN_WON: &str = "Won"; +pub const MATCH3D_RUN_FAILED: &str = "Failed"; +pub const MATCH3D_RUN_STOPPED: &str = "Stopped"; + +pub const MATCH3D_FAILURE_TIME_UP: &str = "TimeUp"; +pub const MATCH3D_FAILURE_TRAY_FULL: &str = "TrayFull"; + +pub const MATCH3D_CLICK_ACCEPTED: &str = "Accepted"; +pub const MATCH3D_CLICK_REJECTED_NOT_CLICKABLE: &str = "RejectedNotClickable"; +pub const MATCH3D_CLICK_REJECTED_ALREADY_MOVED: &str = "RejectedAlreadyMoved"; +pub const MATCH3D_CLICK_REJECTED_TRAY_FULL: &str = "RejectedTrayFull"; +pub const MATCH3D_CLICK_VERSION_CONFLICT: &str = "VersionConflict"; +pub const MATCH3D_CLICK_RUN_FINISHED: &str = "RunFinished"; + +pub const MATCH3D_ITEM_IN_BOARD: &str = "InBoard"; +pub const MATCH3D_ITEM_IN_TRAY: &str = "InTray"; +pub const MATCH3D_ITEM_CLEARED: &str = "Cleared"; + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DAgentSessionCreateInput { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub welcome_message_id: String, + pub welcome_message_text: String, + pub config_json: Option, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DAgentSessionGetInput { + pub session_id: String, + pub owner_user_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DAgentMessageSubmitInput { + pub session_id: String, + pub owner_user_id: String, + pub user_message_id: String, + pub user_message_text: String, + pub submitted_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DAgentMessageFinalizeInput { + pub session_id: String, + pub owner_user_id: String, + pub assistant_message_id: Option, + pub assistant_reply_text: Option, + pub config_json: Option, + pub progress_percent: u32, + pub stage: String, + pub updated_at_micros: i64, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DDraftCompileInput { + pub session_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub author_display_name: String, + pub game_name: Option, + pub summary_text: Option, + pub tags_json: Option, + pub cover_image_src: Option, + pub cover_asset_id: Option, + pub compiled_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DWorkUpdateInput { + pub profile_id: String, + pub owner_user_id: String, + pub game_name: String, + pub theme_text: String, + pub summary_text: String, + pub tags_json: String, + pub cover_image_src: String, + pub cover_asset_id: String, + pub clear_count: u32, + pub difficulty: u32, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DWorkPublishInput { + pub profile_id: String, + pub owner_user_id: String, + pub published_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DWorksListInput { + pub owner_user_id: String, + pub published_only: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DWorkGetInput { + pub profile_id: String, + pub owner_user_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DWorkDeleteInput { + pub profile_id: String, + pub owner_user_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DRunStartInput { + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub started_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DRunGetInput { + pub run_id: String, + pub owner_user_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DRunClickInput { + pub run_id: String, + pub owner_user_id: String, + pub item_instance_id: String, + pub client_snapshot_version: u32, + pub client_event_id: String, + pub clicked_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DRunStopInput { + pub run_id: String, + pub owner_user_id: String, + pub stopped_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DRunRestartInput { + pub source_run_id: String, + pub next_run_id: String, + pub owner_user_id: String, + pub restarted_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DRunTimeUpInput { + pub run_id: String, + pub owner_user_id: String, + pub finished_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DAgentSessionProcedureResult { + pub ok: bool, + pub session_json: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DWorkProcedureResult { + pub ok: bool, + pub work_json: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DWorksProcedureResult { + pub ok: bool, + pub items_json: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DRunProcedureResult { + pub ok: bool, + pub run_json: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DClickItemProcedureResult { + pub ok: bool, + pub status: String, + pub run_json: Option, + pub accepted_item_instance_id: Option, + pub cleared_item_instance_ids: Vec, + pub failure_reason: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Match3DCreatorConfigSnapshot { + pub theme_text: String, + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Match3DAgentMessageSnapshot { + pub message_id: String, + pub session_id: String, + pub role: String, + pub kind: String, + pub text: String, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Match3DDraftSnapshot { + pub profile_id: String, + pub game_name: String, + pub theme_text: String, + pub summary_text: String, + pub tags: Vec, + pub clear_count: u32, + pub difficulty: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Match3DAgentSessionSnapshot { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub current_turn: u32, + pub progress_percent: u32, + pub stage: String, + pub config: Match3DCreatorConfigSnapshot, + pub draft: Option, + pub messages: Vec, + pub last_assistant_reply: String, + pub published_profile_id: Option, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Match3DWorkSnapshot { + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: String, + pub author_display_name: String, + pub game_name: String, + pub theme_text: String, + pub summary_text: String, + pub tags: Vec, + pub cover_image_src: String, + pub cover_asset_id: String, + pub clear_count: u32, + pub difficulty: u32, + pub config: Match3DCreatorConfigSnapshot, + pub publication_status: String, + pub publish_ready: bool, + pub play_count: u32, + pub updated_at_micros: i64, + pub published_at_micros: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Match3DItemSnapshot { + pub item_instance_id: String, + pub item_type_id: String, + pub visual_key: String, + pub x: f32, + pub y: f32, + pub radius: f32, + pub layer: u32, + pub state: String, + pub clickable: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Match3DTraySlotSnapshot { + pub slot_index: u32, + pub item_instance_id: Option, + pub item_type_id: Option, + pub visual_key: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Match3DRunSnapshot { + pub run_id: String, + pub profile_id: String, + pub status: String, + pub snapshot_version: u32, + pub started_at_ms: i64, + pub duration_limit_ms: i64, + pub server_now_ms: i64, + pub remaining_ms: i64, + pub clear_count: u32, + pub total_item_count: u32, + pub cleared_item_count: u32, + pub tray_slots: Vec, + pub items: Vec, + pub failure_reason: Option, +} diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 2cd8dea2..82f541cd 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -4,6 +4,9 @@ use spacetimedb_lib::sats::de::serde::DeserializeWrapper; use spacetimedb_lib::sats::ser::serde::SerializeWrapper; use std::collections::HashSet; +use crate::match3d::tables::{ + match3d_agent_message, match3d_agent_session, match3d_runtime_run, match3d_work_profile, +}; use crate::puzzle::{ puzzle_agent_message, puzzle_agent_session, puzzle_runtime_run, puzzle_work_profile, }; @@ -187,6 +190,10 @@ macro_rules! migration_tables { puzzle_agent_message, puzzle_work_profile, puzzle_runtime_run, + match3d_agent_session, + match3d_agent_message, + match3d_work_profile, + match3d_runtime_run, big_fish_creation_session, big_fish_agent_message, big_fish_asset_slot diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index bd44dfe5..38be4b6c 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -1261,8 +1261,9 @@ fn start_puzzle_run_tx( } let entry_profile = build_puzzle_work_profile_from_row(&entry_profile_row)?; let started_at_ms = micros_to_millis(input.started_at_micros); - let mut run = module_puzzle::start_run_at(input.run_id.clone(), &entry_profile, 0, started_at_ms) - .map_err(|error| error.to_string())?; + let mut run = + module_puzzle::start_run_at(input.run_id.clone(), &entry_profile, 0, started_at_ms) + .map_err(|error| error.to_string())?; let current_grid_size = run.current_grid_size; let current_profile_id = entry_profile.profile_id.clone(); hydrate_puzzle_leaderboard_entries( @@ -1502,13 +1503,11 @@ fn use_puzzle_runtime_prop_tx( let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?; let current_run = deserialize_run(&row.snapshot_json)?; let next_run = match input.prop_kind.as_str() { - "freezeTime" | "freeze_time" => { - module_puzzle::apply_puzzle_freeze_time_at( - ¤t_run, - micros_to_millis(input.used_at_micros), - ) - .map_err(|error| error.to_string())? - } + "freezeTime" | "freeze_time" => module_puzzle::apply_puzzle_freeze_time_at( + ¤t_run, + micros_to_millis(input.used_at_micros), + ) + .map_err(|error| error.to_string())?, "hint" => module_puzzle::set_puzzle_run_paused_at( ¤t_run, false, diff --git a/src/Match3DPlaygroundApp.tsx b/src/Match3DPlaygroundApp.tsx new file mode 100644 index 00000000..68a56989 --- /dev/null +++ b/src/Match3DPlaygroundApp.tsx @@ -0,0 +1,61 @@ +import { useCallback, useRef, useState } from 'react'; + +import type { + Match3DClickItemRequest, + Match3DRunSnapshot, +} from '../packages/shared/src/contracts/match3dRuntime'; +import { Match3DRuntimeShell } from './components/match3d-runtime'; +import { + confirmLocalMatch3DClick, + resolveLocalMatch3DTimer, + startLocalMatch3DRun, +} from './services/match3d-runtime'; + +function buildInitialRun() { + return startLocalMatch3DRun(12); +} + +export default function Match3DPlaygroundApp() { + const [run, setRun] = useState(buildInitialRun); + const authorityRunRef = useRef(run); + + const syncRun = useCallback((nextRun: Match3DRunSnapshot) => { + setRun(nextRun); + }, []); + + const handleClickItem = useCallback(async (payload: Match3DClickItemRequest) => { + const result = await confirmLocalMatch3DClick(authorityRunRef.current, payload); + authorityRunRef.current = result.run; + setRun(result.run); + return result; + }, []); + + const handleRestart = useCallback(() => { + const nextRun = buildInitialRun(); + authorityRunRef.current = nextRun; + setRun(nextRun); + }, []); + + const handleExit = useCallback(() => { + window.location.assign('/'); + }, []); + + const handleTimeExpired = useCallback(() => { + const nextRun = resolveLocalMatch3DTimer(authorityRunRef.current); + authorityRunRef.current = nextRun; + setRun(nextRun); + }, []); + + return ( + + ); +} diff --git a/src/components/creation-agent/CreationAgentWorkspace.tsx b/src/components/creation-agent/CreationAgentWorkspace.tsx index 0bb5ec86..45538f73 100644 --- a/src/components/creation-agent/CreationAgentWorkspace.tsx +++ b/src/components/creation-agent/CreationAgentWorkspace.tsx @@ -1,4 +1,4 @@ -import { ArrowLeft, Paperclip, Send, Sparkles } from 'lucide-react'; +import { ArrowLeft, ImagePlus, Paperclip, Send, Sparkles, X } from 'lucide-react'; import type { ChangeEvent } from 'react'; import { useEffect, useRef, useState } from 'react'; @@ -75,15 +75,21 @@ type CreationAgentWorkspaceProps = { isBusy?: boolean; error?: string | null; quickActions?: CreationAgentQuickAction[]; + referenceImagePreviewSrc?: string | null; + referenceImageLabel?: string | null; + referenceImageError?: string | null; onBack: () => void; onSubmitText: (text: string, quickActionKey?: string) => void; onPrimaryAction: () => void; onQuickAction?: (action: CreationAgentQuickAction) => void; + onReferenceImageChange?: (file: File) => Promise | void; + onClearReferenceImage?: () => void; }; const AUTO_SCROLL_FOLLOW_THRESHOLD_PX = 96; const DOCUMENT_INPUT_ACCEPT = '.txt,.md,.markdown,.csv,.json,text/plain,text/markdown,text/csv,application/json'; +const REFERENCE_IMAGE_INPUT_ACCEPT = 'image/png,image/jpeg,image/webp'; function uniqueRecommendedReplies(recommendedReplies: string[] = []) { return [ @@ -290,19 +296,26 @@ export function CreationAgentWorkspace({ isBusy = false, error = null, quickActions = [], + referenceImagePreviewSrc = null, + referenceImageLabel = null, + referenceImageError = null, onBack, onSubmitText, onPrimaryAction, onQuickAction, + onReferenceImageChange, + onClearReferenceImage, }: CreationAgentWorkspaceProps) { const [draftText, setDraftText] = useState(''); const [documentInputError, setDocumentInputError] = useState( null, ); const [isParsingDocumentInput, setIsParsingDocumentInput] = useState(false); + const [isReadingReferenceImage, setIsReadingReferenceImage] = useState(false); // 统一聊天区只在用户仍停留在底部附近时跟随新内容,避免流式回复持续抢走阅读位置。 const messageListRef = useRef(null); const documentInputRef = useRef(null); + const referenceImageInputRef = useRef(null); const shouldAutoScrollRef = useRef(true); useEffect(() => { @@ -376,7 +389,7 @@ export function CreationAgentWorkspace({ const submit = () => { const text = draftText.trim(); - if (!text || isBusy || isParsingDocumentInput) { + if (!text || isBusy || isParsingDocumentInput || isReadingReferenceImage) { return; } @@ -399,6 +412,10 @@ export function CreationAgentWorkspace({ documentInputRef.current?.click(); }; + const openReferenceImagePicker = () => { + referenceImageInputRef.current?.click(); + }; + const handleDocumentInputChange = async ( event: ChangeEvent, ) => { @@ -426,6 +443,25 @@ export function CreationAgentWorkspace({ } }; + const handleReferenceImageInputChange = async ( + event: ChangeEvent, + ) => { + const file = event.target.files?.[0] ?? null; + event.target.value = ''; + + if (!file || isBusy || isReadingReferenceImage || !onReferenceImageChange) { + return; + } + + setIsReadingReferenceImage(true); + + try { + await onReferenceImageChange(file); + } finally { + setIsReadingReferenceImage(false); + } + }; + return (
- {documentInputError || error ? ( + {referenceImagePreviewSrc ? ( +
+
+ 参考图 +
+
+ {referenceImageLabel || '已选择参考图'} +
+ {onClearReferenceImage ? ( + + ) : null} +
+ ) : null} + + {documentInputError || referenceImageError || error ? (
- {documentInputError || error} + {documentInputError || referenceImageError || error}
) : null} @@ -560,6 +623,15 @@ export function CreationAgentWorkspace({ className="hidden" onChange={handleDocumentInputChange} /> + {onReferenceImageChange ? ( + + ) : null} + {onReferenceImageChange ? ( + + ) : null}