Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
This commit is contained in:
@@ -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,而是以“抓大鹅”模板为外壳、以前端即时反馈保证手感、以后端权威确认保证规则可信、以独立玩法域为工程边界的单局经典消除玩法链路;首轮先跑通题材创作、结果页、试玩、发布和单局清空胜负闭环。
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
8. 子面板返回按钮固定摆在面板右上角
|
||||
9. 设置首页与各级子面板都必须定义单一滚动容器,列表内容必须可稳定滚动,禁止外层与内层同时争夺滚动
|
||||
10. 二级或三级面板打开后,下层内容必须进入不可交互状态,并把焦点主动转移到当前面板内;禁止对仍保留焦点的祖先节点使用 `aria-hidden`
|
||||
11. 右上角头像、账号入口等身份入口直达“账号信息”时,只允许展示“账号信息”面板本身,不再同步弹出或保留“设置与账号安全”首页;只有“设置”入口才打开设置首页
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,835 @@
|
||||
# 抓大鹅 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: Timestamp`。
|
||||
12. `updated_at: Timestamp`。
|
||||
|
||||
## 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: Timestamp`。
|
||||
|
||||
## 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: Timestamp`。
|
||||
17. `published_at: Option<Timestamp>`,未发布为 `None`。
|
||||
|
||||
## 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: Timestamp`。
|
||||
16. `updated_at: Timestamp`。
|
||||
|
||||
## 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` 或仓库现有等价脚本通过。
|
||||
|
||||
B3 当前落地状态:
|
||||
|
||||
1. `server-rs/crates/spacetime-module/src/match3d/` 已承载 Match3D 的表、procedure 输入输出类型和 procedure 实现,并由 `server-rs/crates/spacetime-module/src/lib.rs` 挂载导出。
|
||||
2. `migration.rs` 已纳入 `match3d_agent_session`、`match3d_agent_message`、`match3d_work_profile`、`match3d_runtime_run` 四张表,后续字段变更继续按 `SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md` 追加兼容字段。
|
||||
3. 运行态 `start_match3d_run`、`click_match3d_item`、`stop_match3d_run`、`finish_match3d_time_up` 通过适配层调用 `module-match3d` 的领域规则,SpacetimeDB 层只负责归属校验、事务写入、权威快照持久化和 procedure JSON 返回。
|
||||
4. B3 对外仍返回当前首版快照字段 `snapshotVersion / clientSnapshotVersion` 对应语义;`module-match3d` 内部的 `board_version` 只在适配层中转换,避免影响并行中的 B4/F3 接入。
|
||||
5. SpacetimeDB module 的有效验收命令是 `spacetime build --module-path crates/spacetime-module`;不要用普通 native `cargo test -p spacetime-module` 作为验收口径,因为该 crate 会链接 SpacetimeDB 宿主符号。
|
||||
|
||||
### 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 和路由推进。
|
||||
111
docs/technical/MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md
Normal file
111
docs/technical/MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# 抓大鹅 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 是纯领域层,不读写数据库,不访问网络,不依赖浏览器或文件系统。
|
||||
|
||||
本阶段虽然不落 SpacetimeDB 表和 procedure,但领域模型已经为后续 SpacetimeDB 接入预留 `spacetime-types` feature。后续在 `spacetime-module` 内使用这些类型时,仍必须遵守 reducer 确定性、`ctx.sender()` 鉴权和表结构迁移约束。
|
||||
|
||||
核心类型:
|
||||
|
||||
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`。
|
||||
|
||||
运行态领域内部使用 `board_version` 表示权威快照版本;HTTP 与 TypeScript shared contracts 对外使用 `snapshotVersion` / `clientSnapshotVersion`,由后续 `api-server` facade 做字段映射。
|
||||
|
||||
## 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。
|
||||
@@ -0,0 +1,91 @@
|
||||
# 抓大鹅 Match3D F1 创作入口与 Agent UI 落地记录 2026-04-30
|
||||
|
||||
## 1. 阶段边界
|
||||
|
||||
本文件承接《MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md》的 F1 包。
|
||||
|
||||
F1 只处理前端创作入口、Agent 工作区和等待后端 B5 facade 前的 mock client。它不实现运行态规则,不修改 SpacetimeDB 表,不接 `api-server` 路由。
|
||||
|
||||
## 2. 本阶段写入范围
|
||||
|
||||
1. `src/components/platform-entry/`
|
||||
2. `src/components/match3d-creation/`
|
||||
3. `src/services/match3d-creation/`
|
||||
4. `packages/shared/src/contracts/match3dAgent.ts`
|
||||
|
||||
其中 `packages/shared/src/contracts/match3dAgent.ts` 作为 F1 与后续 B5 的 DTO 对齐点,F1 mock client 不自建脱离共享契约的临时类型。
|
||||
|
||||
## 3. 入口接入
|
||||
|
||||
平台入口新增可见创作类型:
|
||||
|
||||
```text
|
||||
id: match3d
|
||||
title: 抓大鹅
|
||||
subtitle: 经典消除玩法
|
||||
badge: 可创建
|
||||
```
|
||||
|
||||
入口来源统一走 `getVisiblePlatformCreationTypes()`,因此创作首页首屏卡带与“选择创作类型”弹层会同时出现抓大鹅。
|
||||
|
||||
## 4. Agent 工作区
|
||||
|
||||
新增 `Match3DAgentWorkspace`,复用通用 `CreationAgentWorkspace`。
|
||||
|
||||
Agent 只收集三类锚点:
|
||||
|
||||
1. 题材主题。
|
||||
2. 需要消除次数。
|
||||
3. 难度。
|
||||
|
||||
工作区支持参考图片上传入口。图片在 F1 中先以 Data URL 形式随消息 payload 带给 mock client;B5 接入后由后端 facade 替换为正式资产上传与引用。
|
||||
|
||||
UI 中不默认展示玩法规则长文,只展示进度、锚点、聊天内容和必要按钮。
|
||||
|
||||
## 5. mock client
|
||||
|
||||
新增 `src/services/match3d-creation/match3dCreationClient.ts`。
|
||||
|
||||
mock client 提供:
|
||||
|
||||
1. `createMatch3DCreationSession`
|
||||
2. `getMatch3DCreationSession`
|
||||
3. `streamMatch3DCreationMessage`
|
||||
4. `executeMatch3DCreationAction`
|
||||
|
||||
mock 行为:
|
||||
|
||||
1. 创建本地会话。
|
||||
2. 从中文输入中提取题材、消除次数和难度。
|
||||
3. 支持“自动配置”。
|
||||
4. 当三项配置完整时允许执行 `match3d_compile_draft`。
|
||||
5. 编译后返回 `draft_ready` 会话和草稿。
|
||||
|
||||
## 6. 结果承接
|
||||
|
||||
F1 新增 `Match3DDraftReadyView` 作为草稿生成后的临时承接页,只展示草稿基础信息并允许返回 Agent 修改。
|
||||
|
||||
正式结果页的基础信息编辑、封面图、试玩、发布由 F2 接入,F1 不在这里模拟发布。
|
||||
|
||||
## 7. 后续替换点
|
||||
|
||||
B5 完成后,只需要把 `match3dCreationClient` 的本地 Map mock 替换为 HTTP/SSE facade:
|
||||
|
||||
```text
|
||||
POST /api/creation/match3d/sessions
|
||||
GET /api/creation/match3d/sessions/:sessionId
|
||||
POST /api/creation/match3d/sessions/:sessionId/messages/stream
|
||||
POST /api/creation/match3d/sessions/:sessionId/compile
|
||||
```
|
||||
|
||||
`PlatformEntryFlowShellImpl` 与 `Match3DAgentWorkspace` 不应再改一轮业务字段。
|
||||
|
||||
## 8. 验收口径
|
||||
|
||||
1. 创作首页能看到“抓大鹅 / 经典消除玩法”。
|
||||
2. 弹层选择“抓大鹅”能进入 Agent 工作区。
|
||||
3. 输入题材、消除次数、难度后进度到 `100%`。
|
||||
4. 点击“生成结果页”进入草稿承接页。
|
||||
5. 可从草稿承接页返回 Agent 修改。
|
||||
6. `npm run check:encoding` 通过。
|
||||
7. `npm run typecheck` 通过。
|
||||
394
docs/technical/MATCH3D_F2_RESULT_AND_PUBLISH_2026-04-30.md
Normal file
394
docs/technical/MATCH3D_F2_RESULT_AND_PUBLISH_2026-04-30.md
Normal file
@@ -0,0 +1,394 @@
|
||||
# 抓大鹅 Match3D F2 结果页与发布技术方案
|
||||
|
||||
日期:`2026-04-30`
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
本文件承接 [MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md](./MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md),只冻结 F2 开发范围:
|
||||
|
||||
1. Match3D 待发布结果页。
|
||||
2. 作品基础信息编辑。
|
||||
3. 发布前试玩入口。
|
||||
4. 发布入口。
|
||||
5. 已发布作品二次编辑恢复口径。
|
||||
|
||||
本阶段不实现运行态即时反馈 UI,不实现 SpacetimeDB 表与 procedure,不实现 `api-server` facade。F2 可以先基于 shared contracts 与 mock client 开发,等待 B4+B5 接入真实 HTTP。
|
||||
|
||||
---
|
||||
|
||||
## 2. 前置依赖
|
||||
|
||||
F2 依赖以下已冻结文档:
|
||||
|
||||
1. PRD:[AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md](../prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md)
|
||||
2. A0:[MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md](./MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md)
|
||||
3. B1+B2:[MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md](./MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md)
|
||||
|
||||
F2 可在 B4+B5 之前并行开发,但必须遵守 B2 的 TypeScript contract,不得在前端私自扩字段。
|
||||
|
||||
---
|
||||
|
||||
## 3. 本阶段做
|
||||
|
||||
1. 新增 Match3D 结果页组件目录。
|
||||
2. 新增 Match3D works service 目录。
|
||||
3. 展示草稿配置摘要:
|
||||
- 题材主题
|
||||
- 需要消除次数
|
||||
- 难度
|
||||
- 参考图片预览
|
||||
4. 支持编辑发布基础信息:
|
||||
- 游戏名称
|
||||
- 标签
|
||||
- 封面图
|
||||
5. 支持发布前试玩入口。
|
||||
6. 支持试玩中止后回到结果页继续编辑。
|
||||
7. 支持发布入口。
|
||||
8. 支持已发布作品二次编辑的前端恢复路径。
|
||||
|
||||
---
|
||||
|
||||
## 4. 本阶段不做
|
||||
|
||||
1. 不生成题材物品素材。
|
||||
2. 不生成额外封面图;封面图只接收已有图片、上传图片或后端已有占位结果。
|
||||
3. 不要求试玩通关后才能发布。
|
||||
4. 不实现运行态点击、飞入、三消等即时反馈。
|
||||
5. 不实现首页、分类页和广场投影。
|
||||
6. 不实现排行榜。
|
||||
7. 不在 UI 中默认展示玩法规则说明长文。
|
||||
8. 不把发布校验只写在前端;前端只做即时提示,后端 publish gate 是最终门槛。
|
||||
|
||||
---
|
||||
|
||||
## 5. 文件落点
|
||||
|
||||
## 5.1 前端组件
|
||||
|
||||
新增:
|
||||
|
||||
```text
|
||||
src/components/match3d-result/
|
||||
```
|
||||
|
||||
建议文件:
|
||||
|
||||
```text
|
||||
src/components/match3d-result/Match3DResultView.tsx
|
||||
src/components/match3d-result/Match3DResultView.test.tsx
|
||||
src/components/match3d-result/index.ts
|
||||
```
|
||||
|
||||
如组件变大,可后续拆分:
|
||||
|
||||
```text
|
||||
Match3DResultHeader.tsx
|
||||
Match3DResultBasicsForm.tsx
|
||||
Match3DResultConfigPreview.tsx
|
||||
Match3DResultPublishPanel.tsx
|
||||
```
|
||||
|
||||
首版不要过早拆太多文件,优先保持可读和低冲突。
|
||||
|
||||
## 5.2 前端 service
|
||||
|
||||
新增:
|
||||
|
||||
```text
|
||||
src/services/match3d-works/
|
||||
```
|
||||
|
||||
建议文件:
|
||||
|
||||
```text
|
||||
src/services/match3d-works/match3dWorksClient.ts
|
||||
src/services/match3d-works/index.ts
|
||||
```
|
||||
|
||||
F2 只负责 works 维度:
|
||||
|
||||
1. 读取作品详情。
|
||||
2. 更新作品基础信息。
|
||||
3. 发布作品。
|
||||
4. 删除作品可后置,若 F4 需要再补。
|
||||
|
||||
运行态启动接口归 `src/services/match3d-runtime/`,F2 只调用上层传入的 `onStartTestRun`。
|
||||
|
||||
---
|
||||
|
||||
## 6. shared contracts 使用
|
||||
|
||||
F2 只消费 B2 已冻结的 TypeScript contract:
|
||||
|
||||
```text
|
||||
packages/shared/src/contracts/match3dWorks.ts
|
||||
packages/shared/src/contracts/match3dAgent.ts
|
||||
packages/shared/src/contracts/match3dRuntime.ts
|
||||
```
|
||||
|
||||
必要类型:
|
||||
|
||||
1. `Match3DWorkProfile`
|
||||
2. `Match3DWorkSummary`
|
||||
3. `Match3DWorkUpdateRequest`
|
||||
4. `Match3DPublishRequest`
|
||||
5. `Match3DPublishResult`
|
||||
6. `Match3DCompileDraftResult`
|
||||
7. `Match3DCreatorConfig`
|
||||
|
||||
F2 不新增独立的前端私有数据结构来表达作品真相;只允许使用局部表单状态承载未保存输入。
|
||||
|
||||
---
|
||||
|
||||
## 7. 结果页 props contract
|
||||
|
||||
建议 `Match3DResultView` props:
|
||||
|
||||
```ts
|
||||
type Match3DResultViewProps = {
|
||||
profile: Match3DWorkProfile;
|
||||
draft?: Match3DCompileDraftResult | null;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onStartTestRun: (profile: Match3DWorkProfile) => void;
|
||||
onPublish: (payload: Match3DPublishRequest) => void;
|
||||
onSaved?: (profile: Match3DWorkProfile) => void;
|
||||
};
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
1. `profile` 是结果页当前作品真相源。
|
||||
2. `draft` 只用于展示草稿生成附加信息;不能覆盖 `profile` 的发布字段。
|
||||
3. `onStartTestRun` 进入 F3/B5 运行态链路。
|
||||
4. `onPublish` 可以先由 mock client 实现,B5 完成后替换为真实 HTTP。
|
||||
5. `onSaved` 用于把自动保存后的 profile 回写给上层流程控制器。
|
||||
|
||||
---
|
||||
|
||||
## 8. 页面内容顺序
|
||||
|
||||
结果页保持单列表,不做多 Tab。
|
||||
|
||||
固定顺序:
|
||||
|
||||
1. 顶部返回与保存状态。
|
||||
2. 封面图。
|
||||
3. 游戏名称。
|
||||
4. 标签。
|
||||
5. 题材主题。
|
||||
6. 需要消除次数。
|
||||
7. 难度。
|
||||
8. 参考图片预览。
|
||||
9. 试玩按钮。
|
||||
10. 发布按钮。
|
||||
|
||||
UI 只呈现必要信息,不在页面中展示玩法规则说明长文。
|
||||
|
||||
---
|
||||
|
||||
## 9. 字段编辑规则
|
||||
|
||||
## 9.1 游戏名称
|
||||
|
||||
1. 必填。
|
||||
2. 首版建议前端限制 `1~30` 个中文字符等价长度。
|
||||
3. 默认值来自 Agent 确认题材或系统生成草稿。
|
||||
|
||||
## 9.2 标签
|
||||
|
||||
1. 必填。
|
||||
2. 首版建议 `3~6` 个标签,与拼图发布门槛保持一致。
|
||||
3. 输入支持中文逗号、英文逗号、顿号、换行拆分。
|
||||
4. 前端需要去重和去空格。
|
||||
|
||||
## 9.3 封面图
|
||||
|
||||
1. 必填。
|
||||
2. F2 可先复用参考图片、占位封面或用户上传图。
|
||||
3. 图片真实存储由现有资产链或后续 B5 facade 处理。
|
||||
4. 前端不得把本地临时 blob URL 当作已发布封面真相。
|
||||
|
||||
## 9.4 题材主题、需要消除次数、难度
|
||||
|
||||
首版结果页允许展示并可编辑这些配置。
|
||||
|
||||
修改后必须同步保存到作品 profile:
|
||||
|
||||
1. `themeText`
|
||||
2. `clearCount`
|
||||
3. `difficulty`
|
||||
|
||||
注意:
|
||||
|
||||
1. `clearCount` 必须为正整数。
|
||||
2. `difficulty` 必须在 `1~10`。
|
||||
3. 修改配置后,下一次试玩必须基于最新保存配置启动。
|
||||
|
||||
---
|
||||
|
||||
## 10. 自动保存
|
||||
|
||||
F2 建议实现自动保存,口径参考拼图结果页:
|
||||
|
||||
1. 输入变更后 `600ms` debounce。
|
||||
2. 只保存结果页可编辑字段。
|
||||
3. 保存中展示轻量状态。
|
||||
4. 保存失败展示轻量错误,不弹长说明。
|
||||
5. 发布前必须等待最后一次保存完成,或发布 payload 直接携带当前表单字段。
|
||||
|
||||
建议状态:
|
||||
|
||||
```ts
|
||||
type Match3DAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 发布门槛
|
||||
|
||||
前端即时 blocker:
|
||||
|
||||
1. 游戏名称为空。
|
||||
2. 标签数量不在 `3~6`。
|
||||
3. 封面图为空。
|
||||
4. `clearCount` 不是正整数。
|
||||
5. `difficulty` 不在 `1~10`。
|
||||
|
||||
后端 publish gate 是最终门槛,前端不得绕过。
|
||||
|
||||
发布不要求试玩通关。
|
||||
|
||||
---
|
||||
|
||||
## 12. 试玩入口
|
||||
|
||||
结果页提供“试玩”入口。
|
||||
|
||||
行为:
|
||||
|
||||
1. 点击试玩前先保存当前表单。
|
||||
2. 保存成功后调用 `onStartTestRun(profile)`。
|
||||
3. 上层进入 Match3D 运行态。
|
||||
4. 运行态停止或返回后,回到同一个结果页继续编辑。
|
||||
|
||||
F2 不实现运行态本身;只冻结结果页如何发起试玩。
|
||||
|
||||
---
|
||||
|
||||
## 13. 发布接口
|
||||
|
||||
F2 service 建议接口:
|
||||
|
||||
```ts
|
||||
const MATCH3D_WORKS_API_BASE = '/api/creation/match3d/works';
|
||||
|
||||
export async function getMatch3DWorkDetail(profileId: string): Promise<Match3DWorkDetailResponse>;
|
||||
|
||||
export async function updateMatch3DWork(
|
||||
profileId: string,
|
||||
payload: Match3DWorkUpdateRequest,
|
||||
): Promise<Match3DWorkMutationResponse>;
|
||||
|
||||
export async function publishMatch3DWork(
|
||||
profileId: string,
|
||||
payload: Match3DPublishRequest,
|
||||
): Promise<Match3DPublishResult>;
|
||||
```
|
||||
|
||||
后续 B5 必须提供同名 HTTP facade 或在 service 层做最小适配。
|
||||
|
||||
---
|
||||
|
||||
## 14. Mock client 口径
|
||||
|
||||
F2 可以在真实 B5 接口完成前使用 mock client。
|
||||
|
||||
要求:
|
||||
|
||||
1. mock 数据必须来自 shared contracts。
|
||||
2. mock profile 字段必须覆盖发布必填项。
|
||||
3. mock publish 只能返回“可发布成功”的本地结果,不得伪造平台广场投影。
|
||||
4. B5 接入后,mock 只能保留为测试 fixture。
|
||||
|
||||
---
|
||||
|
||||
## 15. 已发布作品二次编辑
|
||||
|
||||
进入自己已发布 Match3D 作品时,结果页应支持二次编辑。
|
||||
|
||||
规则:
|
||||
|
||||
1. 优先通过 `sourceSessionId` 恢复原创作 session。
|
||||
2. 如果没有 session,则通过 `profileId` 读取作品详情进入结果页。
|
||||
3. 二次发布不得创建新作品,必须覆盖同一 `profileId`。
|
||||
4. 不清零 `playCount`。
|
||||
5. 不改变作品归属。
|
||||
|
||||
---
|
||||
|
||||
## 16. 与其它分支的接口边界
|
||||
|
||||
## 16.1 依赖 F1
|
||||
|
||||
F1 负责创建会话和 Agent UI。F2 接收 F1 编译出的 `profile / draft`,不重复实现 Agent 对话。
|
||||
|
||||
## 16.2 依赖 F3
|
||||
|
||||
F3 负责运行态 UI。F2 只提供 `onStartTestRun` 入口。
|
||||
|
||||
## 16.3 依赖 B5
|
||||
|
||||
B5 负责真实 HTTP facade。F2 的 service path 和 DTO 必须按本文冻结,避免后续替换 mock 时改组件结构。
|
||||
|
||||
## 16.4 依赖 F4
|
||||
|
||||
F4 负责首页、分类页和广场分发。F2 发布成功后只需要把返回 profile 交给上层;不直接刷新广场列表。
|
||||
|
||||
---
|
||||
|
||||
## 17. 测试要求
|
||||
|
||||
建议新增:
|
||||
|
||||
```text
|
||||
src/components/match3d-result/Match3DResultView.test.tsx
|
||||
```
|
||||
|
||||
覆盖:
|
||||
|
||||
1. 展示游戏名称、标签、封面图、题材、需要消除次数和难度。
|
||||
2. 游戏名称为空时发布按钮阻断。
|
||||
3. 标签数量不足时发布按钮阻断。
|
||||
4. `clearCount` 非正整数时发布按钮阻断。
|
||||
5. `difficulty` 超出 `1~10` 时发布按钮阻断。
|
||||
6. 点击试玩前触发保存。
|
||||
7. 发布不要求试玩通关。
|
||||
|
||||
service 测试可在 B5 接入后补齐。
|
||||
|
||||
---
|
||||
|
||||
## 18. 验收命令
|
||||
|
||||
F2 文档分支:
|
||||
|
||||
```powershell
|
||||
npm run check:encoding -- docs/technical/MATCH3D_F2_RESULT_AND_PUBLISH_2026-04-30.md docs/technical/README.md
|
||||
```
|
||||
|
||||
F2 前端实现分支:
|
||||
|
||||
```powershell
|
||||
npm run check:encoding
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
如新增组件测试,补跑对应 `vitest`。
|
||||
|
||||
---
|
||||
|
||||
## 19. 一句话结论
|
||||
|
||||
F2 只负责把 Match3D 草稿变成可编辑、可试玩、可发布的作品工作台;它必须复用平台结果页和发布体验,发布不要求试玩通关,并为 B5 真实后端接口与 F3 运行态试玩入口保留清晰边界。
|
||||
@@ -7,6 +7,10 @@
|
||||
- [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md):冻结 SpacetimeDB 表结构变更约束、自动迁移可接受范围、冲突后的系统行为,以及保留旧数据的增量迁移流程;凡涉及 `spacetime publish`、表字段调整或 `migration.rs` 对齐时优先参考。
|
||||
- [SPACETIMEDB_LOCAL_REPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md](./SPACETIMEDB_LOCAL_REPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md):记录本地 standalone 启动时报 `mismatched database identity` 的 root-dir/replica 数据残留根因、备份重建步骤和脚本诊断口径。
|
||||
- [LLM_MODEL_ROUTING_RPG_AND_CREATION_2026-04-30.md](./LLM_MODEL_ROUTING_RPG_AND_CREATION_2026-04-30.md):冻结 RPG 运行时剧情推理使用 `doubao-seed-character-251128` 的 `/chat/completions`,以及所有模板创作大模型推理使用 `deepseek-v3-2-251201` 的 `/responses`。
|
||||
- [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 的边界。
|
||||
- [MATCH3D_F1_CREATION_ENTRY_AND_AGENT_UI_2026-04-30.md](./MATCH3D_F1_CREATION_ENTRY_AND_AGENT_UI_2026-04-30.md):记录抓大鹅 F1 创作入口、Agent 工作区、参考图入口、本地 mock client 与后续 B5 HTTP facade 替换点。
|
||||
- [MATCH3D_F2_RESULT_AND_PUBLISH_2026-04-30.md](./MATCH3D_F2_RESULT_AND_PUBLISH_2026-04-30.md):冻结抓大鹅 F2 结果页、基础信息编辑、发布前试玩入口、发布门槛、自动保存和已发布作品二次编辑恢复口径。
|
||||
- [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 规避参数。
|
||||
|
||||
@@ -142,7 +142,7 @@ npm run deploy:rust:remote
|
||||
5. 执行 `cargo build -p spacetime-module --release --target wasm32-unknown-unknown --manifest-path server-rs/Cargo.toml`,并把 `spacetime_module.wasm` 复制到目标目录。
|
||||
6. 把仓库根目录的 `.env` 与 `.env.local` 分别复制到目标目录根部和目标目录的 `web/` 下;复制后统一移除 UTF-8 BOM 与 CRLF,并把 `GENARRATIVE_SPACETIME_DATABASE` 覆盖为本次 `--database` 参数,避免 Jenkins 工作区里残留的旧 `.env.local` 覆盖发布包目标库。
|
||||
7. 在目标目录写入 `web-server.mjs`,用于托管 `web/` 并把 `/api/*`、`/generated-*`、`/healthz` 反代到本包内的 `api-server`。
|
||||
8. 在目标目录写入 `start.sh` 与 `stop.sh`;`start.sh` 会先按 `KEY=value` 子集加载发布目录根部的 `.env`、`.env.local`,兼容 UTF-8 BOM 与 CRLF,再回退到构建时通过 `--database`、`--api-port`、`--web-port`、`--spacetime-host`、`--spacetime-port` 写入的默认值,并默认导出 `NO_COLOR=1` 与 `CARGO_TERM_COLOR=never`,避免 ANSI 控制码写入日志文件;同时按 Ubuntu 发布环境使用发布目录内 `.spacetimedb/` 作为 root-dir,不再额外设置 `--data-dir`,启动前先执行 `sync_ubuntu_spacetime_install`,优先从 `/usr/.local/share/spacetime/bin/<version>/spacetimedb-cli` 或 `$HOME/.local/share/spacetime/bin/<version>/spacetimedb-cli` 同步到 `.spacetimedb/bin/current/spacetimedb-cli`,当前线上 `spacetime` 入口为 `/usr/local/bin/spacetime`;启动参数为 `spacetime --root-dir ./.spacetimedb start --edition standalone --listen-addr <host>:<port>`,探活必须确认 `server ping` 输出包含 `Server is online:`;普通启动先无清库发布,若 publish 输出可判定为 schema 冲突,则自动导出旧库、清库发布新 wasm、导入回灌;如果以 `--clear-database` 启动,则内部 `spacetime publish` 会追加 `-c=on-conflict`,代表人工确认清库,不触发自动回灌。
|
||||
8. 在目标目录写入 `start.sh` 与 `stop.sh`;`start.sh` 会先按 `KEY=value` 子集加载发布目录根部的 `.env`、`.env.local`,兼容 UTF-8 BOM 与 CRLF,再回退到构建时通过 `--database`、`--api-port`、`--web-host`、`--web-port`、`--spacetime-host`、`--spacetime-port` 写入的默认值,其中 Web 默认只监听 `127.0.0.1`;并默认导出 `NO_COLOR=1` 与 `CARGO_TERM_COLOR=never`,避免 ANSI 控制码写入日志文件;同时按 Ubuntu 发布环境使用发布目录内 `.spacetimedb/` 作为 root-dir,不再额外设置 `--data-dir`,启动前先执行 `sync_ubuntu_spacetime_install`,优先从 `/usr/.local/share/spacetime/bin/<version>/spacetimedb-cli` 或 `$HOME/.local/share/spacetime/bin/<version>/spacetimedb-cli` 同步到 `.spacetimedb/bin/current/spacetimedb-cli`,当前线上 `spacetime` 入口为 `/usr/local/bin/spacetime`;启动参数为 `spacetime --root-dir ./.spacetimedb start --edition standalone --listen-addr <host>:<port>`,探活必须确认 `server ping` 输出包含 `Server is online:`;普通启动先无清库发布,若 publish 输出可判定为 schema 冲突,则自动导出旧库、清库发布新 wasm、导入回灌;如果以 `--clear-database` 启动,则内部 `spacetime publish` 会追加 `-c=on-conflict`,代表人工确认清库,不触发自动回灌。
|
||||
9. 默认执行 `scp -r -i ~\.ssh\dsk.pem build/<timestamp> ubuntu@82.157.175.59:/home/ubuntu/genarrative/` 上传发布包。
|
||||
|
||||
SpacetimeDB database 名称必须匹配 `^[a-z0-9]+(-[a-z0-9]+)*$`:只能使用小写字母、数字,并用单个短横线分隔;大写字母、点号、下划线、首尾短横线和连续短横线都会触发 `spacetime publish` 的 `invalid characters in database name`。发布包构建脚本和 `start.sh` 都会提前拦截这类非法名称。
|
||||
@@ -174,6 +174,7 @@ build/<timestamp>/
|
||||
```bash
|
||||
npm run build:rust:ubuntu -- --name 20260422-153000
|
||||
npm run build:rust:ubuntu -- --database genarrative-dev --web-port 3000 --api-port 8082 --spacetime-port 3101
|
||||
npm run build:rust:ubuntu -- --database genarrative-dev --web-host 127.0.0.1 --web-port 3000
|
||||
npm run build:rust:ubuntu -- --skip-upload
|
||||
```
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ spacetime sql <db> "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` |
|
||||
@@ -449,6 +450,53 @@ SELECT * FROM puzzle_runtime_run WHERE run_id = '<run_id>';
|
||||
SELECT * FROM puzzle_runtime_run WHERE owner_user_id = '<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 = '<session_id>';
|
||||
SELECT * FROM match3d_agent_session WHERE owner_user_id = '<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 = '<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<Timestamp>`。
|
||||
- 索引:`owner_user_id`, `publication_status`。
|
||||
|
||||
```sql
|
||||
SELECT * FROM match3d_work_profile WHERE profile_id = '<profile_id>';
|
||||
SELECT * FROM match3d_work_profile WHERE owner_user_id = '<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 = '<run_id>';
|
||||
SELECT * FROM match3d_runtime_run WHERE owner_user_id = '<user_id>' ORDER BY updated_at DESC;
|
||||
SELECT * FROM match3d_runtime_run WHERE profile_id = '<profile_id>';
|
||||
```
|
||||
|
||||
## 大鱼吃小鱼表
|
||||
|
||||
### `big_fish_creation_session`
|
||||
|
||||
126
packages/shared/src/contracts/match3dAgent.ts
Normal file
126
packages/shared/src/contracts/match3dAgent.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 抓大鹅 Match3D 创作 Agent 共享契约。
|
||||
* 字段按 HTTP facade 的 camelCase DTO 命名,后端领域层 snake_case 字段由 facade 映射。
|
||||
*/
|
||||
export type Match3DCreationStage =
|
||||
| 'collecting'
|
||||
| 'collecting_config'
|
||||
| 'ready_to_compile'
|
||||
| 'draft_ready'
|
||||
| 'draft_compiled'
|
||||
| '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;
|
||||
129
packages/shared/src/contracts/match3dRuntime.ts
Normal file
129
packages/shared/src/contracts/match3dRuntime.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 抓大鹅 Match3D 运行态共享契约。
|
||||
* 前端可以使用 Flying 做即时表现;后端权威快照只应返回 InBoard、InTray、Cleared。
|
||||
*/
|
||||
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;
|
||||
}
|
||||
49
packages/shared/src/contracts/match3dWorks.ts
Normal file
49
packages/shared/src/contracts/match3dWorks.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 抓大鹅 Match3D 作品读写共享契约。
|
||||
* 首版作品发布必须补齐游戏名称、标签、封面、题材、消除次数和难度。
|
||||
*/
|
||||
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;
|
||||
}
|
||||
@@ -3,6 +3,15 @@ export * from './contracts/auth';
|
||||
export type * from './contracts/bigFish';
|
||||
export * from './contracts/common';
|
||||
export type * from './contracts/customWorldAgent';
|
||||
export * from './contracts/match3dAgent';
|
||||
export * from './contracts/match3dRuntime';
|
||||
export * from './contracts/match3dWorks';
|
||||
export * from './contracts/puzzleAgentActions';
|
||||
export * from './contracts/puzzleAgentDraft';
|
||||
export * from './contracts/puzzleAgentSession';
|
||||
export * from './contracts/puzzleResultPreview';
|
||||
export * from './contracts/puzzleRuntimeSession';
|
||||
export * from './contracts/puzzleWorkSummary';
|
||||
export * from './contracts/rpgAgentActions';
|
||||
export * from './contracts/rpgAgentAnchors';
|
||||
export * from './contracts/rpgAgentDraft';
|
||||
@@ -11,12 +20,6 @@ export * from './contracts/rpgCreationFixtures';
|
||||
export * from './contracts/rpgCreationPreview';
|
||||
export * from './contracts/rpgCreationResultView';
|
||||
export * from './contracts/rpgCreationWorkSummary';
|
||||
export * from './contracts/puzzleAgentActions';
|
||||
export * from './contracts/puzzleAgentDraft';
|
||||
export * from './contracts/puzzleAgentSession';
|
||||
export * from './contracts/puzzleResultPreview';
|
||||
export * from './contracts/puzzleRuntimeSession';
|
||||
export * from './contracts/puzzleWorkSummary';
|
||||
export * from './contracts/rpgRuntimeChat';
|
||||
export * from './contracts/rpgRuntimeQuestAssist';
|
||||
export * from './contracts/rpgRuntimeStoryAction';
|
||||
|
||||
@@ -191,7 +191,7 @@ BUILD_NAME="$(date +%Y%m%d-%H%M%S)"
|
||||
DATABASE="xushi-p4wfr"
|
||||
API_HOST="127.0.0.1"
|
||||
API_PORT="8082"
|
||||
WEB_HOST="0.0.0.0"
|
||||
WEB_HOST="127.0.0.1"
|
||||
WEB_PORT="25001"
|
||||
SPACETIME_HOST="127.0.0.1"
|
||||
SPACETIME_PORT="3101"
|
||||
@@ -421,7 +421,7 @@ import {fileURLToPath} from 'node:url';
|
||||
|
||||
const releaseDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const webRoot = path.join(releaseDir, 'web');
|
||||
const webHost = process.env.GENARRATIVE_WEB_HOST || '0.0.0.0';
|
||||
const webHost = process.env.GENARRATIVE_WEB_HOST || '127.0.0.1';
|
||||
const webPort = Number(process.env.GENARRATIVE_WEB_PORT || '3000');
|
||||
const apiTarget = new URL(process.env.GENARRATIVE_API_TARGET || 'http://127.0.0.1:8082');
|
||||
const indexPath = path.join(webRoot, 'index.html');
|
||||
@@ -1215,7 +1215,7 @@ cat >"${TARGET_DIR}/README.md" <<'EOF'
|
||||
|
||||
- 启动时会先加载发布目录根部的 \`.env\` 与 \`.env.local\`,再回退到脚本内默认值。
|
||||
- 环境文件复制进发布包时会移除 UTF-8 BOM 与 CRLF;启动时也会按 \`KEY=value\` 子集解析,跳过不合法行。
|
||||
- 脚本内默认值来自构建时的 `--database`、`--api-port`、`--web-port`、`--spacetime-host`、`--spacetime-port` 参数。
|
||||
- 脚本内默认值来自构建时的 `--database`、`--api-port`、`--web-host`、`--web-port`、`--spacetime-host`、`--spacetime-port` 参数;Web 默认只监听 `127.0.0.1`。
|
||||
- 默认导出 \`NO_COLOR=1\` 与 \`CARGO_TERM_COLOR=never\`,避免 ANSI 颜色控制码写入日志文件;如确有需要可在启动前显式覆盖。
|
||||
- \`GENARRATIVE_WEB_HOST\` / \`GENARRATIVE_WEB_PORT\`
|
||||
- \`GENARRATIVE_API_HOST\` / \`GENARRATIVE_API_PORT\` / \`GENARRATIVE_API_LOG\`
|
||||
|
||||
10
server-rs/Cargo.lock
generated
10
server-rs/Cargo.lock
generated
@@ -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"
|
||||
@@ -2682,6 +2691,7 @@ dependencies = [
|
||||
"module-combat",
|
||||
"module-custom-world",
|
||||
"module-inventory",
|
||||
"module-match3d",
|
||||
"module-npc",
|
||||
"module-progression",
|
||||
"module-puzzle",
|
||||
|
||||
@@ -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",
|
||||
|
||||
14
server-rs/crates/module-match3d/Cargo.toml
Normal file
14
server-rs/crates/module-match3d/Cargo.toml
Normal file
@@ -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 }
|
||||
1033
server-rs/crates/module-match3d/src/lib.rs
Normal file
1033
server-rs/crates/module-match3d/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
137
server-rs/crates/shared-contracts/src/match3d_agent.rs
Normal file
137
server-rs/crates/shared-contracts/src/match3d_agent.rs
Normal file
@@ -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<String>,
|
||||
#[serde(default)]
|
||||
pub theme_text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reference_image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
pub clear_count: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub difficulty: Option<u32>,
|
||||
}
|
||||
|
||||
#[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<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExecuteMatch3DAgentActionRequest {
|
||||
pub action: String,
|
||||
#[serde(default)]
|
||||
pub game_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub summary: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub cover_image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
pub clear_count: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub difficulty: Option<u32>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
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<String>,
|
||||
#[serde(default)]
|
||||
pub cover_image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reference_image_src: Option<String>,
|
||||
pub clear_count: u32,
|
||||
pub difficulty: u32,
|
||||
pub publish_ready: bool,
|
||||
pub blockers: Vec<String>,
|
||||
}
|
||||
|
||||
#[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<Match3DCreatorConfigResponse>,
|
||||
#[serde(default)]
|
||||
pub draft: Option<Match3DResultDraftResponse>,
|
||||
pub messages: Vec<Match3DAgentMessageResponse>,
|
||||
#[serde(default)]
|
||||
pub last_assistant_reply: Option<String>,
|
||||
#[serde(default)]
|
||||
pub published_profile_id: Option<String>,
|
||||
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));
|
||||
}
|
||||
}
|
||||
125
server-rs/crates/shared-contracts/src/match3d_runtime.rs
Normal file
125
server-rs/crates/shared-contracts/src/match3d_runtime.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
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 {
|
||||
#[serde(default)]
|
||||
pub run_id: Option<String>,
|
||||
pub item_instance_id: String,
|
||||
pub client_snapshot_version: u64,
|
||||
pub client_event_id: String,
|
||||
pub clicked_at_ms: 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<u32>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(default)]
|
||||
pub item_type_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub visual_key: Option<String>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
/// 对外 HTTP 快照版本。领域层内部字段名为 board_version,facade 需要在这里完成映射。
|
||||
pub snapshot_version: u64,
|
||||
pub started_at_ms: u64,
|
||||
pub duration_limit_ms: u64,
|
||||
#[serde(default)]
|
||||
pub server_now_ms: Option<u64>,
|
||||
pub remaining_ms: u64,
|
||||
pub clear_count: u32,
|
||||
pub total_item_count: u32,
|
||||
pub cleared_item_count: u32,
|
||||
pub items: Vec<Match3DItemSnapshotResponse>,
|
||||
pub tray_slots: Vec<Match3DTraySlotResponse>,
|
||||
#[serde(default)]
|
||||
pub failure_reason: Option<String>,
|
||||
#[serde(default)]
|
||||
pub last_confirmed_action_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Match3DClickConfirmationResponse {
|
||||
pub accepted: bool,
|
||||
#[serde(default)]
|
||||
pub reject_reason: Option<String>,
|
||||
#[serde(default)]
|
||||
pub entered_slot_index: Option<u32>,
|
||||
pub cleared_item_instance_ids: Vec<String>,
|
||||
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 {
|
||||
run_id: Some("run-1".to_string()),
|
||||
item_instance_id: "item-1".to_string(),
|
||||
client_snapshot_version: 7,
|
||||
client_event_id: "event-1".to_string(),
|
||||
clicked_at_ms: 12_345,
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(payload["runId"], json!("run-1"));
|
||||
assert_eq!(payload["itemInstanceId"], json!("item-1"));
|
||||
assert_eq!(payload["clientSnapshotVersion"], json!(7));
|
||||
assert_eq!(payload["clientEventId"], json!("event-1"));
|
||||
assert_eq!(payload["clickedAtMs"], json!(12_345));
|
||||
}
|
||||
}
|
||||
89
server-rs/crates/shared-contracts/src/match3d_works.rs
Normal file
89
server-rs/crates/shared-contracts/src/match3d_works.rs
Normal file
@@ -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<String>,
|
||||
#[serde(default)]
|
||||
pub cover_image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reference_image_src: Option<String>,
|
||||
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<String>,
|
||||
pub game_name: String,
|
||||
pub theme_text: String,
|
||||
pub summary: String,
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub cover_image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reference_image_src: Option<String>,
|
||||
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<String>,
|
||||
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<Match3DWorkSummaryResponse>,
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
|
||||
1641
server-rs/crates/spacetime-module/src/match3d/mod.rs
Normal file
1641
server-rs/crates/spacetime-module/src/match3d/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
86
server-rs/crates/spacetime-module/src/match3d/tables.rs
Normal file
86
server-rs/crates/spacetime-module/src/match3d/tables.rs
Normal file
@@ -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<Timestamp>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
332
server-rs/crates/spacetime-module/src/match3d/types.rs
Normal file
332
server-rs/crates/spacetime-module/src/match3d/types.rs
Normal file
@@ -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<String>,
|
||||
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<String>,
|
||||
pub assistant_reply_text: Option<String>,
|
||||
pub config_json: Option<String>,
|
||||
pub progress_percent: u32,
|
||||
pub stage: String,
|
||||
pub updated_at_micros: i64,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub summary_text: Option<String>,
|
||||
pub tags_json: Option<String>,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub cover_asset_id: Option<String>,
|
||||
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<String>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct Match3DWorkProcedureResult {
|
||||
pub ok: bool,
|
||||
pub work_json: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct Match3DWorksProcedureResult {
|
||||
pub ok: bool,
|
||||
pub items_json: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct Match3DRunProcedureResult {
|
||||
pub ok: bool,
|
||||
pub run_json: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct Match3DClickItemProcedureResult {
|
||||
pub ok: bool,
|
||||
pub status: String,
|
||||
pub run_json: Option<String>,
|
||||
pub accepted_item_instance_id: Option<String>,
|
||||
pub cleared_item_instance_ids: Vec<String>,
|
||||
pub failure_reason: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Match3DCreatorConfigSnapshot {
|
||||
pub theme_text: String,
|
||||
pub reference_image_src: Option<String>,
|
||||
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<String>,
|
||||
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<Match3DDraftSnapshot>,
|
||||
pub messages: Vec<Match3DAgentMessageSnapshot>,
|
||||
pub last_assistant_reply: String,
|
||||
pub published_profile_id: Option<String>,
|
||||
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<String>,
|
||||
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<i64>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub item_type_id: Option<String>,
|
||||
pub visual_key: Option<String>,
|
||||
}
|
||||
|
||||
#[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<Match3DTraySlotSnapshot>,
|
||||
pub items: Vec<Match3DItemSnapshot>,
|
||||
pub failure_reason: Option<String>,
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -188,6 +191,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
|
||||
|
||||
61
src/Match3DPlaygroundApp.tsx
Normal file
61
src/Match3DPlaygroundApp.tsx
Normal file
@@ -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<Match3DRunSnapshot>(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 (
|
||||
<Match3DRuntimeShell
|
||||
run={run}
|
||||
onBack={handleExit}
|
||||
onRestart={handleRestart}
|
||||
onOptimisticRunChange={syncRun}
|
||||
onClickItem={handleClickItem}
|
||||
onTimeExpired={handleTimeExpired}
|
||||
error={null}
|
||||
isBusy={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ const baseUser: AuthUser = {
|
||||
|
||||
function renderAccountModal(overrides?: {
|
||||
user?: AuthUser;
|
||||
entryMode?: 'settings' | 'account';
|
||||
riskBlocks?: AuthRiskBlockSummary[];
|
||||
sessions?: AuthSessionSummary[];
|
||||
auditLogs?: AuthAuditLogEntry[];
|
||||
@@ -41,6 +42,7 @@ function renderAccountModal(overrides?: {
|
||||
<AccountModal
|
||||
user={overrides?.user ?? baseUser}
|
||||
isOpen
|
||||
entryMode={overrides?.entryMode ?? 'settings'}
|
||||
initialSection={overrides?.initialSection ?? null}
|
||||
platformTheme="light"
|
||||
riskBlocks={overrides?.riskBlocks ?? []}
|
||||
@@ -91,6 +93,21 @@ test('settings header uses a generic title instead of the phone number', () => {
|
||||
expect(screen.queryByRole('button', { name: '退出全部设备' })).toBeNull();
|
||||
});
|
||||
|
||||
test('direct account entry does not render the settings shell as another dialog', () => {
|
||||
renderAccountModal({ entryMode: 'account' });
|
||||
|
||||
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
expect(accountDialog).toBeTruthy();
|
||||
expect(screen.queryByRole('dialog', { name: '设置与账号安全' })).toBeNull();
|
||||
expect(screen.queryByText('设置与账号安全')).toBeNull();
|
||||
expect(
|
||||
within(accountDialog).getByRole('button', { name: '关闭' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(accountDialog).queryByRole('button', { name: '返回' }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('account actions open in independent panels instead of inline expansion', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -131,9 +148,9 @@ test('nested settings panels keep back navigation without an extra close action'
|
||||
expect(
|
||||
within(accountDialog).getByRole('button', { name: '返回' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
accountHeader?.lastElementChild?.textContent?.includes('返回'),
|
||||
).toBe(true);
|
||||
expect(accountHeader?.lastElementChild?.textContent?.includes('返回')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
within(accountDialog).queryByRole('button', { name: '关闭' }),
|
||||
).toBeNull();
|
||||
|
||||
@@ -20,6 +20,7 @@ import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||
type AccountModalProps = {
|
||||
user: AuthUser;
|
||||
isOpen: boolean;
|
||||
entryMode?: 'settings' | 'account';
|
||||
initialSection?: PlatformSettingsSection | null;
|
||||
platformTheme: PlatformTheme;
|
||||
riskBlocks: AuthRiskBlockSummary[];
|
||||
@@ -159,6 +160,7 @@ function OverlayPanel({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
standalone = false,
|
||||
onBack,
|
||||
onClose,
|
||||
children,
|
||||
@@ -167,15 +169,12 @@ function OverlayPanel({
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
standalone?: boolean;
|
||||
onBack?: () => void;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-10 flex items-end bg-black/20 backdrop-blur-[2px] sm:items-center sm:justify-center sm:p-4"
|
||||
onClick={onBack ?? onClose}
|
||||
>
|
||||
const panel = (
|
||||
<div
|
||||
className="platform-auth-card flex max-h-full w-full min-h-0 flex-col overflow-hidden rounded-[28px] p-5 sm:max-w-3xl sm:p-6"
|
||||
role="dialog"
|
||||
@@ -225,6 +224,18 @@ function OverlayPanel({
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (standalone) {
|
||||
return panel;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-10 flex items-end bg-black/20 backdrop-blur-[2px] sm:items-center sm:justify-center sm:p-4"
|
||||
onClick={onBack ?? onClose}
|
||||
>
|
||||
{panel}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -266,6 +277,7 @@ function ThemeOptionCard({
|
||||
export function AccountModal({
|
||||
user,
|
||||
isOpen,
|
||||
entryMode = 'settings',
|
||||
initialSection = null,
|
||||
platformTheme,
|
||||
riskBlocks,
|
||||
@@ -314,6 +326,7 @@ export function AccountModal({
|
||||
const sectionTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const changePhoneTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const passwordTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const isDirectAccountMode = entryMode === 'account';
|
||||
|
||||
const focusAfterNextPaint = useCallback((element: HTMLElement | null) => {
|
||||
if (!element) {
|
||||
@@ -347,7 +360,11 @@ export function AccountModal({
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveSection(normalizeSettingsSection(initialSection));
|
||||
setActiveSection(
|
||||
isDirectAccountMode
|
||||
? 'account'
|
||||
: normalizeSettingsSection(initialSection),
|
||||
);
|
||||
setIsChangePhonePanelOpen(false);
|
||||
setIsPasswordPanelOpen(false);
|
||||
setAccountNotice('');
|
||||
@@ -356,7 +373,13 @@ export function AccountModal({
|
||||
passwordTriggerRef.current = null;
|
||||
resetChangePhoneDraft();
|
||||
resetPasswordDraft();
|
||||
}, [initialSection, isOpen, resetChangePhoneDraft, resetPasswordDraft]);
|
||||
}, [
|
||||
initialSection,
|
||||
isDirectAccountMode,
|
||||
isOpen,
|
||||
resetChangePhoneDraft,
|
||||
resetPasswordDraft,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const settingsHome = settingsHomeRef.current;
|
||||
@@ -446,13 +469,18 @@ export function AccountModal({
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="platform-auth-card relative flex h-[min(100%,calc(100vh-2rem))] w-full max-w-5xl min-h-0 flex-col overflow-hidden rounded-[28px] p-5 sm:p-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="设置与账号安全"
|
||||
className={
|
||||
isDirectAccountMode
|
||||
? 'relative flex max-h-full w-full max-w-3xl min-h-0 flex-col overflow-hidden'
|
||||
: 'platform-auth-card relative flex h-[min(100%,calc(100vh-2rem))] w-full max-w-5xl min-h-0 flex-col overflow-hidden rounded-[28px] p-5 sm:p-6'
|
||||
}
|
||||
role={isDirectAccountMode ? undefined : 'dialog'}
|
||||
aria-modal={isDirectAccountMode ? undefined : true}
|
||||
aria-label={isDirectAccountMode ? undefined : '设置与账号安全'}
|
||||
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{!isDirectAccountMode ? (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-2xl font-semibold text-[var(--platform-text-strong)]">
|
||||
@@ -467,7 +495,9 @@ export function AccountModal({
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isDirectAccountMode ? (
|
||||
<div className="mt-5 min-h-0 flex-1 overflow-y-auto overscroll-y-contain pr-1">
|
||||
<div ref={settingsHomeRef} className="flex min-h-0 flex-col gap-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
@@ -487,6 +517,7 @@ export function AccountModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeSection === 'appearance' ? (
|
||||
<OverlayPanel
|
||||
@@ -538,7 +569,8 @@ export function AccountModal({
|
||||
eyebrow="身份信息"
|
||||
title="账号信息"
|
||||
description="统一查看身份、安全状态、登录设备与最近操作。"
|
||||
onBack={closeSectionPanel}
|
||||
standalone={isDirectAccountMode}
|
||||
onBack={isDirectAccountMode ? undefined : closeSectionPanel}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="flex min-h-0 flex-col gap-4">
|
||||
@@ -671,7 +703,10 @@ export function AccountModal({
|
||||
<span>{block.title}</span>
|
||||
<span className="text-xs">
|
||||
剩余约{' '}
|
||||
{Math.max(1, Math.ceil(block.remainingSeconds / 60))}{' '}
|
||||
{Math.max(
|
||||
1,
|
||||
Math.ceil(block.remainingSeconds / 60),
|
||||
)}{' '}
|
||||
分钟
|
||||
</span>
|
||||
</div>
|
||||
@@ -965,7 +1000,9 @@ export function AccountModal({
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="首次设置可留空"
|
||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||
onChange={(event) =>
|
||||
setCurrentPassword(event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
|
||||
@@ -84,6 +84,9 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
const [wechatLoading, setWechatLoading] = useState(false);
|
||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||
const [showSettingsModal, setShowSettingsModal] = useState(false);
|
||||
const [settingsEntryMode, setSettingsEntryMode] = useState<
|
||||
'settings' | 'account'
|
||||
>('settings');
|
||||
const [initialSettingsSection, setInitialSettingsSection] =
|
||||
useState<PlatformSettingsSection | null>(null);
|
||||
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
|
||||
@@ -126,6 +129,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setStatus('unauthenticated');
|
||||
setShowLoginModal(false);
|
||||
setShowSettingsModal(false);
|
||||
setSettingsEntryMode('settings');
|
||||
setInitialSettingsSection(null);
|
||||
setSessions([]);
|
||||
setAuditLogs([]);
|
||||
@@ -169,6 +173,12 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setError('');
|
||||
}, []);
|
||||
|
||||
const closeSettingsModal = useCallback(() => {
|
||||
setShowSettingsModal(false);
|
||||
setSettingsEntryMode('settings');
|
||||
setInitialSettingsSection(null);
|
||||
}, []);
|
||||
|
||||
const openLoginModal = useCallback(
|
||||
(postLoginAction?: (() => void) | null) => {
|
||||
if (readyUser) {
|
||||
@@ -192,6 +202,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
const openSettingsModal = useCallback(
|
||||
(section?: PlatformSettingsSection) => {
|
||||
if (readyUser) {
|
||||
setSettingsEntryMode('settings');
|
||||
setInitialSettingsSection(section ?? null);
|
||||
setShowSettingsModal(true);
|
||||
return;
|
||||
@@ -203,8 +214,15 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
);
|
||||
|
||||
const openAccountModal = useCallback(() => {
|
||||
openSettingsModal('account');
|
||||
}, [openSettingsModal]);
|
||||
if (readyUser) {
|
||||
setSettingsEntryMode('account');
|
||||
setInitialSettingsSection('account');
|
||||
setShowSettingsModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
openLoginModal();
|
||||
}, [openLoginModal, readyUser]);
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
@@ -224,7 +242,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
|
||||
const resolveGuestFallback = async () => {
|
||||
try {
|
||||
const options = await loadLoginOptions();
|
||||
await loadLoginOptions();
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
@@ -555,6 +573,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
<AccountModal
|
||||
user={readyUser}
|
||||
isOpen={showSettingsModal}
|
||||
entryMode={settingsEntryMode}
|
||||
initialSection={initialSettingsSection}
|
||||
platformTheme={settings.platformTheme}
|
||||
riskBlocks={riskBlocks}
|
||||
@@ -566,7 +585,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
isHydratingSettings={settings.isHydratingSettings}
|
||||
isPersistingSettings={settings.isPersistingSettings}
|
||||
settingsError={settings.settingsError}
|
||||
onClose={() => setShowSettingsModal(false)}
|
||||
onClose={closeSettingsModal}
|
||||
onPlatformThemeChange={settings.setPlatformTheme}
|
||||
onLogout={logoutCurrentSession}
|
||||
onRefreshRiskBlocks={async () => {
|
||||
|
||||
@@ -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> | 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<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isParsingDocumentInput, setIsParsingDocumentInput] = useState(false);
|
||||
const [isReadingReferenceImage, setIsReadingReferenceImage] = useState(false);
|
||||
// 统一聊天区只在用户仍停留在底部附近时跟随新内容,避免流式回复持续抢走阅读位置。
|
||||
const messageListRef = useRef<HTMLDivElement | null>(null);
|
||||
const documentInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const referenceImageInputRef = useRef<HTMLInputElement | null>(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<HTMLInputElement>,
|
||||
) => {
|
||||
@@ -426,6 +443,25 @@ export function CreationAgentWorkspace({
|
||||
}
|
||||
};
|
||||
|
||||
const handleReferenceImageInputChange = async (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
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 (
|
||||
<div className="mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
|
||||
<div
|
||||
@@ -545,9 +581,36 @@ export function CreationAgentWorkspace({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{documentInputError || error ? (
|
||||
{referenceImagePreviewSrc ? (
|
||||
<div className="mx-4 mb-3 flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-2">
|
||||
<div className="h-12 w-12 shrink-0 overflow-hidden rounded-[0.9rem] bg-[var(--platform-track-fill)]">
|
||||
<img
|
||||
src={referenceImagePreviewSrc}
|
||||
alt="参考图"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{referenceImageLabel || '已选择参考图'}
|
||||
</div>
|
||||
{onClearReferenceImage ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy || isReadingReferenceImage}
|
||||
onClick={onClearReferenceImage}
|
||||
className="platform-icon-button h-9 w-9"
|
||||
aria-label="移除参考图"
|
||||
title="移除参考图"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{documentInputError || referenceImageError || error ? (
|
||||
<div className="mx-4 mb-3 rounded-[1rem] border border-red-200/70 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{documentInputError || error}
|
||||
{documentInputError || referenceImageError || error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -560,6 +623,15 @@ export function CreationAgentWorkspace({
|
||||
className="hidden"
|
||||
onChange={handleDocumentInputChange}
|
||||
/>
|
||||
{onReferenceImageChange ? (
|
||||
<input
|
||||
ref={referenceImageInputRef}
|
||||
type="file"
|
||||
accept={REFERENCE_IMAGE_INPUT_ACCEPT}
|
||||
className="hidden"
|
||||
onChange={handleReferenceImageInputChange}
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
@@ -575,9 +647,30 @@ export function CreationAgentWorkspace({
|
||||
className={`h-4 w-4 ${isParsingDocumentInput ? 'animate-pulse' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{onReferenceImageChange ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
isReadingReferenceImage ? '正在读取参考图' : '上传参考图'
|
||||
}
|
||||
title={
|
||||
isReadingReferenceImage ? '正在读取参考图' : '上传参考图'
|
||||
}
|
||||
aria-busy={isReadingReferenceImage}
|
||||
disabled={isBusy || isReadingReferenceImage}
|
||||
onClick={openReferenceImagePicker}
|
||||
className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-[var(--platform-text-base)] hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<ImagePlus
|
||||
className={`h-4 w-4 ${isReadingReferenceImage ? 'animate-pulse' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
<textarea
|
||||
value={draftText}
|
||||
disabled={isBusy || isParsingDocumentInput}
|
||||
disabled={
|
||||
isBusy || isParsingDocumentInput || isReadingReferenceImage
|
||||
}
|
||||
rows={2}
|
||||
onChange={(event) => {
|
||||
setDraftText(event.target.value);
|
||||
@@ -595,7 +688,12 @@ export function CreationAgentWorkspace({
|
||||
<button
|
||||
type="button"
|
||||
aria-label="发送"
|
||||
disabled={isBusy || isParsingDocumentInput || !draftText.trim()}
|
||||
disabled={
|
||||
isBusy ||
|
||||
isParsingDocumentInput ||
|
||||
isReadingReferenceImage ||
|
||||
!draftText.trim()
|
||||
}
|
||||
onClick={submit}
|
||||
className={`inline-flex h-11 w-11 items-center justify-center rounded-full text-white disabled:cursor-not-allowed disabled:opacity-40 ${theme.userBubbleClass}`}
|
||||
>
|
||||
|
||||
215
src/components/match3d-creation/Match3DAgentWorkspace.tsx
Normal file
215
src/components/match3d-creation/Match3DAgentWorkspace.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import type {
|
||||
ExecuteMatch3DActionRequest,
|
||||
Match3DAgentSessionSnapshot,
|
||||
Match3DAnchorItemResponse,
|
||||
SendMatch3DMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import {
|
||||
buildCreationAgentChatMessage,
|
||||
createCreationAgentChatQuickActions,
|
||||
createCreationAgentClientMessageId,
|
||||
resolveCreationAgentQuickActionMessage,
|
||||
} from '../../services/creation-agent';
|
||||
import {
|
||||
type CreationAgentAnchorView,
|
||||
type CreationAgentSessionView,
|
||||
type CreationAgentTheme,
|
||||
CreationAgentWorkspace,
|
||||
} from '../creation-agent';
|
||||
|
||||
type Match3DAgentWorkspaceProps = {
|
||||
session: Match3DAgentSessionSnapshot | null;
|
||||
streamingReplyText?: string;
|
||||
isStreamingReply?: boolean;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onSubmitMessage: (payload: SendMatch3DMessageRequest) => void;
|
||||
onExecuteAction: (payload: ExecuteMatch3DActionRequest) => void;
|
||||
};
|
||||
|
||||
type Match3DReferenceImageState = {
|
||||
src: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const MATCH3D_AGENT_THEME: CreationAgentTheme = {
|
||||
accentTextClass: 'text-lime-100/86',
|
||||
accentBgClass: 'bg-lime-200',
|
||||
accentButtonClass: 'bg-lime-200 shadow-emerald-950/20',
|
||||
userBubbleClass: 'bg-emerald-600 text-white',
|
||||
heroClass:
|
||||
'border border-lime-100/18 bg-[radial-gradient(circle_at_top_left,rgba(190,242,100,0.24),transparent_34%),radial-gradient(circle_at_bottom_right,rgba(251,146,60,0.2),transparent_32%),linear-gradient(135deg,rgba(20,83,45,0.96),rgba(39,39,42,0.96))]',
|
||||
anchorGridClass: 'grid gap-2 sm:grid-cols-3',
|
||||
};
|
||||
|
||||
const MATCH3D_QUICK_ACTIONS = [
|
||||
...createCreationAgentChatQuickActions(),
|
||||
{
|
||||
key: 'match3d-auto-config',
|
||||
label: '自动配置',
|
||||
},
|
||||
];
|
||||
|
||||
function readMatch3DReferenceImageAsDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
reject(new Error('请选择图片文件。'));
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(new Error('参考图读取失败,请重试。'));
|
||||
reader.onload = () => resolve(String(reader.result || ''));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function mapMatch3DAnchor(
|
||||
anchor: Match3DAnchorItemResponse,
|
||||
): CreationAgentAnchorView {
|
||||
return {
|
||||
key: anchor.key,
|
||||
label: anchor.label,
|
||||
value: anchor.value,
|
||||
status: anchor.status,
|
||||
};
|
||||
}
|
||||
|
||||
function mapMatch3DSession(
|
||||
session: Match3DAgentSessionSnapshot,
|
||||
): CreationAgentSessionView {
|
||||
// 中文注释:抓大鹅 F1 只展示聊天与配置锚点,草稿结果交给后续结果页承接。
|
||||
const chatMessages = session.messages.filter(
|
||||
(message) =>
|
||||
message.kind === 'chat' ||
|
||||
message.kind === 'summary' ||
|
||||
message.kind === 'warning',
|
||||
);
|
||||
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
title: null,
|
||||
assistantSummary: null,
|
||||
currentTurn: session.currentTurn,
|
||||
progressPercent: session.progressPercent,
|
||||
anchors: [
|
||||
session.anchorPack.theme,
|
||||
session.anchorPack.clearCount,
|
||||
session.anchorPack.difficulty,
|
||||
].map(mapMatch3DAnchor),
|
||||
messages: chatMessages,
|
||||
recommendedReplies: [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatch3DChatPayload({
|
||||
text,
|
||||
quickFillRequested = false,
|
||||
referenceImageSrc,
|
||||
}: {
|
||||
text: string;
|
||||
quickFillRequested?: boolean;
|
||||
referenceImageSrc?: string | null;
|
||||
}) {
|
||||
return buildCreationAgentChatMessage<{
|
||||
referenceImageSrc?: string | null;
|
||||
}>({
|
||||
clientMessageId: createCreationAgentClientMessageId('match3d'),
|
||||
text,
|
||||
quickFillRequested,
|
||||
extraPayload: {
|
||||
referenceImageSrc: referenceImageSrc || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function Match3DAgentWorkspace({
|
||||
session,
|
||||
streamingReplyText = '',
|
||||
isStreamingReply = false,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onSubmitMessage,
|
||||
onExecuteAction,
|
||||
}: Match3DAgentWorkspaceProps) {
|
||||
const [referenceImage, setReferenceImage] =
|
||||
useState<Match3DReferenceImageState | null>(null);
|
||||
const [referenceImageError, setReferenceImageError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
return (
|
||||
<CreationAgentWorkspace
|
||||
session={session ? mapMatch3DSession(session) : null}
|
||||
theme={MATCH3D_AGENT_THEME}
|
||||
loadingText="正在准备抓大鹅共创工作区..."
|
||||
composerPlaceholder="题材、消除次数、难度..."
|
||||
primaryActionLabel="生成结果页"
|
||||
streamingReplyText={streamingReplyText}
|
||||
isStreamingReply={isStreamingReply}
|
||||
isBusy={isBusy}
|
||||
error={error}
|
||||
quickActions={MATCH3D_QUICK_ACTIONS}
|
||||
referenceImagePreviewSrc={referenceImage?.src ?? null}
|
||||
referenceImageLabel={referenceImage?.label ?? null}
|
||||
referenceImageError={referenceImageError}
|
||||
onBack={onBack}
|
||||
onSubmitText={(text) => {
|
||||
onSubmitMessage(
|
||||
buildMatch3DChatPayload({
|
||||
text,
|
||||
referenceImageSrc: referenceImage?.src ?? null,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onPrimaryAction={() => {
|
||||
onExecuteAction({ action: 'match3d_compile_draft' });
|
||||
}}
|
||||
onQuickAction={(action) => {
|
||||
const quickActionMessage =
|
||||
action.key === 'match3d-auto-config'
|
||||
? {
|
||||
text: '自动配置',
|
||||
quickFillRequested: true,
|
||||
}
|
||||
: resolveCreationAgentQuickActionMessage(
|
||||
action.key,
|
||||
'请总结一下当前抓大鹅设定。',
|
||||
);
|
||||
|
||||
onSubmitMessage(
|
||||
buildMatch3DChatPayload({
|
||||
...quickActionMessage,
|
||||
referenceImageSrc: referenceImage?.src ?? null,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onReferenceImageChange={async (file) => {
|
||||
try {
|
||||
const dataUrl = await readMatch3DReferenceImageAsDataUrl(file);
|
||||
setReferenceImage({
|
||||
src: dataUrl,
|
||||
label: file.name.trim() || '本地参考图',
|
||||
});
|
||||
setReferenceImageError(null);
|
||||
} catch (caughtError) {
|
||||
setReferenceImageError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: '参考图读取失败,请重试。',
|
||||
);
|
||||
}
|
||||
}}
|
||||
onClearReferenceImage={() => {
|
||||
setReferenceImage(null);
|
||||
setReferenceImageError(null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default Match3DAgentWorkspace;
|
||||
105
src/components/match3d-creation/Match3DDraftReadyView.tsx
Normal file
105
src/components/match3d-creation/Match3DDraftReadyView.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { ArrowLeft, Edit3, Sparkles } from 'lucide-react';
|
||||
|
||||
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
|
||||
type Match3DDraftReadyViewProps = {
|
||||
session: Match3DAgentSessionSnapshot;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
export function Match3DDraftReadyView({
|
||||
session,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
}: Match3DDraftReadyViewProps) {
|
||||
const draft = session.draft;
|
||||
const title = draft?.gameName || '抓大鹅草稿';
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-4xl flex-col">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={isBusy}
|
||||
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
返回
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section className="platform-subpanel min-h-0 flex-1 overflow-y-auto rounded-[1.5rem] p-4 sm:p-5">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
|
||||
<div className="grid aspect-square w-full max-w-[10rem] shrink-0 place-items-center rounded-[1.2rem] bg-[radial-gradient(circle_at_30%_25%,rgba(190,242,100,0.36),transparent_34%),linear-gradient(135deg,rgba(16,185,129,0.18),rgba(251,146,60,0.16))] text-emerald-800">
|
||||
<Sparkles className="h-10 w-10" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-2xl font-black leading-tight text-[var(--platform-text-strong)] sm:text-3xl">
|
||||
{title}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-base)]">
|
||||
{draft?.summaryText ?? session.lastAssistantReply ?? ''}
|
||||
</div>
|
||||
|
||||
{draft ? (
|
||||
<div className="mt-5 grid gap-2 sm:grid-cols-3">
|
||||
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
|
||||
题材
|
||||
</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{draft.themeText}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
|
||||
物品
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{draft.totalItemCount ?? draft.clearCount * 3} 件
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
|
||||
难度
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{draft.difficulty}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-4 rounded-[1.25rem] text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-4 flex justify-end pb-[max(0.25rem,env(safe-area-inset-bottom))]">
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="platform-button platform-button--primary cursor-not-allowed opacity-55"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Edit3 className="h-4 w-4" />
|
||||
继续编辑
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Match3DDraftReadyView;
|
||||
68
src/components/match3d-runtime/Match3DRuntimeShell.test.tsx
Normal file
68
src/components/match3d-runtime/Match3DRuntimeShell.test.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
Match3DClickItemRequest,
|
||||
Match3DRunSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import {
|
||||
confirmLocalMatch3DClick,
|
||||
startLocalMatch3DRun,
|
||||
} from '../../services/match3d-runtime';
|
||||
import { Match3DRuntimeShell } from './Match3DRuntimeShell';
|
||||
|
||||
function renderRuntime(run: Match3DRunSnapshot) {
|
||||
let currentRun = run;
|
||||
let authorityRun = run;
|
||||
const onClickItem = vi.fn(async (payload: Match3DClickItemRequest) => {
|
||||
const result = await confirmLocalMatch3DClick(authorityRun, payload);
|
||||
authorityRun = result.run;
|
||||
return result;
|
||||
});
|
||||
const onOptimisticRunChange = vi.fn((nextRun: Match3DRunSnapshot) => {
|
||||
currentRun = nextRun;
|
||||
rerender(
|
||||
<Match3DRuntimeShell
|
||||
run={currentRun}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={onOptimisticRunChange}
|
||||
onClickItem={onClickItem}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const { rerender } = render(
|
||||
<Match3DRuntimeShell
|
||||
run={currentRun}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={onOptimisticRunChange}
|
||||
onClickItem={onClickItem}
|
||||
/>,
|
||||
);
|
||||
return {
|
||||
onClickItem,
|
||||
onOptimisticRunChange,
|
||||
};
|
||||
}
|
||||
|
||||
test('展示圆形空间和 7 格备选栏', () => {
|
||||
renderRuntime(startLocalMatch3DRun(4));
|
||||
|
||||
expect(screen.getByTestId('match3d-board')).toBeTruthy();
|
||||
expect(screen.getAllByTestId('match3d-tray-slot')).toHaveLength(7);
|
||||
});
|
||||
|
||||
test('点击可见物品后先乐观入槽再等待确认', async () => {
|
||||
const run = startLocalMatch3DRun(4);
|
||||
const clickableItem = run.items.find((item) => item.clickable);
|
||||
expect(clickableItem).toBeTruthy();
|
||||
const { onClickItem, onOptimisticRunChange } = renderRuntime(run);
|
||||
|
||||
fireEvent.click(screen.getByTestId(`match3d-item-${clickableItem!.itemInstanceId}`));
|
||||
|
||||
expect(onOptimisticRunChange).toHaveBeenCalled();
|
||||
await waitFor(() => expect(onClickItem).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
454
src/components/match3d-runtime/Match3DRuntimeShell.tsx
Normal file
454
src/components/match3d-runtime/Match3DRuntimeShell.tsx
Normal file
@@ -0,0 +1,454 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
RotateCcw,
|
||||
Sparkles,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { type PointerEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
Match3DClickItemRequest,
|
||||
Match3DClickItemResult,
|
||||
Match3DItemSnapshot,
|
||||
Match3DRunSnapshot,
|
||||
Match3DTraySlot,
|
||||
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import { MATCH3D_VISUAL_SEEDS } from '../../services/match3d-runtime';
|
||||
|
||||
type Match3DRuntimeShellProps = {
|
||||
run: Match3DRunSnapshot | null;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onRestart: () => void;
|
||||
onOptimisticRunChange: (run: Match3DRunSnapshot) => void;
|
||||
onClickItem: (
|
||||
payload: Match3DClickItemRequest,
|
||||
) => Promise<Match3DClickItemResult>;
|
||||
onTimeExpired?: () => void;
|
||||
};
|
||||
|
||||
type PendingClick = {
|
||||
clientEventId: string;
|
||||
itemInstanceId: string;
|
||||
previousRun: Match3DRunSnapshot;
|
||||
};
|
||||
|
||||
type Match3DFeedbackEvent = {
|
||||
id: string;
|
||||
kind: 'cleared' | 'rejected';
|
||||
itemIds: string[];
|
||||
};
|
||||
|
||||
function formatTimer(value: number) {
|
||||
const totalSeconds = Math.max(0, Math.ceil(value / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatElapsed(startedAtMs: number, remainingMs: number, durationLimitMs: number) {
|
||||
const elapsedMs = Math.max(0, durationLimitMs - remainingMs);
|
||||
const totalSeconds = Math.floor(elapsedMs / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function resolveVisualSeed(visualKey: string) {
|
||||
return (
|
||||
MATCH3D_VISUAL_SEEDS.find((seed) => seed.visualKey === visualKey) ??
|
||||
MATCH3D_VISUAL_SEEDS[0]!
|
||||
);
|
||||
}
|
||||
|
||||
function buildClientEventId(itemInstanceId: string) {
|
||||
return `match3d-click-${itemInstanceId}-${Date.now()}-${Math.round(
|
||||
Math.random() * 1_000_000,
|
||||
)}`;
|
||||
}
|
||||
|
||||
function isPointInsideCircle(
|
||||
pointX: number,
|
||||
pointY: number,
|
||||
item: Match3DItemSnapshot,
|
||||
) {
|
||||
return Math.hypot(pointX - item.x, pointY - item.y) <= item.radius;
|
||||
}
|
||||
|
||||
function findHitItem(
|
||||
run: Match3DRunSnapshot,
|
||||
pointX: number,
|
||||
pointY: number,
|
||||
) {
|
||||
return run.items
|
||||
.filter(
|
||||
(item) =>
|
||||
item.state === 'InBoard' &&
|
||||
item.clickable &&
|
||||
isPointInsideCircle(pointX, pointY, item),
|
||||
)
|
||||
.sort((left, right) => right.layer - left.layer)[0];
|
||||
}
|
||||
|
||||
function buildOptimisticRun(
|
||||
run: Match3DRunSnapshot,
|
||||
item: Match3DItemSnapshot,
|
||||
) {
|
||||
const nextSlot = run.traySlots.find((slot) => !slot.itemInstanceId);
|
||||
if (!nextSlot) {
|
||||
return run;
|
||||
}
|
||||
return {
|
||||
...run,
|
||||
items: run.items.map((entry) =>
|
||||
entry.itemInstanceId === item.itemInstanceId
|
||||
? {
|
||||
...entry,
|
||||
state: 'Flying' as const,
|
||||
clickable: false,
|
||||
}
|
||||
: entry,
|
||||
),
|
||||
traySlots: run.traySlots.map((slot) =>
|
||||
slot.slotIndex === nextSlot.slotIndex
|
||||
? {
|
||||
slotIndex: slot.slotIndex,
|
||||
itemInstanceId: item.itemInstanceId,
|
||||
itemTypeId: item.itemTypeId,
|
||||
visualKey: item.visualKey,
|
||||
}
|
||||
: slot,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function Match3DToken({
|
||||
item,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
item: Match3DItemSnapshot;
|
||||
disabled: boolean;
|
||||
onClick: (item: Match3DItemSnapshot) => void;
|
||||
}) {
|
||||
const visualSeed = resolveVisualSeed(item.visualKey);
|
||||
const size = `${item.radius * 200}%`;
|
||||
const itemStateClass =
|
||||
item.state === 'Flying'
|
||||
? 'scale-75 opacity-0'
|
||||
: item.clickable
|
||||
? 'cursor-pointer opacity-100 hover:scale-105 active:scale-95'
|
||||
: 'opacity-48';
|
||||
|
||||
if (item.state !== 'InBoard' && item.state !== 'Flying') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`absolute flex -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-white/35 bg-gradient-to-br ${visualSeed.colorClassName} text-sm font-black text-white shadow-[0_10px_18px_rgba(15,23,42,0.32)] transition-all duration-300 [text-shadow:0_1px_2px_rgba(15,23,42,0.65)] ${itemStateClass}`}
|
||||
style={{
|
||||
left: `${item.x * 100}%`,
|
||||
top: `${item.y * 100}%`,
|
||||
width: size,
|
||||
height: size,
|
||||
zIndex: item.layer,
|
||||
}}
|
||||
aria-label={`${visualSeed.label} ${item.clickable ? '可点击' : '被遮挡'}`}
|
||||
data-testid={`match3d-item-${item.itemInstanceId}`}
|
||||
disabled={disabled || !item.clickable || item.state !== 'InBoard'}
|
||||
onClick={() => onClick(item)}
|
||||
>
|
||||
<span className="relative z-10">{visualSeed.label}</span>
|
||||
<span className="absolute inset-[16%] rounded-full bg-white/24" />
|
||||
<span className="absolute left-[18%] top-[14%] h-[18%] w-[28%] rounded-full bg-white/42 blur-[1px]" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Match3DTrayToken({ slot }: { slot: Match3DTraySlot }) {
|
||||
if (!slot.visualKey) {
|
||||
return <span className="h-full w-full rounded-xl border border-dashed border-slate-300/35 bg-white/8" />;
|
||||
}
|
||||
const visualSeed = resolveVisualSeed(slot.visualKey);
|
||||
return (
|
||||
<span
|
||||
className={`flex h-full w-full items-center justify-center rounded-xl border border-white/35 bg-gradient-to-br ${visualSeed.colorClassName} text-xs font-black text-white shadow-[0_8px_16px_rgba(15,23,42,0.24)] [text-shadow:0_1px_2px_rgba(15,23,42,0.62)]`}
|
||||
>
|
||||
{visualSeed.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Match3DSettlement({
|
||||
run,
|
||||
onBack,
|
||||
onRestart,
|
||||
}: {
|
||||
run: Match3DRunSnapshot;
|
||||
onBack: () => void;
|
||||
onRestart: () => void;
|
||||
}) {
|
||||
if (run.status === 'Running') {
|
||||
return null;
|
||||
}
|
||||
const won = run.status === 'Won';
|
||||
const stopped = run.status === 'Stopped';
|
||||
const title = won ? '通关完成' : stopped ? '已停止' : '本轮失败';
|
||||
const description = won
|
||||
? `用时 ${formatElapsed(run.startedAtMs, run.remainingMs, run.durationLimitMs)}`
|
||||
: `已清除 ${run.clearedItemCount}/${run.totalItemCount}`;
|
||||
return (
|
||||
<div className="absolute inset-0 z-[80] flex items-center justify-center bg-slate-950/62 px-5 backdrop-blur-sm">
|
||||
<section
|
||||
className="w-full max-w-sm rounded-[1.5rem] border border-white/18 bg-white/94 p-5 text-slate-950 shadow-[0_26px_70px_rgba(15,23,42,0.34)]"
|
||||
role="dialog"
|
||||
aria-label={title}
|
||||
>
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<span
|
||||
className={`flex h-11 w-11 items-center justify-center rounded-full ${
|
||||
won ? 'bg-emerald-100 text-emerald-700' : 'bg-rose-100 text-rose-700'
|
||||
}`}
|
||||
>
|
||||
{won ? <CheckCircle2 size={24} /> : <XCircle size={24} />}
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-xl font-black">{title}</h2>
|
||||
<p className="text-sm font-semibold text-slate-500">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-black text-slate-700"
|
||||
onClick={onBack}
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-xl bg-slate-950 px-4 py-3 text-sm font-black text-white"
|
||||
onClick={onRestart}
|
||||
>
|
||||
再来一局
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Match3DRuntimeShell({
|
||||
run,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onRestart,
|
||||
onOptimisticRunChange,
|
||||
onClickItem,
|
||||
onTimeExpired,
|
||||
}: Match3DRuntimeShellProps) {
|
||||
const stageRef = useRef<HTMLDivElement | null>(null);
|
||||
const [pendingClick, setPendingClick] = useState<PendingClick | null>(null);
|
||||
const [feedbackEvent, setFeedbackEvent] = useState<Match3DFeedbackEvent | null>(
|
||||
null,
|
||||
);
|
||||
const [timeLeftMs, setTimeLeftMs] = useState(run?.remainingMs ?? 0);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeLeftMs(run?.remainingMs ?? 0);
|
||||
}, [run?.remainingMs, run?.snapshotVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!run || run.status !== 'Running') {
|
||||
return undefined;
|
||||
}
|
||||
const timer = window.setInterval(() => {
|
||||
setTimeLeftMs((current) => {
|
||||
const next = Math.max(0, current - 1000);
|
||||
if (next <= 0) {
|
||||
onTimeExpired?.();
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [onTimeExpired, run]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!feedbackEvent) {
|
||||
return undefined;
|
||||
}
|
||||
const timer = window.setTimeout(() => setFeedbackEvent(null), 520);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [feedbackEvent]);
|
||||
|
||||
const progressText = useMemo(() => {
|
||||
if (!run) {
|
||||
return '0/0';
|
||||
}
|
||||
return `${run.clearedItemCount}/${run.totalItemCount}`;
|
||||
}, [run]);
|
||||
|
||||
const handleItemClick = async (item: Match3DItemSnapshot) => {
|
||||
if (!run || run.status !== 'Running' || pendingClick) {
|
||||
return;
|
||||
}
|
||||
const optimisticRun = buildOptimisticRun(run, item);
|
||||
const clientEventId = buildClientEventId(item.itemInstanceId);
|
||||
// 中文注释:先更新前端即时反馈,再等待后端确认;确认失败时用权威快照回滚校正。
|
||||
setPendingClick({
|
||||
clientEventId,
|
||||
itemInstanceId: item.itemInstanceId,
|
||||
previousRun: run,
|
||||
});
|
||||
onOptimisticRunChange(optimisticRun);
|
||||
|
||||
const result = await onClickItem({
|
||||
runId: run.runId,
|
||||
itemInstanceId: item.itemInstanceId,
|
||||
clientSnapshotVersion: run.snapshotVersion,
|
||||
clientEventId,
|
||||
clickedAtMs: Date.now(),
|
||||
});
|
||||
if (result.status === 'Accepted') {
|
||||
if (result.clearedItemInstanceIds.length > 0) {
|
||||
setFeedbackEvent({
|
||||
id: clientEventId,
|
||||
kind: 'cleared',
|
||||
itemIds: result.clearedItemInstanceIds,
|
||||
});
|
||||
}
|
||||
onOptimisticRunChange(result.run);
|
||||
} else {
|
||||
setFeedbackEvent({
|
||||
id: clientEventId,
|
||||
kind: 'rejected',
|
||||
itemIds: [item.itemInstanceId],
|
||||
});
|
||||
onOptimisticRunChange(result.run ?? run);
|
||||
}
|
||||
setPendingClick(null);
|
||||
};
|
||||
|
||||
const handleBoardPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!run || run.status !== 'Running' || pendingClick) {
|
||||
return;
|
||||
}
|
||||
const rect = stageRef.current?.getBoundingClientRect();
|
||||
if (!rect) {
|
||||
return;
|
||||
}
|
||||
const pointX = (event.clientX - rect.left) / rect.width;
|
||||
const pointY = (event.clientY - rect.top) / rect.height;
|
||||
const item = findHitItem(run, pointX, pointY);
|
||||
if (item) {
|
||||
void handleItemClick(item);
|
||||
}
|
||||
};
|
||||
|
||||
if (!run) {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center bg-slate-950 text-white">
|
||||
{isBusy ? '载入中' : error ?? '暂无运行态'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="relative flex min-h-dvh w-full justify-center overflow-hidden bg-[#16221f] text-white">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_12%,rgba(255,255,255,0.22),transparent_26%),linear-gradient(180deg,#b8e28d_0%,#377569_52%,#14201f_100%)]" />
|
||||
<div className="relative flex min-h-dvh w-full max-w-md flex-col px-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.8rem)] pt-[calc(env(safe-area-inset-top,0px)+0.65rem)]">
|
||||
<header className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-black/22 text-white backdrop-blur"
|
||||
onClick={onBack}
|
||||
aria-label="返回"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2 rounded-full border border-white/16 bg-black/24 px-3 py-2 text-sm font-black backdrop-blur">
|
||||
<Clock3 size={16} />
|
||||
<span>{formatTimer(timeLeftMs)}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-black/22 text-white backdrop-blur"
|
||||
onClick={onRestart}
|
||||
aria-label="重新开始"
|
||||
>
|
||||
<RotateCcw size={18} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section className="mt-3 grid grid-cols-3 gap-2 text-center text-[0.72rem] font-black">
|
||||
<div className="rounded-2xl border border-white/14 bg-black/18 px-2 py-2 backdrop-blur">
|
||||
{progressText}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/14 bg-black/18 px-2 py-2 backdrop-blur">
|
||||
{run.clearCount} 组
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/14 bg-black/18 px-2 py-2 backdrop-blur">
|
||||
v{run.snapshotVersion}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="relative mt-3 flex flex-1 min-h-0 items-center justify-center">
|
||||
<div
|
||||
ref={stageRef}
|
||||
className="relative aspect-square w-full max-w-[min(92vw,58dvh)] overflow-hidden rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)]"
|
||||
onPointerDown={handleBoardPointerDown}
|
||||
data-testid="match3d-board"
|
||||
>
|
||||
<div className="absolute inset-[7%] rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />
|
||||
{run.items.map((item) => (
|
||||
<Match3DToken
|
||||
key={item.itemInstanceId}
|
||||
item={item}
|
||||
disabled={Boolean(pendingClick)}
|
||||
onClick={handleItemClick}
|
||||
/>
|
||||
))}
|
||||
{feedbackEvent?.kind === 'cleared' ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-[70] flex items-center justify-center">
|
||||
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-white/24 text-amber-100 shadow-[0_0_42px_rgba(255,255,255,0.72)] backdrop-blur-sm">
|
||||
<Sparkles size={42} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-3 rounded-[1.35rem] border border-white/14 bg-black/24 p-2 shadow-[0_16px_36px_rgba(15,23,42,0.24)] backdrop-blur">
|
||||
<div className="grid grid-cols-7 gap-1.5" data-testid="match3d-tray">
|
||||
{run.traySlots.map((slot) => (
|
||||
<div
|
||||
key={slot.slotIndex}
|
||||
className="aspect-square min-w-0 rounded-xl bg-white/10 p-1"
|
||||
data-testid="match3d-tray-slot"
|
||||
>
|
||||
<Match3DTrayToken slot={slot} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{feedbackEvent?.kind === 'rejected' ? (
|
||||
<div className="pointer-events-none absolute left-1/2 top-24 z-[90] -translate-x-1/2 rounded-full border border-rose-200/60 bg-rose-500/88 px-4 py-2 text-xs font-black text-white shadow-lg">
|
||||
已校正
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Match3DSettlement run={run} onBack={onBack} onRestart={onRestart} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default Match3DRuntimeShell;
|
||||
1
src/components/match3d-runtime/index.ts
Normal file
1
src/components/match3d-runtime/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Match3DRuntimeShell } from './Match3DRuntimeShell';
|
||||
@@ -10,6 +10,7 @@ export interface PlatformEntryCreationTypeModalProps {
|
||||
onClose: () => void;
|
||||
onSelectRpg: () => void;
|
||||
onSelectBigFish: () => void;
|
||||
onSelectMatch3D: () => void;
|
||||
onSelectPuzzle: () => void;
|
||||
}
|
||||
|
||||
@@ -71,6 +72,7 @@ export function PlatformEntryCreationTypeModal({
|
||||
onClose,
|
||||
onSelectRpg,
|
||||
onSelectBigFish,
|
||||
onSelectMatch3D,
|
||||
onSelectPuzzle,
|
||||
}: PlatformEntryCreationTypeModalProps) {
|
||||
if (!isOpen) {
|
||||
@@ -103,6 +105,9 @@ export function PlatformEntryCreationTypeModal({
|
||||
if (item.id === 'big-fish') {
|
||||
onSelectBigFish();
|
||||
}
|
||||
if (item.id === 'match3d') {
|
||||
onSelectMatch3D();
|
||||
}
|
||||
if (item.id === 'puzzle') {
|
||||
onSelectPuzzle();
|
||||
}
|
||||
|
||||
@@ -19,6 +19,14 @@ import type {
|
||||
SubmitBigFishInputRequest,
|
||||
} from '../../../packages/shared/src/contracts/bigFish';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type {
|
||||
CreateMatch3DSessionRequest,
|
||||
ExecuteMatch3DActionRequest,
|
||||
Match3DActionResponse,
|
||||
Match3DAgentSessionSnapshot,
|
||||
Match3DSessionResponse,
|
||||
SendMatch3DMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type {
|
||||
PuzzleAgentActionRequest,
|
||||
PuzzleAgentOperationRecord,
|
||||
@@ -75,6 +83,7 @@ import {
|
||||
readCustomWorldAgentUiState,
|
||||
shouldRestoreCustomWorldAgentUiState,
|
||||
} from '../../services/customWorldAgentUiState';
|
||||
import { match3dCreationClient } from '../../services/match3d-creation';
|
||||
import {
|
||||
buildBigFishGenerationAnchorEntries,
|
||||
buildMiniGameDraftGenerationProgress,
|
||||
@@ -652,6 +661,20 @@ const BigFishRuntimeShell = lazy(async () => {
|
||||
};
|
||||
});
|
||||
|
||||
const Match3DAgentWorkspace = lazy(async () => {
|
||||
const module = await import('../match3d-creation/Match3DAgentWorkspace');
|
||||
return {
|
||||
default: module.Match3DAgentWorkspace,
|
||||
};
|
||||
});
|
||||
|
||||
const Match3DDraftReadyView = lazy(async () => {
|
||||
const module = await import('../match3d-creation/Match3DDraftReadyView');
|
||||
return {
|
||||
default: module.Match3DDraftReadyView,
|
||||
};
|
||||
});
|
||||
|
||||
const CustomWorldCreationHub = lazy(async () => {
|
||||
const module = await import('../custom-world-home/CustomWorldCreationHub');
|
||||
return {
|
||||
@@ -858,6 +881,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
resolveRpgCreationErrorMessage(error, fallback),
|
||||
[],
|
||||
);
|
||||
const resolveMatch3DErrorMessage = useCallback(
|
||||
(error: unknown, fallback: string) =>
|
||||
resolveRpgCreationErrorMessage(error, fallback),
|
||||
[],
|
||||
);
|
||||
|
||||
const refreshBigFishShelf = useCallback(async () => {
|
||||
setIsBigFishLoadingLibrary(true);
|
||||
@@ -1237,6 +1265,44 @@ export function PlatformEntryFlowShellImpl({
|
||||
},
|
||||
});
|
||||
|
||||
const match3dFlow = usePlatformCreationAgentFlowController<
|
||||
Match3DAgentSessionSnapshot,
|
||||
CreateMatch3DSessionRequest,
|
||||
Match3DSessionResponse,
|
||||
SendMatch3DMessageRequest,
|
||||
ExecuteMatch3DActionRequest,
|
||||
Match3DActionResponse
|
||||
>({
|
||||
client: {
|
||||
createSession: match3dCreationClient.createSession,
|
||||
getSession: match3dCreationClient.getSession,
|
||||
streamMessage: match3dCreationClient.streamMessage,
|
||||
executeAction: match3dCreationClient.executeAction,
|
||||
selectSession: (response) => response.session,
|
||||
},
|
||||
createPayload: {},
|
||||
workspaceStage: 'match3d-agent-workspace',
|
||||
resultStage: 'match3d-result',
|
||||
platformStage: 'platform',
|
||||
isCompileAction: (payload) => payload.action === 'match3d_compile_draft',
|
||||
resolveErrorMessage: resolveMatch3DErrorMessage,
|
||||
errorMessages: {
|
||||
open: '开启抓大鹅共创工作台失败。',
|
||||
restoreMissingSession: '这份抓大鹅草稿缺少会话信息,请重新开始创作。',
|
||||
restore: '读取抓大鹅创作草稿失败。',
|
||||
submit: '发送抓大鹅共创消息失败。',
|
||||
execute: '执行抓大鹅操作失败。',
|
||||
},
|
||||
enterCreateTab,
|
||||
setSelectionStage,
|
||||
onSessionOpened: () => {
|
||||
setShowCreationTypeModal(false);
|
||||
},
|
||||
onActionComplete: ({ response, setSession }) => {
|
||||
setSession(response.session);
|
||||
},
|
||||
});
|
||||
|
||||
const puzzleFlow = usePlatformCreationAgentFlowController<
|
||||
PuzzleAgentSessionSnapshot,
|
||||
CreatePuzzleAgentSessionRequest,
|
||||
@@ -1356,6 +1422,16 @@ export function PlatformEntryFlowShellImpl({
|
||||
const streamingBigFishReplyText = bigFishFlow.streamingReplyText;
|
||||
const isStreamingBigFishReply = bigFishFlow.isStreamingReply;
|
||||
|
||||
const match3dSession = match3dFlow.session;
|
||||
const match3dError = match3dFlow.error;
|
||||
const setMatch3DSession = match3dFlow.setSession;
|
||||
const setMatch3DError = match3dFlow.setError;
|
||||
const isMatch3DBusy = match3dFlow.isBusy;
|
||||
const streamingMatch3DReplyText = match3dFlow.streamingReplyText;
|
||||
const setStreamingMatch3DReplyText = match3dFlow.setStreamingReplyText;
|
||||
const isStreamingMatch3DReply = match3dFlow.isStreamingReply;
|
||||
const setIsStreamingMatch3DReply = match3dFlow.setIsStreamingReply;
|
||||
|
||||
const puzzleSession = puzzleFlow.session;
|
||||
const puzzleError = puzzleFlow.error;
|
||||
const setPuzzleError = puzzleFlow.setError;
|
||||
@@ -1379,6 +1455,20 @@ export function PlatformEntryFlowShellImpl({
|
||||
await bigFishFlow.openWorkspace();
|
||||
}, [bigFishFlow]);
|
||||
|
||||
const openMatch3DAgentWorkspace = useCallback(async () => {
|
||||
setMatch3DSession(null);
|
||||
setMatch3DError(null);
|
||||
setStreamingMatch3DReplyText('');
|
||||
setIsStreamingMatch3DReply(false);
|
||||
await match3dFlow.openWorkspace();
|
||||
}, [
|
||||
match3dFlow,
|
||||
setIsStreamingMatch3DReply,
|
||||
setMatch3DError,
|
||||
setMatch3DSession,
|
||||
setStreamingMatch3DReplyText,
|
||||
]);
|
||||
|
||||
const openPuzzleAgentWorkspace = useCallback(async () => {
|
||||
setPuzzleRun(null);
|
||||
setPuzzleOperation(null);
|
||||
@@ -1466,6 +1556,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
setBigFishRuntimeReturnStage('platform');
|
||||
setBigFishGenerationState(null);
|
||||
setBigFishError(null);
|
||||
setMatch3DSession(null);
|
||||
setMatch3DError(null);
|
||||
setStreamingMatch3DReplyText('');
|
||||
setIsStreamingMatch3DReply(false);
|
||||
setPuzzleOperation(null);
|
||||
setPuzzleWorks([]);
|
||||
setSelectedPuzzleDetail(null);
|
||||
@@ -1500,10 +1594,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
resetRpgSessionViewState,
|
||||
selectionStage,
|
||||
setBigFishError,
|
||||
setIsStreamingMatch3DReply,
|
||||
setMatch3DError,
|
||||
setMatch3DSession,
|
||||
setPuzzleError,
|
||||
setRpgCustomWorldError,
|
||||
setRpgGeneratedCustomWorldProfile,
|
||||
setSelectionStage,
|
||||
setStreamingMatch3DReplyText,
|
||||
]);
|
||||
|
||||
const handleCreationHubCreateType = useCallback(
|
||||
@@ -1523,6 +1621,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'match3d') {
|
||||
runProtectedAction(() => {
|
||||
void openMatch3DAgentWorkspace();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'puzzle') {
|
||||
runProtectedAction(() => {
|
||||
void openPuzzleAgentWorkspace();
|
||||
@@ -1531,6 +1636,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
},
|
||||
[
|
||||
openBigFishAgentWorkspace,
|
||||
openMatch3DAgentWorkspace,
|
||||
openPuzzleAgentWorkspace,
|
||||
prepareCreationLaunch,
|
||||
runProtectedAction,
|
||||
@@ -1546,6 +1652,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
bigFishFlow.leaveFlow();
|
||||
}, [bigFishFlow]);
|
||||
|
||||
const leaveMatch3DFlow = useCallback(() => {
|
||||
match3dFlow.leaveFlow();
|
||||
}, [match3dFlow]);
|
||||
|
||||
const leavePuzzleFlow = useCallback(() => {
|
||||
setPuzzleOperation(null);
|
||||
setPuzzleRun(null);
|
||||
@@ -1556,10 +1666,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
const submitBigFishMessage = bigFishFlow.submitMessage;
|
||||
|
||||
const submitMatch3DMessage = match3dFlow.submitMessage;
|
||||
|
||||
const submitPuzzleMessage = puzzleFlow.submitMessage;
|
||||
|
||||
const executeBigFishAction = bigFishFlow.executeAction;
|
||||
|
||||
const executeMatch3DAction = match3dFlow.executeAction;
|
||||
|
||||
const executePuzzleAction = puzzleFlow.executeAction;
|
||||
|
||||
const retryPuzzleDraftGeneration = useCallback(() => {
|
||||
@@ -1602,6 +1716,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
}, [bigFishRun, bigFishSession, selectionStage, setSelectionStage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectionStage === 'match3d-result' && !match3dSession?.draft) {
|
||||
setSelectionStage(
|
||||
match3dSession ? 'match3d-agent-workspace' : 'platform',
|
||||
);
|
||||
}
|
||||
}, [match3dSession, selectionStage, setSelectionStage]);
|
||||
|
||||
const startBigFishRun = useCallback(() => {
|
||||
if (!bigFishSession) {
|
||||
return;
|
||||
@@ -3280,11 +3402,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
: (platformBootstrap.platformError ??
|
||||
sessionController.agentWorkspaceRestoreError ??
|
||||
bigFishError ??
|
||||
match3dError ??
|
||||
puzzleError)
|
||||
}
|
||||
onRetry={() => {
|
||||
platformBootstrap.setPlatformError(null);
|
||||
setBigFishError(null);
|
||||
setMatch3DError(null);
|
||||
setPuzzleError(null);
|
||||
void platformBootstrap.refreshCustomWorldWorks().catch((error) => {
|
||||
platformBootstrap.setPlatformError(
|
||||
@@ -3297,11 +3421,15 @@ export function PlatformEntryFlowShellImpl({
|
||||
void refreshPuzzleShelf();
|
||||
}}
|
||||
createError={
|
||||
sessionController.creationTypeError ?? bigFishError ?? puzzleError
|
||||
sessionController.creationTypeError ??
|
||||
bigFishError ??
|
||||
match3dError ??
|
||||
puzzleError
|
||||
}
|
||||
createBusy={
|
||||
sessionController.isCreatingAgentSession ||
|
||||
isBigFishBusy ||
|
||||
isMatch3DBusy ||
|
||||
isPuzzleBusy
|
||||
}
|
||||
onCreateType={handleCreationHubCreateType}
|
||||
@@ -3469,7 +3597,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
entry={selectedPublicWorkDetail}
|
||||
authorAvatarUrl={selectedPublicWorkAuthor?.avatarUrl ?? null}
|
||||
authorDisplayName={selectedPublicWorkAuthor?.displayName ?? null}
|
||||
isBusy={isPublicWorkDetailBusy || isPuzzleBusy || isBigFishBusy}
|
||||
isBusy={
|
||||
isPublicWorkDetailBusy ||
|
||||
isPuzzleBusy ||
|
||||
isBigFishBusy ||
|
||||
isMatch3DBusy
|
||||
}
|
||||
error={publicWorkDetailError}
|
||||
onBack={() => {
|
||||
setPublicWorkDetailError(null);
|
||||
@@ -3767,6 +3900,58 @@ export function PlatformEntryFlowShellImpl({
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{selectionStage === 'match3d-agent-workspace' && (
|
||||
<motion.div
|
||||
key="match3d-agent-workspace"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载抓大鹅共创工作区..." />}
|
||||
>
|
||||
<Match3DAgentWorkspace
|
||||
session={match3dSession}
|
||||
streamingReplyText={streamingMatch3DReplyText}
|
||||
isStreamingReply={isStreamingMatch3DReply}
|
||||
isBusy={isMatch3DBusy || isStreamingMatch3DReply}
|
||||
error={match3dError}
|
||||
onBack={leaveMatch3DFlow}
|
||||
onSubmitMessage={(payload) => {
|
||||
void submitMatch3DMessage(payload);
|
||||
}}
|
||||
onExecuteAction={(payload) => {
|
||||
void executeMatch3DAction(payload);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{selectionStage === 'match3d-result' && match3dSession?.draft && (
|
||||
<motion.div
|
||||
key="match3d-result"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载抓大鹅结果..." />}
|
||||
>
|
||||
<Match3DDraftReadyView
|
||||
session={match3dSession}
|
||||
isBusy={isMatch3DBusy}
|
||||
error={match3dError}
|
||||
onBack={() => {
|
||||
setSelectionStage('match3d-agent-workspace');
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{selectionStage === 'puzzle-agent-workspace' && (
|
||||
<motion.div
|
||||
key="puzzle-agent-workspace"
|
||||
@@ -4207,15 +4392,20 @@ export function PlatformEntryFlowShellImpl({
|
||||
isBusy={
|
||||
sessionController.isCreatingAgentSession ||
|
||||
isBigFishBusy ||
|
||||
isMatch3DBusy ||
|
||||
isPuzzleBusy
|
||||
}
|
||||
error={
|
||||
bigFishError ?? puzzleError ?? sessionController.creationTypeError
|
||||
bigFishError ??
|
||||
match3dError ??
|
||||
puzzleError ??
|
||||
sessionController.creationTypeError
|
||||
}
|
||||
onClose={() => {
|
||||
if (
|
||||
sessionController.isCreatingAgentSession ||
|
||||
isBigFishBusy ||
|
||||
isMatch3DBusy ||
|
||||
isPuzzleBusy
|
||||
) {
|
||||
return;
|
||||
@@ -4230,6 +4420,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
void openBigFishAgentWorkspace();
|
||||
});
|
||||
}}
|
||||
onSelectMatch3D={() => {
|
||||
runProtectedAction(() => {
|
||||
void openMatch3DAgentWorkspace();
|
||||
});
|
||||
}}
|
||||
onSelectPuzzle={() => {
|
||||
runProtectedAction(() => {
|
||||
void openPuzzleAgentWorkspace();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type PlatformCreationTypeId =
|
||||
| 'rpg'
|
||||
| 'big-fish'
|
||||
| 'match3d'
|
||||
| 'puzzle'
|
||||
| 'airp'
|
||||
| 'visual-novel';
|
||||
@@ -64,6 +65,13 @@ export const PLATFORM_CREATION_TYPES: PlatformCreationTypeCard[] = [
|
||||
badge: '可创建',
|
||||
locked: false,
|
||||
},
|
||||
{
|
||||
id: 'match3d',
|
||||
title: '抓大鹅',
|
||||
subtitle: '经典消除玩法',
|
||||
badge: '可创建',
|
||||
locked: false,
|
||||
},
|
||||
{
|
||||
id: 'airp',
|
||||
title: 'AIRP',
|
||||
|
||||
@@ -22,6 +22,8 @@ export type SelectionStage =
|
||||
| 'big-fish-generating'
|
||||
| 'big-fish-result'
|
||||
| 'big-fish-runtime'
|
||||
| 'match3d-agent-workspace'
|
||||
| 'match3d-result'
|
||||
| 'puzzle-agent-workspace'
|
||||
| 'puzzle-generating'
|
||||
| 'puzzle-result'
|
||||
|
||||
@@ -21,6 +21,12 @@ describe('matchAppRoute', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('routes match3d playground path to the standalone Match3D runtime', () => {
|
||||
expect(matchAppRoute('/MATCH3D/')).toEqual({
|
||||
kind: 'match3d-playground',
|
||||
});
|
||||
});
|
||||
|
||||
it('routes former standalone editor paths back to the main game', () => {
|
||||
expect(matchAppRoute('/item-editor/tools')).toEqual({
|
||||
kind: 'game',
|
||||
|
||||
@@ -15,6 +15,9 @@ export type AppRouteMatch =
|
||||
| {
|
||||
kind: 'big-fish-playground';
|
||||
}
|
||||
| {
|
||||
kind: 'match3d-playground';
|
||||
}
|
||||
| {
|
||||
kind: 'game';
|
||||
};
|
||||
@@ -29,6 +32,7 @@ export type ResolvedAppRoute = {
|
||||
|
||||
const GameApp = lazy(() => import('../AuthenticatedApp')) as AppRouteComponent;
|
||||
const BigFishPlaygroundApp = lazy(() => import('../BigFishPlaygroundApp')) as AppRouteComponent;
|
||||
const Match3DPlaygroundApp = lazy(() => import('../Match3DPlaygroundApp')) as AppRouteComponent;
|
||||
const PuzzlePlaygroundApp = lazy(() => import('../PuzzlePlaygroundApp')) as AppRouteComponent;
|
||||
|
||||
function normalizeRoutePath(pathname: string) {
|
||||
@@ -50,6 +54,12 @@ export function matchAppRoute(pathname: string): AppRouteMatch {
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedPath === '/match3d') {
|
||||
return {
|
||||
kind: 'match3d-playground',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'game',
|
||||
};
|
||||
@@ -76,6 +86,15 @@ export function resolveAppRoute(pathname: string): ResolvedAppRoute {
|
||||
};
|
||||
}
|
||||
|
||||
if (matchedRoute.kind === 'match3d-playground') {
|
||||
return {
|
||||
kind: 'match3d-playground',
|
||||
loadingEyebrow: '正在载入抓大鹅',
|
||||
loadingText: '正在进入消除关卡...',
|
||||
Component: Match3DPlaygroundApp,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'game',
|
||||
loadingEyebrow: '正在载入游戏',
|
||||
|
||||
7
src/services/match3d-creation/index.ts
Normal file
7
src/services/match3d-creation/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
createMatch3DCreationSession,
|
||||
executeMatch3DCreationAction,
|
||||
getMatch3DCreationSession,
|
||||
match3dCreationClient,
|
||||
streamMatch3DCreationMessage,
|
||||
} from './match3dCreationClient';
|
||||
361
src/services/match3d-creation/match3dCreationClient.ts
Normal file
361
src/services/match3d-creation/match3dCreationClient.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import type {
|
||||
CreateMatch3DSessionRequest,
|
||||
ExecuteMatch3DActionRequest,
|
||||
Match3DActionResponse,
|
||||
Match3DAgentMessageResponse,
|
||||
Match3DAgentSessionSnapshot,
|
||||
Match3DAnchorItemResponse,
|
||||
Match3DCreatorConfig,
|
||||
Match3DSessionResponse,
|
||||
SendMatch3DMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type { TextStreamOptions } from '../aiTypes';
|
||||
|
||||
const MOCK_RESPONSE_DELAY_MS = 180;
|
||||
const MATCH3D_SESSION_PREFIX = 'match3d-session';
|
||||
|
||||
const DEFAULT_MATCH3D_CONFIG: Match3DCreatorConfig = {
|
||||
themeText: '缤纷玩具',
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
};
|
||||
|
||||
let match3dSessionCounter = 0;
|
||||
const mockSessions = new Map<string, Match3DAgentSessionSnapshot>();
|
||||
|
||||
function delay(ms = MOCK_RESPONSE_DELAY_MS) {
|
||||
return new Promise<void>((resolve) => globalThis.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function createMessage(
|
||||
sessionId: string,
|
||||
role: Match3DAgentMessageResponse['role'],
|
||||
text: string,
|
||||
kind: Match3DAgentMessageResponse['kind'] = 'chat',
|
||||
): Match3DAgentMessageResponse {
|
||||
return {
|
||||
id: `${sessionId}-message-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
role,
|
||||
kind,
|
||||
text,
|
||||
createdAt: nowIso(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildAnchor(
|
||||
key: string,
|
||||
label: string,
|
||||
value: string,
|
||||
): Match3DAnchorItemResponse {
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
value,
|
||||
status: value.trim() ? 'confirmed' : 'missing',
|
||||
};
|
||||
}
|
||||
|
||||
function buildAnchorPack(config: Partial<Match3DCreatorConfig>) {
|
||||
return {
|
||||
theme: buildAnchor('theme', '题材主题', config.themeText ?? ''),
|
||||
clearCount: buildAnchor(
|
||||
'clearCount',
|
||||
'需要消除次数',
|
||||
typeof config.clearCount === 'number' ? String(config.clearCount) : '',
|
||||
),
|
||||
difficulty: buildAnchor(
|
||||
'difficulty',
|
||||
'难度',
|
||||
typeof config.difficulty === 'number' ? String(config.difficulty) : '',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value: unknown) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = Math.floor(value);
|
||||
return normalized > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeDifficulty(value: unknown) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.max(1, Math.min(10, Math.round(value)));
|
||||
}
|
||||
|
||||
function buildConfigFromPartial(
|
||||
partial: Partial<Match3DCreatorConfig>,
|
||||
): Match3DCreatorConfig | null {
|
||||
const themeText = partial.themeText?.trim();
|
||||
const clearCount = normalizePositiveInteger(partial.clearCount);
|
||||
const difficulty = normalizeDifficulty(partial.difficulty);
|
||||
|
||||
if (!themeText || !clearCount || !difficulty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
themeText,
|
||||
referenceImageSrc: partial.referenceImageSrc ?? null,
|
||||
clearCount,
|
||||
difficulty,
|
||||
};
|
||||
}
|
||||
|
||||
function parseConfigFromText(
|
||||
text: string,
|
||||
current: Partial<Match3DCreatorConfig>,
|
||||
): Partial<Match3DCreatorConfig> {
|
||||
const next = { ...current };
|
||||
const trimmedText = text.trim();
|
||||
|
||||
const themeMatch =
|
||||
trimmedText.match(/(?:题材|主题)[::\s]*([\u4e00-\u9fa5A-Za-z0-9_-]{2,24})/u) ??
|
||||
trimmedText.match(/(?:想做|做成|选择|使用)([\u4e00-\u9fa5A-Za-z0-9_-]{2,24})(?:题材|主题)/u);
|
||||
const clearCountMatch =
|
||||
trimmedText.match(/(?:消除|次数)[::\s]*(\d+)/u) ??
|
||||
trimmedText.match(/(\d+)\s*(?:次消除|次)/u);
|
||||
const difficultyMatch =
|
||||
trimmedText.match(/(?:难度)[::\s]*(10|[1-9])/u) ??
|
||||
trimmedText.match(/(?:难一点|困难)/u);
|
||||
|
||||
if (themeMatch?.[1]) {
|
||||
next.themeText = themeMatch[1].trim();
|
||||
}
|
||||
|
||||
if (clearCountMatch?.[1]) {
|
||||
next.clearCount = Number(clearCountMatch[1]);
|
||||
}
|
||||
|
||||
if (difficultyMatch?.[1]) {
|
||||
next.difficulty = Number(difficultyMatch[1]);
|
||||
} else if (difficultyMatch?.[0]) {
|
||||
next.difficulty = 7;
|
||||
}
|
||||
|
||||
if (!next.themeText && trimmedText.length >= 2 && trimmedText.length <= 24) {
|
||||
next.themeText = trimmedText;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function resolveSessionProgress(config: Partial<Match3DCreatorConfig>) {
|
||||
const completed = [
|
||||
Boolean(config.themeText?.trim()),
|
||||
Boolean(normalizePositiveInteger(config.clearCount)),
|
||||
Boolean(normalizeDifficulty(config.difficulty)),
|
||||
].filter(Boolean).length;
|
||||
|
||||
return Math.round((completed / 3) * 100);
|
||||
}
|
||||
|
||||
function buildAssistantReply(config: Partial<Match3DCreatorConfig>) {
|
||||
const missing: string[] = [];
|
||||
if (!config.themeText?.trim()) {
|
||||
missing.push('题材主题');
|
||||
}
|
||||
if (!normalizePositiveInteger(config.clearCount)) {
|
||||
missing.push('需要消除次数');
|
||||
}
|
||||
if (!normalizeDifficulty(config.difficulty)) {
|
||||
missing.push('难度');
|
||||
}
|
||||
|
||||
if (missing.length === 0) {
|
||||
const readyConfig = buildConfigFromPartial(config) ?? DEFAULT_MATCH3D_CONFIG;
|
||||
return `已确认:${readyConfig.themeText}题材,消除 ${readyConfig.clearCount} 次,共 ${readyConfig.clearCount * 3} 件物品,难度 ${readyConfig.difficulty}。可以生成结果页。`;
|
||||
}
|
||||
|
||||
return `还需要确认:${missing.join('、')}。`;
|
||||
}
|
||||
|
||||
function updateSessionConfig(
|
||||
session: Match3DAgentSessionSnapshot,
|
||||
partialConfig: Partial<Match3DCreatorConfig>,
|
||||
) {
|
||||
const progressPercent = resolveSessionProgress(partialConfig);
|
||||
const config = buildConfigFromPartial(partialConfig);
|
||||
|
||||
return {
|
||||
...session,
|
||||
progressPercent,
|
||||
stage: 'collecting_config',
|
||||
anchorPack: buildAnchorPack(partialConfig),
|
||||
config,
|
||||
updatedAt: nowIso(),
|
||||
} satisfies Match3DAgentSessionSnapshot;
|
||||
}
|
||||
|
||||
function ensureMockSession(sessionId: string) {
|
||||
const session = mockSessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error('抓大鹅创作会话不存在,请重新开始创作。');
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
function buildDraft(config: Match3DCreatorConfig) {
|
||||
return {
|
||||
gameName: `${config.themeText}抓大鹅`,
|
||||
themeText: config.themeText,
|
||||
summaryText: `${config.themeText}题材的经典三消收纳关卡。`,
|
||||
tags: [config.themeText, '抓大鹅', '消除'].slice(0, 3),
|
||||
coverImageSrc: config.referenceImageSrc ?? null,
|
||||
clearCount: config.clearCount,
|
||||
difficulty: config.difficulty,
|
||||
totalItemCount: config.clearCount * 3,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createMatch3DCreationSession(
|
||||
payload: CreateMatch3DSessionRequest = {},
|
||||
): Promise<Match3DSessionResponse> {
|
||||
await delay();
|
||||
|
||||
match3dSessionCounter += 1;
|
||||
const sessionId = `${MATCH3D_SESSION_PREFIX}-${match3dSessionCounter}`;
|
||||
const partialConfig: Partial<Match3DCreatorConfig> = {
|
||||
themeText: payload.themeText ?? payload.seedText,
|
||||
referenceImageSrc: payload.referenceImageSrc ?? null,
|
||||
clearCount: payload.clearCount,
|
||||
difficulty: payload.difficulty,
|
||||
};
|
||||
const now = nowIso();
|
||||
const session: Match3DAgentSessionSnapshot = updateSessionConfig(
|
||||
{
|
||||
sessionId,
|
||||
currentTurn: 0,
|
||||
progressPercent: 0,
|
||||
stage: 'collecting_config',
|
||||
anchorPack: buildAnchorPack(partialConfig),
|
||||
config: null,
|
||||
draft: null,
|
||||
messages: [
|
||||
createMessage(
|
||||
sessionId,
|
||||
'assistant',
|
||||
'先确认题材、需要消除次数和难度。也可以直接说“自动配置”。',
|
||||
),
|
||||
],
|
||||
lastAssistantReply: null,
|
||||
updatedAt: now,
|
||||
},
|
||||
partialConfig,
|
||||
);
|
||||
|
||||
mockSessions.set(sessionId, session);
|
||||
return { session };
|
||||
}
|
||||
|
||||
export async function getMatch3DCreationSession(sessionId: string) {
|
||||
await delay(80);
|
||||
return { session: ensureMockSession(sessionId) };
|
||||
}
|
||||
|
||||
export async function streamMatch3DCreationMessage(
|
||||
sessionId: string,
|
||||
payload: SendMatch3DMessageRequest,
|
||||
options: TextStreamOptions = {},
|
||||
): Promise<Match3DAgentSessionSnapshot> {
|
||||
await delay(120);
|
||||
const session = ensureMockSession(sessionId);
|
||||
const text = payload.text.trim();
|
||||
const currentConfig: Partial<Match3DCreatorConfig> = session.config ?? {
|
||||
themeText: session.anchorPack.theme.value,
|
||||
clearCount: Number(session.anchorPack.clearCount.value) || undefined,
|
||||
difficulty: Number(session.anchorPack.difficulty.value) || undefined,
|
||||
};
|
||||
const nextConfig =
|
||||
payload.quickFillRequested || /自动配置/u.test(text)
|
||||
? {
|
||||
...DEFAULT_MATCH3D_CONFIG,
|
||||
themeText: currentConfig.themeText || DEFAULT_MATCH3D_CONFIG.themeText,
|
||||
}
|
||||
: parseConfigFromText(text, currentConfig);
|
||||
const userMessage = {
|
||||
id: payload.clientMessageId,
|
||||
role: 'user',
|
||||
kind: 'chat',
|
||||
text,
|
||||
createdAt: nowIso(),
|
||||
} satisfies Match3DAgentMessageResponse;
|
||||
const assistantReply = buildAssistantReply(nextConfig);
|
||||
|
||||
options.onUpdate?.(assistantReply.slice(0, Math.ceil(assistantReply.length / 2)));
|
||||
await delay(80);
|
||||
options.onUpdate?.(assistantReply);
|
||||
await delay(80);
|
||||
|
||||
const nextSession = updateSessionConfig(
|
||||
{
|
||||
...session,
|
||||
currentTurn: session.currentTurn + 1,
|
||||
messages: [
|
||||
...session.messages,
|
||||
userMessage,
|
||||
createMessage(sessionId, 'assistant', assistantReply),
|
||||
],
|
||||
lastAssistantReply: assistantReply,
|
||||
},
|
||||
{
|
||||
...nextConfig,
|
||||
referenceImageSrc:
|
||||
payload.referenceImageSrc ?? currentConfig.referenceImageSrc ?? null,
|
||||
},
|
||||
);
|
||||
|
||||
mockSessions.set(sessionId, nextSession);
|
||||
return nextSession;
|
||||
}
|
||||
|
||||
export async function executeMatch3DCreationAction(
|
||||
sessionId: string,
|
||||
payload: ExecuteMatch3DActionRequest,
|
||||
): Promise<Match3DActionResponse> {
|
||||
await delay(220);
|
||||
const session = ensureMockSession(sessionId);
|
||||
|
||||
if (payload.action !== 'match3d_compile_draft') {
|
||||
throw new Error('未知抓大鹅创作操作。');
|
||||
}
|
||||
|
||||
const config = session.config ?? buildConfigFromPartial(DEFAULT_MATCH3D_CONFIG);
|
||||
if (!config) {
|
||||
throw new Error('请先确认题材、需要消除次数和难度。');
|
||||
}
|
||||
|
||||
const nextSession = {
|
||||
...session,
|
||||
stage: 'draft_ready',
|
||||
progressPercent: 100,
|
||||
config,
|
||||
draft: buildDraft(config),
|
||||
lastAssistantReply: '抓大鹅草稿已准备完成。',
|
||||
messages: [
|
||||
...session.messages,
|
||||
createMessage(sessionId, 'assistant', '抓大鹅草稿已准备完成。', 'summary'),
|
||||
],
|
||||
updatedAt: nowIso(),
|
||||
} satisfies Match3DAgentSessionSnapshot;
|
||||
|
||||
mockSessions.set(sessionId, nextSession);
|
||||
return { session: nextSession };
|
||||
}
|
||||
|
||||
export const match3dCreationClient = {
|
||||
createSession: createMatch3DCreationSession,
|
||||
getSession: getMatch3DCreationSession,
|
||||
streamMessage: streamMatch3DCreationMessage,
|
||||
executeAction: executeMatch3DCreationAction,
|
||||
};
|
||||
8
src/services/match3d-runtime/index.ts
Normal file
8
src/services/match3d-runtime/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
buildLocalMatch3DOptimisticRun,
|
||||
confirmLocalMatch3DClick,
|
||||
MATCH3D_VISUAL_SEEDS,
|
||||
resolveLocalMatch3DTimer,
|
||||
startLocalMatch3DRun,
|
||||
stopLocalMatch3DRun,
|
||||
} from './match3dLocalRuntime';
|
||||
409
src/services/match3d-runtime/match3dLocalRuntime.ts
Normal file
409
src/services/match3d-runtime/match3dLocalRuntime.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
import type {
|
||||
Match3DClickItemRequest,
|
||||
Match3DClickItemResult,
|
||||
Match3DItemSnapshot,
|
||||
Match3DRunSnapshot,
|
||||
Match3DTraySlot,
|
||||
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
|
||||
const MATCH3D_TRAY_SLOT_COUNT = 7;
|
||||
const MATCH3D_LOCAL_DURATION_MS = 600_000;
|
||||
|
||||
type Match3DVisualSeed = {
|
||||
itemTypeId: string;
|
||||
visualKey: string;
|
||||
colorClassName: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const MATCH3D_VISUAL_SEEDS: Match3DVisualSeed[] = [
|
||||
{
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'apple-red',
|
||||
colorClassName: 'from-rose-400 to-red-600',
|
||||
label: '苹',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'banana',
|
||||
visualKey: 'banana-yellow',
|
||||
colorClassName: 'from-yellow-300 to-amber-500',
|
||||
label: '蕉',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'grape',
|
||||
visualKey: 'grape-purple',
|
||||
colorClassName: 'from-violet-400 to-purple-700',
|
||||
label: '萄',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'melon',
|
||||
visualKey: 'melon-green',
|
||||
colorClassName: 'from-emerald-300 to-green-600',
|
||||
label: '瓜',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'berry',
|
||||
visualKey: 'berry-blue',
|
||||
colorClassName: 'from-sky-300 to-blue-600',
|
||||
label: '莓',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'peach',
|
||||
visualKey: 'peach-pink',
|
||||
colorClassName: 'from-pink-300 to-orange-400',
|
||||
label: '桃',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'plum',
|
||||
visualKey: 'plum-indigo',
|
||||
colorClassName: 'from-indigo-300 to-indigo-700',
|
||||
label: '李',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'lime',
|
||||
visualKey: 'lime-lime',
|
||||
colorClassName: 'from-lime-300 to-lime-600',
|
||||
label: '柠',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'orange',
|
||||
visualKey: 'orange-orange',
|
||||
colorClassName: 'from-orange-300 to-orange-600',
|
||||
label: '橙',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'candy',
|
||||
visualKey: 'candy-cyan',
|
||||
colorClassName: 'from-cyan-300 to-teal-600',
|
||||
label: '糖',
|
||||
},
|
||||
];
|
||||
|
||||
function createEmptyTray(): Match3DTraySlot[] {
|
||||
return Array.from({ length: MATCH3D_TRAY_SLOT_COUNT }, (_, slotIndex) => ({
|
||||
slotIndex,
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeRemainingMs(run: Match3DRunSnapshot, nowMs = Date.now()) {
|
||||
if (run.status !== 'Running') {
|
||||
return run;
|
||||
}
|
||||
const elapsedMs = Math.max(0, nowMs - run.startedAtMs);
|
||||
const remainingMs = Math.max(0, run.durationLimitMs - elapsedMs);
|
||||
if (remainingMs > 0) {
|
||||
return {
|
||||
...run,
|
||||
serverNowMs: nowMs,
|
||||
remainingMs,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...run,
|
||||
status: 'Failed' as const,
|
||||
serverNowMs: nowMs,
|
||||
remainingMs: 0,
|
||||
failureReason: 'TimeUp' as const,
|
||||
snapshotVersion: run.snapshotVersion + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function buildItem(
|
||||
seed: Match3DVisualSeed,
|
||||
index: number,
|
||||
copyIndex: number,
|
||||
): Match3DItemSnapshot {
|
||||
const ring = Math.floor(index / 6);
|
||||
const angle = index * 0.86 + copyIndex * 0.22;
|
||||
const spread = 0.16 + (ring % 4) * 0.085;
|
||||
const x = 0.5 + Math.cos(angle) * spread + ((index % 3) - 1) * 0.026;
|
||||
const y = 0.5 + Math.sin(angle * 1.13) * spread + ((copyIndex % 3) - 1) * 0.02;
|
||||
const radius = 0.072 - Math.min(0.018, ring * 0.004) + (copyIndex % 2) * 0.004;
|
||||
return {
|
||||
itemInstanceId: `${seed.itemTypeId}-${copyIndex + 1}`,
|
||||
itemTypeId: seed.itemTypeId,
|
||||
visualKey: seed.visualKey,
|
||||
x: Math.max(0.18, Math.min(0.82, x)),
|
||||
y: Math.max(0.18, Math.min(0.82, y)),
|
||||
radius,
|
||||
layer: index + 1,
|
||||
state: 'InBoard',
|
||||
clickable: true,
|
||||
};
|
||||
}
|
||||
|
||||
function recomputeClickable(items: Match3DItemSnapshot[]) {
|
||||
const boardItems = items.filter((item) => item.state === 'InBoard');
|
||||
return items.map((item) => {
|
||||
if (item.state !== 'InBoard') {
|
||||
return {
|
||||
...item,
|
||||
clickable: false,
|
||||
};
|
||||
}
|
||||
const coveredByHigherLayer = boardItems.some((other) => {
|
||||
if (other.itemInstanceId === item.itemInstanceId || other.layer <= item.layer) {
|
||||
return false;
|
||||
}
|
||||
const distance = Math.hypot(other.x - item.x, other.y - item.y);
|
||||
return distance < Math.min(item.radius, other.radius) * 0.78;
|
||||
});
|
||||
return {
|
||||
...item,
|
||||
clickable: !coveredByHigherLayer,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function findNextTrayIndex(traySlots: Match3DTraySlot[]) {
|
||||
return traySlots.find((slot) => !slot.itemInstanceId)?.slotIndex ?? -1;
|
||||
}
|
||||
|
||||
function countClearedItems(items: Match3DItemSnapshot[]) {
|
||||
return items.filter((item) => item.state === 'Cleared').length;
|
||||
}
|
||||
|
||||
function resolveRunStatus(run: Match3DRunSnapshot): Match3DRunSnapshot {
|
||||
const clearedItemCount = countClearedItems(run.items);
|
||||
if (clearedItemCount >= run.totalItemCount) {
|
||||
return {
|
||||
...run,
|
||||
status: 'Won',
|
||||
clearedItemCount,
|
||||
remainingMs: Math.max(0, run.remainingMs),
|
||||
};
|
||||
}
|
||||
const trayIsFull = run.traySlots.every((slot) => Boolean(slot.itemInstanceId));
|
||||
if (trayIsFull) {
|
||||
return {
|
||||
...run,
|
||||
status: 'Failed',
|
||||
clearedItemCount,
|
||||
failureReason: 'TrayFull',
|
||||
};
|
||||
}
|
||||
return {
|
||||
...run,
|
||||
status: 'Running',
|
||||
failureReason: undefined,
|
||||
clearedItemCount,
|
||||
};
|
||||
}
|
||||
|
||||
function settleMatchedTrayItems(run: Match3DRunSnapshot) {
|
||||
const slotsByType = new Map<string, Match3DTraySlot[]>();
|
||||
for (const slot of run.traySlots) {
|
||||
if (!slot.itemTypeId || !slot.itemInstanceId) {
|
||||
continue;
|
||||
}
|
||||
slotsByType.set(slot.itemTypeId, [
|
||||
...(slotsByType.get(slot.itemTypeId) ?? []),
|
||||
slot,
|
||||
]);
|
||||
}
|
||||
|
||||
const matchedSlots = [...slotsByType.values()].find((slots) => slots.length >= 3);
|
||||
if (!matchedSlots) {
|
||||
return {
|
||||
run,
|
||||
clearedItemInstanceIds: [] as string[],
|
||||
};
|
||||
}
|
||||
|
||||
const clearedItemInstanceIds = matchedSlots
|
||||
.slice(0, 3)
|
||||
.map((slot) => slot.itemInstanceId)
|
||||
.filter((itemInstanceId): itemInstanceId is string => Boolean(itemInstanceId));
|
||||
const clearedSet = new Set(clearedItemInstanceIds);
|
||||
const nextRun = {
|
||||
...run,
|
||||
traySlots: run.traySlots.map((slot) =>
|
||||
slot.itemInstanceId && clearedSet.has(slot.itemInstanceId)
|
||||
? { slotIndex: slot.slotIndex }
|
||||
: slot,
|
||||
),
|
||||
items: run.items.map((item) =>
|
||||
clearedSet.has(item.itemInstanceId)
|
||||
? {
|
||||
...item,
|
||||
state: 'Cleared' as const,
|
||||
clickable: false,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
};
|
||||
|
||||
return {
|
||||
run: nextRun,
|
||||
clearedItemInstanceIds,
|
||||
};
|
||||
}
|
||||
|
||||
export function startLocalMatch3DRun(clearCount = 12): Match3DRunSnapshot {
|
||||
const normalizedClearCount = Math.max(1, Math.round(clearCount));
|
||||
const typeCount = Math.min(MATCH3D_VISUAL_SEEDS.length, normalizedClearCount);
|
||||
const items = Array.from({ length: normalizedClearCount }, (_, clearIndex) =>
|
||||
Array.from({ length: 3 }, (_, copyOffset) => {
|
||||
const seed = MATCH3D_VISUAL_SEEDS[clearIndex % typeCount] ?? MATCH3D_VISUAL_SEEDS[0]!;
|
||||
return buildItem(seed, clearIndex * 3 + copyOffset, clearIndex * 3 + copyOffset);
|
||||
}),
|
||||
).flat();
|
||||
const nowMs = Date.now();
|
||||
return {
|
||||
runId: `local-match3d-run-${nowMs}`,
|
||||
profileId: 'local-match3d-profile',
|
||||
status: 'Running',
|
||||
snapshotVersion: 1,
|
||||
startedAtMs: nowMs,
|
||||
durationLimitMs: MATCH3D_LOCAL_DURATION_MS,
|
||||
serverNowMs: nowMs,
|
||||
remainingMs: MATCH3D_LOCAL_DURATION_MS,
|
||||
clearCount: normalizedClearCount,
|
||||
totalItemCount: items.length,
|
||||
clearedItemCount: 0,
|
||||
traySlots: createEmptyTray(),
|
||||
items: recomputeClickable(items),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveLocalMatch3DTimer(run: Match3DRunSnapshot) {
|
||||
return normalizeRemainingMs(run);
|
||||
}
|
||||
|
||||
export function buildLocalMatch3DOptimisticRun(
|
||||
run: Match3DRunSnapshot,
|
||||
itemInstanceId: string,
|
||||
): Match3DRunSnapshot {
|
||||
const targetItem = run.items.find((item) => item.itemInstanceId === itemInstanceId);
|
||||
const nextTrayIndex = findNextTrayIndex(run.traySlots);
|
||||
if (!targetItem || targetItem.state !== 'InBoard' || nextTrayIndex < 0) {
|
||||
return run;
|
||||
}
|
||||
return {
|
||||
...run,
|
||||
items: run.items.map((item) =>
|
||||
item.itemInstanceId === itemInstanceId
|
||||
? {
|
||||
...item,
|
||||
state: 'Flying' as const,
|
||||
clickable: false,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
traySlots: run.traySlots.map((slot) =>
|
||||
slot.slotIndex === nextTrayIndex
|
||||
? {
|
||||
slotIndex: slot.slotIndex,
|
||||
itemInstanceId: targetItem.itemInstanceId,
|
||||
itemTypeId: targetItem.itemTypeId,
|
||||
visualKey: targetItem.visualKey,
|
||||
}
|
||||
: slot,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function confirmLocalMatch3DClick(
|
||||
run: Match3DRunSnapshot,
|
||||
request: Match3DClickItemRequest,
|
||||
): Promise<Match3DClickItemResult> {
|
||||
// 中文注释:F3 阶段用本地函数模拟后端权威确认,真实接口接入后保留同一结果语义。
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 180));
|
||||
const timedRun = normalizeRemainingMs(run);
|
||||
if (timedRun.status !== 'Running') {
|
||||
return {
|
||||
status: 'RunFinished',
|
||||
run: timedRun,
|
||||
clearedItemInstanceIds: [],
|
||||
failureReason: timedRun.failureReason,
|
||||
};
|
||||
}
|
||||
if (request.clientSnapshotVersion !== run.snapshotVersion) {
|
||||
return {
|
||||
status: 'VersionConflict',
|
||||
run: timedRun,
|
||||
clearedItemInstanceIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
const targetItem = run.items.find(
|
||||
(item) => item.itemInstanceId === request.itemInstanceId,
|
||||
);
|
||||
if (!targetItem || targetItem.state !== 'InBoard') {
|
||||
return {
|
||||
status: 'RejectedAlreadyMoved',
|
||||
run: timedRun,
|
||||
clearedItemInstanceIds: [],
|
||||
};
|
||||
}
|
||||
if (!targetItem.clickable) {
|
||||
return {
|
||||
status: 'RejectedNotClickable',
|
||||
run: timedRun,
|
||||
clearedItemInstanceIds: [],
|
||||
};
|
||||
}
|
||||
const nextTrayIndex = findNextTrayIndex(run.traySlots);
|
||||
if (nextTrayIndex < 0) {
|
||||
const failedRun = {
|
||||
...timedRun,
|
||||
status: 'Failed' as const,
|
||||
failureReason: 'TrayFull' as const,
|
||||
snapshotVersion: run.snapshotVersion + 1,
|
||||
};
|
||||
return {
|
||||
status: 'RejectedTrayFull',
|
||||
run: failedRun,
|
||||
clearedItemInstanceIds: [],
|
||||
failureReason: 'TrayFull',
|
||||
};
|
||||
}
|
||||
|
||||
const movedRun: Match3DRunSnapshot = {
|
||||
...timedRun,
|
||||
snapshotVersion: run.snapshotVersion + 1,
|
||||
items: timedRun.items.map((item) =>
|
||||
item.itemInstanceId === targetItem.itemInstanceId
|
||||
? {
|
||||
...item,
|
||||
state: 'InTray' as const,
|
||||
clickable: false,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
traySlots: timedRun.traySlots.map((slot) =>
|
||||
slot.slotIndex === nextTrayIndex
|
||||
? {
|
||||
slotIndex: slot.slotIndex,
|
||||
itemInstanceId: targetItem.itemInstanceId,
|
||||
itemTypeId: targetItem.itemTypeId,
|
||||
visualKey: targetItem.visualKey,
|
||||
}
|
||||
: slot,
|
||||
),
|
||||
};
|
||||
const settled = settleMatchedTrayItems(movedRun);
|
||||
const nextRun = resolveRunStatus({
|
||||
...settled.run,
|
||||
items: recomputeClickable(settled.run.items),
|
||||
});
|
||||
|
||||
return {
|
||||
status: 'Accepted',
|
||||
run: nextRun,
|
||||
acceptedItemInstanceId: targetItem.itemInstanceId,
|
||||
clearedItemInstanceIds: settled.clearedItemInstanceIds,
|
||||
failureReason: nextRun.failureReason,
|
||||
};
|
||||
}
|
||||
|
||||
export function stopLocalMatch3DRun(run: Match3DRunSnapshot): Match3DRunSnapshot {
|
||||
if (run.status !== 'Running') {
|
||||
return run;
|
||||
}
|
||||
return {
|
||||
...run,
|
||||
status: 'Stopped',
|
||||
snapshotVersion: run.snapshotVersion + 1,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user