107
docs/technical/MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md
Normal file
107
docs/technical/MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# 抓大鹅 Match3D 领域规则与共享契约 Stage1 方案
|
||||||
|
|
||||||
|
日期:`2026-04-30`
|
||||||
|
|
||||||
|
## 1. 文档目的
|
||||||
|
|
||||||
|
本文件承接 [MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md](./MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md),只冻结 B1 + B2 开发范围:
|
||||||
|
|
||||||
|
1. 新增 `module-match3d` 纯领域 crate。
|
||||||
|
2. 新增 Rust shared contracts。
|
||||||
|
3. 新增 TypeScript shared contracts。
|
||||||
|
|
||||||
|
本阶段不实现 SpacetimeDB 表、procedure、`spacetime-client` 调用封装、`api-server` facade 和前端页面。
|
||||||
|
|
||||||
|
## 2. Stage1 边界
|
||||||
|
|
||||||
|
## 2.1 本阶段做
|
||||||
|
|
||||||
|
1. 领域层定义创作配置、作品草稿、作品 profile、运行态快照、物品、托盘、点击确认结果。
|
||||||
|
2. 领域层提供纯函数:
|
||||||
|
- 校验创作配置
|
||||||
|
- 编译默认草稿
|
||||||
|
- 校验发布字段
|
||||||
|
- 按确定性 seed 生成初始运行态
|
||||||
|
- 刷新 2D 可点击快照
|
||||||
|
- 确认点击、入槽、三消、胜利、托盘满失败
|
||||||
|
- 确认倒计时失败
|
||||||
|
3. Rust / TypeScript shared contracts 提供前后端对齐的请求与响应 DTO。
|
||||||
|
4. 运行态采用“前端即时反馈 + 后端权威确认”契约:
|
||||||
|
- 前端可先播放点击、飞入、入槽、三消、腾格和胜负过渡。
|
||||||
|
- 后端确认后返回权威快照。
|
||||||
|
- 后端拒绝或快照版本不一致时,前端按权威快照回滚或校正。
|
||||||
|
|
||||||
|
## 2.2 本阶段不做
|
||||||
|
|
||||||
|
1. 不新增 SpacetimeDB 表。
|
||||||
|
2. 不新增 SpacetimeDB procedure。
|
||||||
|
3. 不生成新的 SpacetimeDB bindings。
|
||||||
|
4. 不新增 `api-server` 路由。
|
||||||
|
5. 不接入平台入口、结果页或运行态 UI。
|
||||||
|
6. 不接入真实图片生成。
|
||||||
|
7. 不做排行榜与后续关卡推荐。
|
||||||
|
|
||||||
|
## 3. 领域 crate 设计
|
||||||
|
|
||||||
|
新增:
|
||||||
|
|
||||||
|
```text
|
||||||
|
server-rs/crates/module-match3d
|
||||||
|
```
|
||||||
|
|
||||||
|
该 crate 是纯领域层,不读写数据库,不访问网络,不依赖浏览器或文件系统。
|
||||||
|
|
||||||
|
核心类型:
|
||||||
|
|
||||||
|
1. `Match3DCreatorConfig`
|
||||||
|
2. `Match3DResultDraft`
|
||||||
|
3. `Match3DWorkProfile`
|
||||||
|
4. `Match3DRunSnapshot`
|
||||||
|
5. `Match3DItemSnapshot`
|
||||||
|
6. `Match3DTraySlot`
|
||||||
|
7. `Match3DClickConfirmation`
|
||||||
|
|
||||||
|
核心函数:
|
||||||
|
|
||||||
|
1. `build_creator_config`
|
||||||
|
2. `compile_result_draft`
|
||||||
|
3. `validate_publish_requirements`
|
||||||
|
4. `create_work_profile`
|
||||||
|
5. `publish_work_profile`
|
||||||
|
6. `start_run_with_seed_at`
|
||||||
|
7. `confirm_click_at`
|
||||||
|
8. `resolve_run_timer_at`
|
||||||
|
|
||||||
|
## 4. 即时反馈与权威确认
|
||||||
|
|
||||||
|
本阶段将点击处理明确拆成两层:
|
||||||
|
|
||||||
|
1. 前端即时反馈层
|
||||||
|
- 读取后端快照中的 `boardVersion`、物品位置、层级、半径和 `clickable`。
|
||||||
|
- 本地做命中检测和动画。
|
||||||
|
- 立即表现飞入、入槽、三消和胜负过渡。
|
||||||
|
|
||||||
|
2. 后端权威确认层
|
||||||
|
- 校验 `runId`、`itemInstanceId`、运行态状态和物品是否仍可点击。
|
||||||
|
- 重新计算入槽、三消、托盘满失败和胜利。
|
||||||
|
- 返回最新 `Match3DRunSnapshot`。
|
||||||
|
- 用 `boardVersion` 帮前端识别是否需要校正。
|
||||||
|
|
||||||
|
`Flying` 只作为前端表现态,不要求后端逐帧落库。后端只确认物品是否已从 `InBoard` 进入 `InTray` 或 `Cleared`。
|
||||||
|
|
||||||
|
## 5. 生成规则 Stage1 口径
|
||||||
|
|
||||||
|
1. `clearCount` 必须是正整数。
|
||||||
|
2. `totalItemCount = clearCount * 3`。
|
||||||
|
3. 难度范围为 `1~10`。
|
||||||
|
4. 首版内置 `10` 种 demo 视觉 key。
|
||||||
|
5. 当 `clearCount > 10` 时,复用视觉 key,并保证每种物品数量仍为 `3` 的倍数。
|
||||||
|
6. 初始布局使用确定性 seed 生成圆形空间内的 2D 坐标。
|
||||||
|
7. 可点击判定只做 2D 近似:若物品被更高层物品完全覆盖,则不可点击;否则可点击。
|
||||||
|
|
||||||
|
## 6. 验收
|
||||||
|
|
||||||
|
1. `cargo test -p module-match3d` 通过。
|
||||||
|
2. `cargo test -p shared-contracts match3d` 通过。
|
||||||
|
3. `npm run check:encoding` 覆盖新增中文文档和新增源码。
|
||||||
|
4. 本阶段不要求运行 `npm run api-server:maincloud`,因为未修改后端运行服务入口、SpacetimeDB 表或 `api-server` facade。
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
- [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md):冻结 SpacetimeDB 表结构变更约束、自动迁移可接受范围、冲突后的系统行为,以及保留旧数据的增量迁移流程;凡涉及 `spacetime publish`、表字段调整或 `migration.rs` 对齐时优先参考。
|
- [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md):冻结 SpacetimeDB 表结构变更约束、自动迁移可接受范围、冲突后的系统行为,以及保留旧数据的增量迁移流程;凡涉及 `spacetime publish`、表字段调整或 `migration.rs` 对齐时优先参考。
|
||||||
- [MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md](./MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md):冻结抓大鹅 Match3D 首版 demo 的独立玩法域、表与 procedure、HTTP facade、前端即时反馈/后端权威确认协议,以及可并行开发包。
|
- [MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md](./MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md):冻结抓大鹅 Match3D 首版 demo 的独立玩法域、表与 procedure、HTTP facade、前端即时反馈/后端权威确认协议,以及可并行开发包。
|
||||||
|
- [MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md](./MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md):冻结抓大鹅 Match3D B1+B2 的纯领域规则 crate、Rust/TypeScript shared contracts,以及 Stage1 不触碰 SpacetimeDB 表和 api-server 的边界。
|
||||||
- [PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md](./PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md):记录平台首页底部 dock 在手机浏览器地址栏展开时脱离可见区域的根因,以及 `100dvh`、固定底部锚点和安全区占位的修复口径。
|
- [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 自动迁移回灌和导入脚本参数。
|
- [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 规避参数。
|
- [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 规避参数。
|
||||||
|
|||||||
@@ -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` |
|
| 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` |
|
| 世界创作 | `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` |
|
| 拼图 | `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` |
|
| 大鱼吃小鱼 | `big_fish_creation_session`, `big_fish_agent_message`, `big_fish_asset_slot`, `big_fish_runtime_run` |
|
||||||
| 资产 | `asset_object`, `asset_entity_binding` |
|
| 资产 | `asset_object`, `asset_entity_binding` |
|
||||||
| AI 任务 | `ai_task`, `ai_task_stage`, `ai_text_chunk`, `ai_result_reference` |
|
| AI 任务 | `ai_task`, `ai_task_stage`, `ai_text_chunk`, `ai_result_reference` |
|
||||||
@@ -446,6 +447,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;
|
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`
|
### `big_fish_creation_session`
|
||||||
|
|||||||
119
packages/shared/src/contracts/match3dAgent.ts
Normal file
119
packages/shared/src/contracts/match3dAgent.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
export type Match3DCreationStage =
|
||||||
|
| 'collecting_config'
|
||||||
|
| 'draft_ready'
|
||||||
|
| 'ready_to_publish'
|
||||||
|
| 'published'
|
||||||
|
| string;
|
||||||
|
|
||||||
|
export type Match3DAgentMessageRole = 'user' | 'assistant' | 'system' | string;
|
||||||
|
|
||||||
|
export type Match3DAgentMessageKind =
|
||||||
|
| 'chat'
|
||||||
|
| 'summary'
|
||||||
|
| 'action_result'
|
||||||
|
| 'warning'
|
||||||
|
| string;
|
||||||
|
|
||||||
|
export type Match3DAnchorStatus = 'confirmed' | 'missing' | 'inferred' | 'locked' | string;
|
||||||
|
|
||||||
|
export interface CreateMatch3DAgentSessionRequest {
|
||||||
|
seedText?: string;
|
||||||
|
themeText?: string;
|
||||||
|
referenceImageSrc?: string | null;
|
||||||
|
clearCount?: number;
|
||||||
|
difficulty?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateMatch3DSessionRequest = CreateMatch3DAgentSessionRequest;
|
||||||
|
|
||||||
|
export interface SendMatch3DAgentMessageRequest {
|
||||||
|
clientMessageId: string;
|
||||||
|
text: string;
|
||||||
|
quickFillRequested?: boolean;
|
||||||
|
referenceImageSrc?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SendMatch3DMessageRequest = SendMatch3DAgentMessageRequest;
|
||||||
|
|
||||||
|
export interface ExecuteMatch3DAgentActionRequest {
|
||||||
|
action: string;
|
||||||
|
gameName?: string;
|
||||||
|
summary?: string;
|
||||||
|
tags?: string[];
|
||||||
|
coverImageSrc?: string | null;
|
||||||
|
clearCount?: number;
|
||||||
|
difficulty?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExecuteMatch3DActionRequest = ExecuteMatch3DAgentActionRequest;
|
||||||
|
|
||||||
|
export interface Match3DAnchorItemResponse {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
status: Match3DAnchorStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Match3DAnchorPackResponse {
|
||||||
|
theme: Match3DAnchorItemResponse;
|
||||||
|
clearCount: Match3DAnchorItemResponse;
|
||||||
|
difficulty: Match3DAnchorItemResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Match3DCreatorConfig {
|
||||||
|
themeText: string;
|
||||||
|
referenceImageSrc?: string | null;
|
||||||
|
clearCount: number;
|
||||||
|
difficulty: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Match3DResultDraft {
|
||||||
|
gameName: string;
|
||||||
|
themeText: string;
|
||||||
|
summaryText?: string;
|
||||||
|
summary?: string;
|
||||||
|
tags: string[];
|
||||||
|
coverImageSrc?: string | null;
|
||||||
|
referenceImageSrc?: string | null;
|
||||||
|
clearCount: number;
|
||||||
|
difficulty: number;
|
||||||
|
totalItemCount?: number;
|
||||||
|
publishReady?: boolean;
|
||||||
|
blockers?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Match3DAgentMessage {
|
||||||
|
id: string;
|
||||||
|
role: Match3DAgentMessageRole;
|
||||||
|
kind: Match3DAgentMessageKind;
|
||||||
|
text: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Match3DAgentMessageResponse = Match3DAgentMessage;
|
||||||
|
|
||||||
|
export interface Match3DAgentSessionSnapshot {
|
||||||
|
sessionId: string;
|
||||||
|
currentTurn: number;
|
||||||
|
progressPercent: number;
|
||||||
|
stage: Match3DCreationStage;
|
||||||
|
anchorPack: Match3DAnchorPackResponse;
|
||||||
|
config?: Match3DCreatorConfig | null;
|
||||||
|
draft?: Match3DResultDraft | null;
|
||||||
|
messages: Match3DAgentMessage[];
|
||||||
|
lastAssistantReply?: string | null;
|
||||||
|
publishedProfileId?: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Match3DAgentSessionResponse {
|
||||||
|
session: Match3DAgentSessionSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Match3DSessionResponse = Match3DAgentSessionResponse;
|
||||||
|
|
||||||
|
export interface Match3DAgentActionResponse {
|
||||||
|
session: Match3DAgentSessionSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Match3DActionResponse = Match3DAgentActionResponse;
|
||||||
125
packages/shared/src/contracts/match3dRuntime.ts
Normal file
125
packages/shared/src/contracts/match3dRuntime.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
export type Match3DRunStatus =
|
||||||
|
| 'running'
|
||||||
|
| 'won'
|
||||||
|
| 'failed'
|
||||||
|
| 'stopped'
|
||||||
|
| 'Running'
|
||||||
|
| 'Won'
|
||||||
|
| 'Failed'
|
||||||
|
| 'Stopped'
|
||||||
|
| string;
|
||||||
|
export type Match3DItemState =
|
||||||
|
| 'in_board'
|
||||||
|
| 'in_tray'
|
||||||
|
| 'cleared'
|
||||||
|
| 'InBoard'
|
||||||
|
| 'Flying'
|
||||||
|
| 'InTray'
|
||||||
|
| 'Cleared'
|
||||||
|
| string;
|
||||||
|
export type Match3DFailureReason =
|
||||||
|
| 'time_up'
|
||||||
|
| 'tray_full'
|
||||||
|
| 'TimeUp'
|
||||||
|
| 'TrayFull'
|
||||||
|
| string;
|
||||||
|
export type Match3DClickRejectReason =
|
||||||
|
| 'run_not_active'
|
||||||
|
| 'snapshot_version_mismatch'
|
||||||
|
| 'item_not_found'
|
||||||
|
| 'item_not_in_board'
|
||||||
|
| 'item_not_clickable'
|
||||||
|
| 'tray_full'
|
||||||
|
| string;
|
||||||
|
|
||||||
|
export type Match3DClickConfirmStatus =
|
||||||
|
| 'Accepted'
|
||||||
|
| 'RejectedNotClickable'
|
||||||
|
| 'RejectedAlreadyMoved'
|
||||||
|
| 'RejectedTrayFull'
|
||||||
|
| 'VersionConflict'
|
||||||
|
| 'RunFinished';
|
||||||
|
|
||||||
|
export interface StartMatch3DRunRequest {
|
||||||
|
profileId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Match3DClickItemRequest {
|
||||||
|
runId?: string;
|
||||||
|
itemInstanceId: string;
|
||||||
|
clientActionId?: string;
|
||||||
|
snapshotVersion?: number;
|
||||||
|
clientSnapshotVersion: number;
|
||||||
|
clientEventId: string;
|
||||||
|
clickedAtMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClickMatch3DItemRequest = Match3DClickItemRequest;
|
||||||
|
|
||||||
|
export interface StopMatch3DRunRequest {
|
||||||
|
clientActionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Match3DItemSnapshot {
|
||||||
|
itemInstanceId: string;
|
||||||
|
itemTypeId: string;
|
||||||
|
visualKey: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
radius: number;
|
||||||
|
layer: number;
|
||||||
|
state: Match3DItemState;
|
||||||
|
clickable: boolean;
|
||||||
|
traySlotIndex?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Match3DTraySlot {
|
||||||
|
slotIndex: number;
|
||||||
|
itemInstanceId?: string | null;
|
||||||
|
itemTypeId?: string | null;
|
||||||
|
visualKey?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Match3DRunSnapshot {
|
||||||
|
runId: string;
|
||||||
|
profileId: string;
|
||||||
|
ownerUserId?: string;
|
||||||
|
status: Match3DRunStatus;
|
||||||
|
snapshotVersion: number;
|
||||||
|
startedAtMs: number;
|
||||||
|
durationLimitMs: number;
|
||||||
|
serverNowMs?: number;
|
||||||
|
remainingMs: number;
|
||||||
|
clearCount: number;
|
||||||
|
totalItemCount: number;
|
||||||
|
clearedItemCount: number;
|
||||||
|
boardVersion?: number;
|
||||||
|
items: Match3DItemSnapshot[];
|
||||||
|
traySlots: Match3DTraySlot[];
|
||||||
|
failureReason?: Match3DFailureReason | null;
|
||||||
|
lastConfirmedActionId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Match3DClickConfirmation {
|
||||||
|
accepted: boolean;
|
||||||
|
rejectReason?: Match3DClickRejectReason | null;
|
||||||
|
enteredSlotIndex?: number | null;
|
||||||
|
clearedItemInstanceIds: string[];
|
||||||
|
run: Match3DRunSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Match3DClickItemResult {
|
||||||
|
status: Match3DClickConfirmStatus;
|
||||||
|
run: Match3DRunSnapshot;
|
||||||
|
acceptedItemInstanceId?: string;
|
||||||
|
clearedItemInstanceIds: string[];
|
||||||
|
failureReason?: Match3DFailureReason | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Match3DRunResponse {
|
||||||
|
run: Match3DRunSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Match3DClickResponse {
|
||||||
|
confirmation: Match3DClickConfirmation;
|
||||||
|
}
|
||||||
45
packages/shared/src/contracts/match3dWorks.ts
Normal file
45
packages/shared/src/contracts/match3dWorks.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
export type Match3DWorkPublicationStatus = 'draft' | 'published' | string;
|
||||||
|
|
||||||
|
export interface PutMatch3DWorkRequest {
|
||||||
|
gameName: string;
|
||||||
|
summary: string;
|
||||||
|
tags: string[];
|
||||||
|
coverImageSrc?: string | null;
|
||||||
|
referenceImageSrc?: string | null;
|
||||||
|
clearCount: number;
|
||||||
|
difficulty: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Match3DWorkSummary {
|
||||||
|
workId: string;
|
||||||
|
profileId: string;
|
||||||
|
ownerUserId: string;
|
||||||
|
sourceSessionId?: string | null;
|
||||||
|
gameName: string;
|
||||||
|
themeText: string;
|
||||||
|
summary: string;
|
||||||
|
tags: string[];
|
||||||
|
coverImageSrc?: string | null;
|
||||||
|
referenceImageSrc?: string | null;
|
||||||
|
clearCount: number;
|
||||||
|
difficulty: number;
|
||||||
|
publicationStatus: Match3DWorkPublicationStatus;
|
||||||
|
playCount: number;
|
||||||
|
updatedAt: string;
|
||||||
|
publishedAt?: string | null;
|
||||||
|
publishReady: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Match3DWorkProfile extends Match3DWorkSummary {}
|
||||||
|
|
||||||
|
export interface Match3DWorksResponse {
|
||||||
|
items: Match3DWorkSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Match3DWorkDetailResponse {
|
||||||
|
item: Match3DWorkProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Match3DWorkMutationResponse {
|
||||||
|
item: Match3DWorkProfile;
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@ export * from './contracts/rpgCreationFixtures';
|
|||||||
export * from './contracts/rpgCreationPreview';
|
export * from './contracts/rpgCreationPreview';
|
||||||
export * from './contracts/rpgCreationResultView';
|
export * from './contracts/rpgCreationResultView';
|
||||||
export * from './contracts/rpgCreationWorkSummary';
|
export * from './contracts/rpgCreationWorkSummary';
|
||||||
|
export * from './contracts/match3dAgent';
|
||||||
|
export * from './contracts/match3dRuntime';
|
||||||
|
export * from './contracts/match3dWorks';
|
||||||
export * from './contracts/puzzleAgentActions';
|
export * from './contracts/puzzleAgentActions';
|
||||||
export * from './contracts/puzzleAgentDraft';
|
export * from './contracts/puzzleAgentDraft';
|
||||||
export * from './contracts/puzzleAgentSession';
|
export * from './contracts/puzzleAgentSession';
|
||||||
|
|||||||
9
server-rs/Cargo.lock
generated
9
server-rs/Cargo.lock
generated
@@ -1562,6 +1562,15 @@ dependencies = [
|
|||||||
"spacetimedb",
|
"spacetimedb",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "module-match3d"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"shared-kernel",
|
||||||
|
"spacetimedb",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "module-npc"
|
name = "module-npc"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ members = [
|
|||||||
"crates/module-combat",
|
"crates/module-combat",
|
||||||
"crates/module-inventory",
|
"crates/module-inventory",
|
||||||
"crates/module-custom-world",
|
"crates/module-custom-world",
|
||||||
|
"crates/module-match3d",
|
||||||
"crates/module-npc",
|
"crates/module-npc",
|
||||||
"crates/module-puzzle",
|
"crates/module-puzzle",
|
||||||
"crates/module-progression",
|
"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 }
|
||||||
996
server-rs/crates/module-match3d/src/lib.rs
Normal file
996
server-rs/crates/module-match3d/src/lib.rs
Normal file
@@ -0,0 +1,996 @@
|
|||||||
|
use std::{error::Error, fmt};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use shared_kernel::{normalize_optional_string, normalize_required_string, normalize_string_list};
|
||||||
|
#[cfg(feature = "spacetime-types")]
|
||||||
|
use spacetimedb::SpacetimeType;
|
||||||
|
|
||||||
|
pub const MATCH3D_SESSION_ID_PREFIX: &str = "match3d-session-";
|
||||||
|
pub const MATCH3D_MESSAGE_ID_PREFIX: &str = "match3d-message-";
|
||||||
|
pub const MATCH3D_PROFILE_ID_PREFIX: &str = "match3d-profile-";
|
||||||
|
pub const MATCH3D_WORK_ID_PREFIX: &str = "match3d-work-";
|
||||||
|
pub const MATCH3D_RUN_ID_PREFIX: &str = "match3d-run-";
|
||||||
|
pub const MATCH3D_TRAY_SLOT_COUNT: u32 = 7;
|
||||||
|
pub const MATCH3D_ITEMS_PER_CLEAR: u32 = 3;
|
||||||
|
pub const MATCH3D_MIN_DIFFICULTY: u32 = 1;
|
||||||
|
pub const MATCH3D_MAX_DIFFICULTY: u32 = 10;
|
||||||
|
pub const MATCH3D_DEFAULT_DURATION_LIMIT_MS: u64 = 10 * 60 * 1000;
|
||||||
|
pub const MATCH3D_BOARD_RADIUS: f32 = 1.0;
|
||||||
|
|
||||||
|
const MATCH3D_DEMO_VISUAL_KEYS: [&str; 10] = [
|
||||||
|
"red_circle",
|
||||||
|
"yellow_triangle",
|
||||||
|
"purple_diamond",
|
||||||
|
"green_square",
|
||||||
|
"blue_star",
|
||||||
|
"orange_hexagon",
|
||||||
|
"cyan_capsule",
|
||||||
|
"pink_heart",
|
||||||
|
"lime_leaf",
|
||||||
|
"white_moon",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum Match3DCreationStage {
|
||||||
|
CollectingConfig,
|
||||||
|
DraftReady,
|
||||||
|
ReadyToPublish,
|
||||||
|
Published,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum Match3DPublicationStatus {
|
||||||
|
Draft,
|
||||||
|
Published,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum Match3DRunStatus {
|
||||||
|
Running,
|
||||||
|
Won,
|
||||||
|
Failed,
|
||||||
|
Stopped,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum Match3DFailureReason {
|
||||||
|
TimeUp,
|
||||||
|
TrayFull,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum Match3DItemState {
|
||||||
|
InBoard,
|
||||||
|
InTray,
|
||||||
|
Cleared,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum Match3DClickRejectReason {
|
||||||
|
RunNotActive,
|
||||||
|
SnapshotVersionMismatch,
|
||||||
|
ItemNotFound,
|
||||||
|
ItemNotInBoard,
|
||||||
|
ItemNotClickable,
|
||||||
|
TrayFull,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Match3DCreatorConfig {
|
||||||
|
pub theme_text: String,
|
||||||
|
pub reference_image_src: Option<String>,
|
||||||
|
pub clear_count: u32,
|
||||||
|
pub difficulty: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Match3DResultDraft {
|
||||||
|
pub game_name: String,
|
||||||
|
pub theme_text: String,
|
||||||
|
pub summary: String,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub cover_image_src: Option<String>,
|
||||||
|
pub reference_image_src: Option<String>,
|
||||||
|
pub clear_count: u32,
|
||||||
|
pub difficulty: u32,
|
||||||
|
pub publish_ready: bool,
|
||||||
|
pub blockers: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Match3DWorkProfile {
|
||||||
|
pub work_id: String,
|
||||||
|
pub profile_id: String,
|
||||||
|
pub owner_user_id: String,
|
||||||
|
pub source_session_id: Option<String>,
|
||||||
|
pub game_name: String,
|
||||||
|
pub theme_text: String,
|
||||||
|
pub summary: String,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub cover_image_src: Option<String>,
|
||||||
|
pub reference_image_src: Option<String>,
|
||||||
|
pub clear_count: u32,
|
||||||
|
pub difficulty: u32,
|
||||||
|
pub publication_status: Match3DPublicationStatus,
|
||||||
|
pub play_count: u32,
|
||||||
|
pub updated_at_micros: i64,
|
||||||
|
pub published_at_micros: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Match3DItemSnapshot {
|
||||||
|
pub item_instance_id: String,
|
||||||
|
pub item_type_id: String,
|
||||||
|
pub visual_key: String,
|
||||||
|
pub x: f32,
|
||||||
|
pub y: f32,
|
||||||
|
pub radius: f32,
|
||||||
|
pub layer: u32,
|
||||||
|
pub state: Match3DItemState,
|
||||||
|
pub clickable: bool,
|
||||||
|
pub tray_slot_index: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Match3DTraySlot {
|
||||||
|
pub slot_index: u32,
|
||||||
|
pub item_instance_id: Option<String>,
|
||||||
|
pub item_type_id: Option<String>,
|
||||||
|
pub visual_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Match3DRunSnapshot {
|
||||||
|
pub run_id: String,
|
||||||
|
pub profile_id: String,
|
||||||
|
pub owner_user_id: String,
|
||||||
|
pub status: Match3DRunStatus,
|
||||||
|
pub started_at_ms: u64,
|
||||||
|
pub duration_limit_ms: u64,
|
||||||
|
pub remaining_ms: u64,
|
||||||
|
pub clear_count: u32,
|
||||||
|
pub total_item_count: u32,
|
||||||
|
pub cleared_item_count: u32,
|
||||||
|
pub board_version: u64,
|
||||||
|
pub items: Vec<Match3DItemSnapshot>,
|
||||||
|
pub tray_slots: Vec<Match3DTraySlot>,
|
||||||
|
pub failure_reason: Option<Match3DFailureReason>,
|
||||||
|
pub last_confirmed_action_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Match3DClickInput {
|
||||||
|
pub run_id: String,
|
||||||
|
pub owner_user_id: String,
|
||||||
|
pub item_instance_id: String,
|
||||||
|
pub client_action_id: String,
|
||||||
|
pub snapshot_version: u64,
|
||||||
|
pub clicked_at_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Match3DClickConfirmation {
|
||||||
|
pub accepted: bool,
|
||||||
|
pub reject_reason: Option<Match3DClickRejectReason>,
|
||||||
|
pub entered_slot_index: Option<u32>,
|
||||||
|
pub cleared_item_instance_ids: Vec<String>,
|
||||||
|
pub run: Match3DRunSnapshot,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Match3DFieldError {
|
||||||
|
MissingText,
|
||||||
|
MissingOwnerUserId,
|
||||||
|
MissingProfileId,
|
||||||
|
MissingRunId,
|
||||||
|
MissingItemId,
|
||||||
|
InvalidClearCount,
|
||||||
|
InvalidDifficulty,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Match3DFieldError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::MissingText => write!(f, "必填文本缺失"),
|
||||||
|
Self::MissingOwnerUserId => write!(f, "owner_user_id 缺失"),
|
||||||
|
Self::MissingProfileId => write!(f, "profile_id 缺失"),
|
||||||
|
Self::MissingRunId => write!(f, "run_id 缺失"),
|
||||||
|
Self::MissingItemId => write!(f, "item_instance_id 缺失"),
|
||||||
|
Self::InvalidClearCount => write!(f, "需要消除次数必须为正整数"),
|
||||||
|
Self::InvalidDifficulty => write!(f, "难度必须在 1 到 10 之间"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for Match3DFieldError {}
|
||||||
|
|
||||||
|
impl Match3DCreationStage {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::CollectingConfig => "collecting_config",
|
||||||
|
Self::DraftReady => "draft_ready",
|
||||||
|
Self::ReadyToPublish => "ready_to_publish",
|
||||||
|
Self::Published => "published",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Match3DPublicationStatus {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Draft => "draft",
|
||||||
|
Self::Published => "published",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Match3DRunStatus {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Running => "running",
|
||||||
|
Self::Won => "won",
|
||||||
|
Self::Failed => "failed",
|
||||||
|
Self::Stopped => "stopped",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Match3DFailureReason {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::TimeUp => "time_up",
|
||||||
|
Self::TrayFull => "tray_full",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Match3DItemState {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::InBoard => "in_board",
|
||||||
|
Self::InTray => "in_tray",
|
||||||
|
Self::Cleared => "cleared",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Match3DClickRejectReason {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::RunNotActive => "run_not_active",
|
||||||
|
Self::SnapshotVersionMismatch => "snapshot_version_mismatch",
|
||||||
|
Self::ItemNotFound => "item_not_found",
|
||||||
|
Self::ItemNotInBoard => "item_not_in_board",
|
||||||
|
Self::ItemNotClickable => "item_not_clickable",
|
||||||
|
Self::TrayFull => "tray_full",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_creator_config(
|
||||||
|
theme_text: &str,
|
||||||
|
reference_image_src: Option<String>,
|
||||||
|
clear_count: u32,
|
||||||
|
difficulty: u32,
|
||||||
|
) -> Result<Match3DCreatorConfig, Match3DFieldError> {
|
||||||
|
let theme_text = normalize_required_string(theme_text).ok_or(Match3DFieldError::MissingText)?;
|
||||||
|
if clear_count == 0 {
|
||||||
|
return Err(Match3DFieldError::InvalidClearCount);
|
||||||
|
}
|
||||||
|
if !(MATCH3D_MIN_DIFFICULTY..=MATCH3D_MAX_DIFFICULTY).contains(&difficulty) {
|
||||||
|
return Err(Match3DFieldError::InvalidDifficulty);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Match3DCreatorConfig {
|
||||||
|
theme_text,
|
||||||
|
reference_image_src: normalize_optional_string(reference_image_src),
|
||||||
|
clear_count,
|
||||||
|
difficulty,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compile_result_draft(config: &Match3DCreatorConfig) -> Match3DResultDraft {
|
||||||
|
let game_name = format!("{}抓大鹅", config.theme_text);
|
||||||
|
let summary = format!(
|
||||||
|
"{}主题,{} 次消除目标,难度 {}。",
|
||||||
|
config.theme_text, config.clear_count, config.difficulty
|
||||||
|
);
|
||||||
|
let tags = default_tags_for_theme(&config.theme_text);
|
||||||
|
let blockers = validate_basic_publish_fields(&game_name, &summary, &tags);
|
||||||
|
|
||||||
|
Match3DResultDraft {
|
||||||
|
game_name,
|
||||||
|
theme_text: config.theme_text.clone(),
|
||||||
|
summary,
|
||||||
|
tags,
|
||||||
|
cover_image_src: None,
|
||||||
|
reference_image_src: config.reference_image_src.clone(),
|
||||||
|
clear_count: config.clear_count,
|
||||||
|
difficulty: config.difficulty,
|
||||||
|
publish_ready: blockers.is_empty(),
|
||||||
|
blockers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_publish_requirements(draft: &Match3DResultDraft) -> Vec<String> {
|
||||||
|
let mut blockers = validate_basic_publish_fields(&draft.game_name, &draft.summary, &draft.tags);
|
||||||
|
if draft.clear_count == 0 {
|
||||||
|
blockers.push("需要消除次数必须为正整数".to_string());
|
||||||
|
}
|
||||||
|
if !(MATCH3D_MIN_DIFFICULTY..=MATCH3D_MAX_DIFFICULTY).contains(&draft.difficulty) {
|
||||||
|
blockers.push("难度必须在 1 到 10 之间".to_string());
|
||||||
|
}
|
||||||
|
blockers
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_work_profile(
|
||||||
|
work_id: String,
|
||||||
|
profile_id: String,
|
||||||
|
owner_user_id: String,
|
||||||
|
source_session_id: Option<String>,
|
||||||
|
draft: &Match3DResultDraft,
|
||||||
|
updated_at_micros: i64,
|
||||||
|
) -> Result<Match3DWorkProfile, Match3DFieldError> {
|
||||||
|
let work_id = normalize_required_string(work_id).ok_or(Match3DFieldError::MissingText)?;
|
||||||
|
let profile_id =
|
||||||
|
normalize_required_string(profile_id).ok_or(Match3DFieldError::MissingProfileId)?;
|
||||||
|
let owner_user_id =
|
||||||
|
normalize_required_string(owner_user_id).ok_or(Match3DFieldError::MissingOwnerUserId)?;
|
||||||
|
|
||||||
|
Ok(Match3DWorkProfile {
|
||||||
|
work_id,
|
||||||
|
profile_id,
|
||||||
|
owner_user_id,
|
||||||
|
source_session_id: normalize_optional_string(source_session_id),
|
||||||
|
game_name: draft.game_name.clone(),
|
||||||
|
theme_text: draft.theme_text.clone(),
|
||||||
|
summary: draft.summary.clone(),
|
||||||
|
tags: normalize_string_list(draft.tags.clone()),
|
||||||
|
cover_image_src: draft.cover_image_src.clone(),
|
||||||
|
reference_image_src: draft.reference_image_src.clone(),
|
||||||
|
clear_count: draft.clear_count,
|
||||||
|
difficulty: draft.difficulty,
|
||||||
|
publication_status: Match3DPublicationStatus::Draft,
|
||||||
|
play_count: 0,
|
||||||
|
updated_at_micros,
|
||||||
|
published_at_micros: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn publish_work_profile(
|
||||||
|
profile: &Match3DWorkProfile,
|
||||||
|
published_at_micros: i64,
|
||||||
|
) -> Result<Match3DWorkProfile, Match3DFieldError> {
|
||||||
|
if profile.clear_count == 0 {
|
||||||
|
return Err(Match3DFieldError::InvalidClearCount);
|
||||||
|
}
|
||||||
|
if !(MATCH3D_MIN_DIFFICULTY..=MATCH3D_MAX_DIFFICULTY).contains(&profile.difficulty) {
|
||||||
|
return Err(Match3DFieldError::InvalidDifficulty);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut next = profile.clone();
|
||||||
|
next.publication_status = Match3DPublicationStatus::Published;
|
||||||
|
next.updated_at_micros = published_at_micros;
|
||||||
|
next.published_at_micros = Some(published_at_micros);
|
||||||
|
Ok(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_run_with_seed_at(
|
||||||
|
run_id: String,
|
||||||
|
owner_user_id: String,
|
||||||
|
profile_id: String,
|
||||||
|
config: &Match3DCreatorConfig,
|
||||||
|
seed: u64,
|
||||||
|
started_at_ms: u64,
|
||||||
|
) -> Result<Match3DRunSnapshot, Match3DFieldError> {
|
||||||
|
let run_id = normalize_required_string(run_id).ok_or(Match3DFieldError::MissingRunId)?;
|
||||||
|
let owner_user_id =
|
||||||
|
normalize_required_string(owner_user_id).ok_or(Match3DFieldError::MissingOwnerUserId)?;
|
||||||
|
let profile_id =
|
||||||
|
normalize_required_string(profile_id).ok_or(Match3DFieldError::MissingProfileId)?;
|
||||||
|
|
||||||
|
let total_item_count = config
|
||||||
|
.clear_count
|
||||||
|
.checked_mul(MATCH3D_ITEMS_PER_CLEAR)
|
||||||
|
.ok_or(Match3DFieldError::InvalidClearCount)?;
|
||||||
|
let mut run = Match3DRunSnapshot {
|
||||||
|
run_id,
|
||||||
|
profile_id,
|
||||||
|
owner_user_id,
|
||||||
|
status: Match3DRunStatus::Running,
|
||||||
|
started_at_ms,
|
||||||
|
duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS,
|
||||||
|
remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS,
|
||||||
|
clear_count: config.clear_count,
|
||||||
|
total_item_count,
|
||||||
|
cleared_item_count: 0,
|
||||||
|
board_version: 1,
|
||||||
|
items: build_initial_items(config.clear_count, config.difficulty, seed),
|
||||||
|
tray_slots: empty_tray_slots(),
|
||||||
|
failure_reason: None,
|
||||||
|
last_confirmed_action_id: None,
|
||||||
|
};
|
||||||
|
refresh_clickable_flags(&mut run);
|
||||||
|
Ok(run)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn confirm_click_at(
|
||||||
|
run: &Match3DRunSnapshot,
|
||||||
|
input: &Match3DClickInput,
|
||||||
|
) -> Result<Match3DClickConfirmation, Match3DFieldError> {
|
||||||
|
let item_instance_id = normalize_required_string(&input.item_instance_id)
|
||||||
|
.ok_or(Match3DFieldError::MissingItemId)?;
|
||||||
|
let client_action_id = normalize_required_string(&input.client_action_id)
|
||||||
|
.unwrap_or_else(|| "match3d-action-unknown".to_string());
|
||||||
|
|
||||||
|
let mut next = resolve_run_timer_at(run, input.clicked_at_ms);
|
||||||
|
if next.status != Match3DRunStatus::Running {
|
||||||
|
return Ok(rejected(next, Match3DClickRejectReason::RunNotActive));
|
||||||
|
}
|
||||||
|
if input.snapshot_version != next.board_version {
|
||||||
|
return Ok(rejected(
|
||||||
|
next,
|
||||||
|
Match3DClickRejectReason::SnapshotVersionMismatch,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(item_index) = next
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.position(|item| item.item_instance_id == item_instance_id)
|
||||||
|
else {
|
||||||
|
return Ok(rejected(next, Match3DClickRejectReason::ItemNotFound));
|
||||||
|
};
|
||||||
|
|
||||||
|
if next.items[item_index].state != Match3DItemState::InBoard {
|
||||||
|
return Ok(rejected(next, Match3DClickRejectReason::ItemNotInBoard));
|
||||||
|
}
|
||||||
|
if !next.items[item_index].clickable {
|
||||||
|
return Ok(rejected(next, Match3DClickRejectReason::ItemNotClickable));
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(slot_index) = first_empty_slot_index(&next.tray_slots) else {
|
||||||
|
next = fail_run(next, Match3DFailureReason::TrayFull, client_action_id);
|
||||||
|
return Ok(rejected(next, Match3DClickRejectReason::TrayFull));
|
||||||
|
};
|
||||||
|
|
||||||
|
let item_type_id = next.items[item_index].item_type_id.clone();
|
||||||
|
next.items[item_index].state = Match3DItemState::InTray;
|
||||||
|
next.items[item_index].clickable = false;
|
||||||
|
next.items[item_index].tray_slot_index = Some(slot_index);
|
||||||
|
fill_tray_slot(&mut next.tray_slots, slot_index, &next.items[item_index]);
|
||||||
|
|
||||||
|
let cleared_item_instance_ids = clear_first_triple(&mut next, &item_type_id);
|
||||||
|
compact_tray(&mut next);
|
||||||
|
next.cleared_item_count = next
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.filter(|item| item.state == Match3DItemState::Cleared)
|
||||||
|
.count() as u32;
|
||||||
|
|
||||||
|
if next.cleared_item_count >= next.total_item_count {
|
||||||
|
next.status = Match3DRunStatus::Won;
|
||||||
|
} else if first_empty_slot_index(&next.tray_slots).is_none() {
|
||||||
|
next.status = Match3DRunStatus::Failed;
|
||||||
|
next.failure_reason = Some(Match3DFailureReason::TrayFull);
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh_clickable_flags(&mut next);
|
||||||
|
next.board_version += 1;
|
||||||
|
next.last_confirmed_action_id = Some(client_action_id);
|
||||||
|
|
||||||
|
Ok(Match3DClickConfirmation {
|
||||||
|
accepted: true,
|
||||||
|
reject_reason: None,
|
||||||
|
entered_slot_index: Some(slot_index),
|
||||||
|
cleared_item_instance_ids,
|
||||||
|
run: next,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_run_timer_at(run: &Match3DRunSnapshot, now_ms: u64) -> Match3DRunSnapshot {
|
||||||
|
let mut next = run.clone();
|
||||||
|
if next.status != Match3DRunStatus::Running {
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
let elapsed_ms = now_ms.saturating_sub(next.started_at_ms);
|
||||||
|
next.remaining_ms = next.duration_limit_ms.saturating_sub(elapsed_ms);
|
||||||
|
if next.remaining_ms == 0 {
|
||||||
|
next.status = Match3DRunStatus::Failed;
|
||||||
|
next.failure_reason = Some(Match3DFailureReason::TimeUp);
|
||||||
|
next.board_version += 1;
|
||||||
|
}
|
||||||
|
next
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop_run_at(run: &Match3DRunSnapshot, stopped_action_id: String) -> Match3DRunSnapshot {
|
||||||
|
let mut next = run.clone();
|
||||||
|
if next.status == Match3DRunStatus::Running {
|
||||||
|
next.status = Match3DRunStatus::Stopped;
|
||||||
|
next.board_version += 1;
|
||||||
|
next.last_confirmed_action_id = normalize_required_string(stopped_action_id);
|
||||||
|
}
|
||||||
|
next
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh_clickable_flags(run: &mut Match3DRunSnapshot) {
|
||||||
|
let board_items = run
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.filter(|item| item.state == Match3DItemState::InBoard)
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
for item in &mut run.items {
|
||||||
|
if item.state != Match3DItemState::InBoard {
|
||||||
|
item.clickable = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.clickable = !board_items.iter().any(|cover| {
|
||||||
|
cover.layer > item.layer
|
||||||
|
&& fully_covers(cover.x, cover.y, cover.radius, item.x, item.y, item.radius)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_initial_items(clear_count: u32, difficulty: u32, seed: u64) -> Vec<Match3DItemSnapshot> {
|
||||||
|
let mut rng = DeterministicRng::new(seed ^ ((clear_count as u64) << 32) ^ difficulty as u64);
|
||||||
|
let radius = resolve_item_radius(difficulty);
|
||||||
|
let mut items = Vec::with_capacity((clear_count * MATCH3D_ITEMS_PER_CLEAR) as usize);
|
||||||
|
|
||||||
|
for clear_index in 0..clear_count {
|
||||||
|
let visual_index = (clear_index as usize) % MATCH3D_DEMO_VISUAL_KEYS.len();
|
||||||
|
let item_type_id = format!("match3d-type-{:02}", visual_index + 1);
|
||||||
|
let visual_key = MATCH3D_DEMO_VISUAL_KEYS[visual_index].to_string();
|
||||||
|
|
||||||
|
for copy_index in 0..MATCH3D_ITEMS_PER_CLEAR {
|
||||||
|
let (x, y) = random_point_in_circle(&mut rng, MATCH3D_BOARD_RADIUS - radius);
|
||||||
|
let instance_index = clear_index * MATCH3D_ITEMS_PER_CLEAR + copy_index;
|
||||||
|
items.push(Match3DItemSnapshot {
|
||||||
|
item_instance_id: format!("match3d-item-{instance_index:04}"),
|
||||||
|
item_type_id: item_type_id.clone(),
|
||||||
|
visual_key: visual_key.clone(),
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
radius,
|
||||||
|
layer: instance_index,
|
||||||
|
state: Match3DItemState::InBoard,
|
||||||
|
clickable: true,
|
||||||
|
tray_slot_index: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 洗牌只改变层级顺序,不改变每组三个的可通关性。
|
||||||
|
for index in (1..items.len()).rev() {
|
||||||
|
let swap_index = (rng.next_u32() as usize) % (index + 1);
|
||||||
|
items.swap(index, swap_index);
|
||||||
|
}
|
||||||
|
for (layer, item) in items.iter_mut().enumerate() {
|
||||||
|
item.layer = layer as u32;
|
||||||
|
}
|
||||||
|
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_item_radius(difficulty: u32) -> f32 {
|
||||||
|
let clamped = difficulty.clamp(MATCH3D_MIN_DIFFICULTY, MATCH3D_MAX_DIFFICULTY);
|
||||||
|
let radius = 0.105 - (clamped as f32 - 1.0) * 0.0055;
|
||||||
|
radius.max(0.052)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn random_point_in_circle(rng: &mut DeterministicRng, max_radius: f32) -> (f32, f32) {
|
||||||
|
for _ in 0..24 {
|
||||||
|
let x = rng.next_unit_signed() * max_radius;
|
||||||
|
let y = rng.next_unit_signed() * max_radius;
|
||||||
|
if x * x + y * y <= max_radius * max_radius {
|
||||||
|
return (x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(0.0, 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fully_covers(
|
||||||
|
cover_x: f32,
|
||||||
|
cover_y: f32,
|
||||||
|
cover_radius: f32,
|
||||||
|
item_x: f32,
|
||||||
|
item_y: f32,
|
||||||
|
item_radius: f32,
|
||||||
|
) -> bool {
|
||||||
|
let dx = cover_x - item_x;
|
||||||
|
let dy = cover_y - item_y;
|
||||||
|
let distance = (dx * dx + dy * dy).sqrt();
|
||||||
|
distance + item_radius <= cover_radius * 0.96
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_tray_slots() -> Vec<Match3DTraySlot> {
|
||||||
|
(0..MATCH3D_TRAY_SLOT_COUNT)
|
||||||
|
.map(|slot_index| Match3DTraySlot {
|
||||||
|
slot_index,
|
||||||
|
item_instance_id: None,
|
||||||
|
item_type_id: None,
|
||||||
|
visual_key: None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_empty_slot_index(slots: &[Match3DTraySlot]) -> Option<u32> {
|
||||||
|
slots
|
||||||
|
.iter()
|
||||||
|
.find(|slot| slot.item_instance_id.is_none())
|
||||||
|
.map(|slot| slot.slot_index)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fill_tray_slot(slots: &mut [Match3DTraySlot], slot_index: u32, item: &Match3DItemSnapshot) {
|
||||||
|
if let Some(slot) = slots.iter_mut().find(|slot| slot.slot_index == slot_index) {
|
||||||
|
slot.item_instance_id = Some(item.item_instance_id.clone());
|
||||||
|
slot.item_type_id = Some(item.item_type_id.clone());
|
||||||
|
slot.visual_key = Some(item.visual_key.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_first_triple(run: &mut Match3DRunSnapshot, item_type_id: &str) -> Vec<String> {
|
||||||
|
let matched_slot_item_ids = run
|
||||||
|
.tray_slots
|
||||||
|
.iter()
|
||||||
|
.filter(|slot| slot.item_type_id.as_deref() == Some(item_type_id))
|
||||||
|
.filter_map(|slot| slot.item_instance_id.clone())
|
||||||
|
.take(MATCH3D_ITEMS_PER_CLEAR as usize)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
if matched_slot_item_ids.len() < MATCH3D_ITEMS_PER_CLEAR as usize {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
for item in &mut run.items {
|
||||||
|
if matched_slot_item_ids.contains(&item.item_instance_id) {
|
||||||
|
item.state = Match3DItemState::Cleared;
|
||||||
|
item.clickable = false;
|
||||||
|
item.tray_slot_index = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for slot in &mut run.tray_slots {
|
||||||
|
if slot
|
||||||
|
.item_instance_id
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|id| matched_slot_item_ids.contains(id))
|
||||||
|
{
|
||||||
|
slot.item_instance_id = None;
|
||||||
|
slot.item_type_id = None;
|
||||||
|
slot.visual_key = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
matched_slot_item_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compact_tray(run: &mut Match3DRunSnapshot) {
|
||||||
|
let mut occupied = run
|
||||||
|
.tray_slots
|
||||||
|
.iter()
|
||||||
|
.filter_map(|slot| {
|
||||||
|
Some((
|
||||||
|
slot.item_instance_id.clone()?,
|
||||||
|
slot.item_type_id.clone()?,
|
||||||
|
slot.visual_key.clone()?,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
for slot in &mut run.tray_slots {
|
||||||
|
slot.item_instance_id = None;
|
||||||
|
slot.item_type_id = None;
|
||||||
|
slot.visual_key = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (slot_index, (item_instance_id, item_type_id, visual_key)) in occupied.drain(..).enumerate()
|
||||||
|
{
|
||||||
|
let slot_index = slot_index as u32;
|
||||||
|
if let Some(slot) = run
|
||||||
|
.tray_slots
|
||||||
|
.iter_mut()
|
||||||
|
.find(|slot| slot.slot_index == slot_index)
|
||||||
|
{
|
||||||
|
slot.item_instance_id = Some(item_instance_id.clone());
|
||||||
|
slot.item_type_id = Some(item_type_id);
|
||||||
|
slot.visual_key = Some(visual_key);
|
||||||
|
}
|
||||||
|
if let Some(item) = run
|
||||||
|
.items
|
||||||
|
.iter_mut()
|
||||||
|
.find(|item| item.item_instance_id == item_instance_id)
|
||||||
|
{
|
||||||
|
item.tray_slot_index = Some(slot_index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fail_run(
|
||||||
|
mut run: Match3DRunSnapshot,
|
||||||
|
reason: Match3DFailureReason,
|
||||||
|
action_id: String,
|
||||||
|
) -> Match3DRunSnapshot {
|
||||||
|
run.status = Match3DRunStatus::Failed;
|
||||||
|
run.failure_reason = Some(reason);
|
||||||
|
run.board_version += 1;
|
||||||
|
run.last_confirmed_action_id = Some(action_id);
|
||||||
|
run
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rejected(
|
||||||
|
run: Match3DRunSnapshot,
|
||||||
|
reject_reason: Match3DClickRejectReason,
|
||||||
|
) -> Match3DClickConfirmation {
|
||||||
|
Match3DClickConfirmation {
|
||||||
|
accepted: false,
|
||||||
|
reject_reason: Some(reject_reason),
|
||||||
|
entered_slot_index: None,
|
||||||
|
cleared_item_instance_ids: Vec::new(),
|
||||||
|
run,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_basic_publish_fields(game_name: &str, summary: &str, tags: &[String]) -> Vec<String> {
|
||||||
|
let mut blockers = Vec::new();
|
||||||
|
if normalize_required_string(game_name).is_none() {
|
||||||
|
blockers.push("游戏名称不能为空".to_string());
|
||||||
|
}
|
||||||
|
if normalize_required_string(summary).is_none() {
|
||||||
|
blockers.push("简介不能为空".to_string());
|
||||||
|
}
|
||||||
|
let normalized_tags = normalize_string_list(tags.to_vec());
|
||||||
|
if normalized_tags.is_empty() {
|
||||||
|
blockers.push("至少需要 1 个标签".to_string());
|
||||||
|
}
|
||||||
|
blockers
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_tags_for_theme(theme_text: &str) -> Vec<String> {
|
||||||
|
let mut tags = vec![
|
||||||
|
"抓大鹅".to_string(),
|
||||||
|
"经典消除".to_string(),
|
||||||
|
theme_text.to_string(),
|
||||||
|
];
|
||||||
|
tags.sort();
|
||||||
|
tags.dedup();
|
||||||
|
tags
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DeterministicRng {
|
||||||
|
state: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeterministicRng {
|
||||||
|
fn new(seed: u64) -> Self {
|
||||||
|
Self { state: seed.max(1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_u32(&mut self) -> u32 {
|
||||||
|
let mut value = self.state;
|
||||||
|
value ^= value << 13;
|
||||||
|
value ^= value >> 7;
|
||||||
|
value ^= value << 17;
|
||||||
|
self.state = value;
|
||||||
|
(value >> 32) as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_unit_signed(&mut self) -> f32 {
|
||||||
|
let value = self.next_u32() as f32 / u32::MAX as f32;
|
||||||
|
value * 2.0 - 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn test_config(clear_count: u32) -> Match3DCreatorConfig {
|
||||||
|
build_creator_config("水果", None, clear_count, 4).expect("config should be valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manual_item(id: &str, type_id: &str, slot: Option<u32>) -> Match3DItemSnapshot {
|
||||||
|
Match3DItemSnapshot {
|
||||||
|
item_instance_id: id.to_string(),
|
||||||
|
item_type_id: type_id.to_string(),
|
||||||
|
visual_key: type_id.to_string(),
|
||||||
|
x: 0.0,
|
||||||
|
y: 0.0,
|
||||||
|
radius: 0.08,
|
||||||
|
layer: 0,
|
||||||
|
state: if slot.is_some() {
|
||||||
|
Match3DItemState::InTray
|
||||||
|
} else {
|
||||||
|
Match3DItemState::InBoard
|
||||||
|
},
|
||||||
|
clickable: slot.is_none(),
|
||||||
|
tray_slot_index: slot,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn creator_config_requires_positive_clear_count() {
|
||||||
|
let error = build_creator_config("水果", None, 0, 3).expect_err("zero should fail");
|
||||||
|
assert_eq!(error, Match3DFieldError::InvalidClearCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn initial_run_generates_triples() {
|
||||||
|
let run = start_run_with_seed_at(
|
||||||
|
"run-1".to_string(),
|
||||||
|
"user-1".to_string(),
|
||||||
|
"profile-1".to_string(),
|
||||||
|
&test_config(12),
|
||||||
|
42,
|
||||||
|
1_000,
|
||||||
|
)
|
||||||
|
.expect("run should start");
|
||||||
|
|
||||||
|
assert_eq!(run.total_item_count, 36);
|
||||||
|
let mut counts = BTreeMap::<String, u32>::new();
|
||||||
|
for item in &run.items {
|
||||||
|
*counts.entry(item.item_type_id.clone()).or_default() += 1;
|
||||||
|
}
|
||||||
|
assert!(counts.values().all(|count| count % 3 == 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clicking_three_same_items_clears_and_wins() {
|
||||||
|
let mut run = start_run_with_seed_at(
|
||||||
|
"run-1".to_string(),
|
||||||
|
"user-1".to_string(),
|
||||||
|
"profile-1".to_string(),
|
||||||
|
&test_config(1),
|
||||||
|
7,
|
||||||
|
10_000,
|
||||||
|
)
|
||||||
|
.expect("run should start");
|
||||||
|
for item in &mut run.items {
|
||||||
|
item.clickable = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ids = run
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.map(|item| item.item_instance_id.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
for (index, item_id) in ids.iter().enumerate() {
|
||||||
|
let input = Match3DClickInput {
|
||||||
|
run_id: run.run_id.clone(),
|
||||||
|
owner_user_id: run.owner_user_id.clone(),
|
||||||
|
item_instance_id: item_id.clone(),
|
||||||
|
client_action_id: format!("action-{index}"),
|
||||||
|
snapshot_version: run.board_version,
|
||||||
|
clicked_at_ms: 11_000 + index as u64,
|
||||||
|
};
|
||||||
|
run = confirm_click_at(&run, &input)
|
||||||
|
.expect("click should confirm")
|
||||||
|
.run;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(run.status, Match3DRunStatus::Won);
|
||||||
|
assert_eq!(run.cleared_item_count, 3);
|
||||||
|
assert!(
|
||||||
|
run.tray_slots
|
||||||
|
.iter()
|
||||||
|
.all(|slot| slot.item_instance_id.is_none())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tray_full_fails_when_no_triple_can_clear() {
|
||||||
|
let mut run = Match3DRunSnapshot {
|
||||||
|
run_id: "run-full".to_string(),
|
||||||
|
profile_id: "profile-1".to_string(),
|
||||||
|
owner_user_id: "user-1".to_string(),
|
||||||
|
status: Match3DRunStatus::Running,
|
||||||
|
started_at_ms: 0,
|
||||||
|
duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS,
|
||||||
|
remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS,
|
||||||
|
clear_count: 3,
|
||||||
|
total_item_count: 9,
|
||||||
|
cleared_item_count: 0,
|
||||||
|
board_version: 1,
|
||||||
|
items: (0..8)
|
||||||
|
.map(|index| manual_item(&format!("item-{index}"), &format!("type-{index}"), None))
|
||||||
|
.collect(),
|
||||||
|
tray_slots: empty_tray_slots(),
|
||||||
|
failure_reason: None,
|
||||||
|
last_confirmed_action_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
for index in 0..7 {
|
||||||
|
let input = Match3DClickInput {
|
||||||
|
run_id: run.run_id.clone(),
|
||||||
|
owner_user_id: run.owner_user_id.clone(),
|
||||||
|
item_instance_id: format!("item-{index}"),
|
||||||
|
client_action_id: format!("action-{index}"),
|
||||||
|
snapshot_version: run.board_version,
|
||||||
|
clicked_at_ms: 1_000 + index,
|
||||||
|
};
|
||||||
|
run = confirm_click_at(&run, &input)
|
||||||
|
.expect("click should confirm")
|
||||||
|
.run;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(run.status, Match3DRunStatus::Failed);
|
||||||
|
assert_eq!(run.failure_reason, Some(Match3DFailureReason::TrayFull));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn timer_expiration_fails_running_run() {
|
||||||
|
let run = start_run_with_seed_at(
|
||||||
|
"run-1".to_string(),
|
||||||
|
"user-1".to_string(),
|
||||||
|
"profile-1".to_string(),
|
||||||
|
&test_config(2),
|
||||||
|
9,
|
||||||
|
1_000,
|
||||||
|
)
|
||||||
|
.expect("run should start");
|
||||||
|
|
||||||
|
let expired = resolve_run_timer_at(&run, 1_000 + MATCH3D_DEFAULT_DURATION_LIMIT_MS);
|
||||||
|
|
||||||
|
assert_eq!(expired.status, Match3DRunStatus::Failed);
|
||||||
|
assert_eq!(expired.failure_reason, Some(Match3DFailureReason::TimeUp));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fully_covered_item_is_not_clickable() {
|
||||||
|
let mut run = Match3DRunSnapshot {
|
||||||
|
run_id: "run-cover".to_string(),
|
||||||
|
profile_id: "profile-1".to_string(),
|
||||||
|
owner_user_id: "user-1".to_string(),
|
||||||
|
status: Match3DRunStatus::Running,
|
||||||
|
started_at_ms: 0,
|
||||||
|
duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS,
|
||||||
|
remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS,
|
||||||
|
clear_count: 1,
|
||||||
|
total_item_count: 2,
|
||||||
|
cleared_item_count: 0,
|
||||||
|
board_version: 1,
|
||||||
|
items: vec![
|
||||||
|
Match3DItemSnapshot {
|
||||||
|
layer: 0,
|
||||||
|
radius: 0.04,
|
||||||
|
..manual_item("bottom", "type-a", None)
|
||||||
|
},
|
||||||
|
Match3DItemSnapshot {
|
||||||
|
layer: 1,
|
||||||
|
radius: 0.08,
|
||||||
|
..manual_item("top", "type-b", None)
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tray_slots: empty_tray_slots(),
|
||||||
|
failure_reason: None,
|
||||||
|
last_confirmed_action_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
refresh_clickable_flags(&mut run);
|
||||||
|
|
||||||
|
let bottom = run
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.item_instance_id == "bottom")
|
||||||
|
.expect("bottom item should exist");
|
||||||
|
assert!(!bottom.clickable);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,9 @@ pub mod big_fish;
|
|||||||
pub mod big_fish_works;
|
pub mod big_fish_works;
|
||||||
pub mod creation_agent_document_input;
|
pub mod creation_agent_document_input;
|
||||||
pub mod llm;
|
pub mod llm;
|
||||||
|
pub mod match3d_agent;
|
||||||
|
pub mod match3d_runtime;
|
||||||
|
pub mod match3d_works;
|
||||||
pub mod puzzle_agent;
|
pub mod puzzle_agent;
|
||||||
pub mod puzzle_gallery;
|
pub mod puzzle_gallery;
|
||||||
pub mod puzzle_runtime;
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
115
server-rs/crates/shared-contracts/src/match3d_runtime.rs
Normal file
115
server-rs/crates/shared-contracts/src/match3d_runtime.rs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct StartMatch3DRunRequest {
|
||||||
|
pub profile_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ClickMatch3DItemRequest {
|
||||||
|
pub item_instance_id: String,
|
||||||
|
pub client_action_id: String,
|
||||||
|
pub snapshot_version: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct StopMatch3DRunRequest {
|
||||||
|
pub client_action_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Match3DItemSnapshotResponse {
|
||||||
|
pub item_instance_id: String,
|
||||||
|
pub item_type_id: String,
|
||||||
|
pub visual_key: String,
|
||||||
|
pub x: f32,
|
||||||
|
pub y: f32,
|
||||||
|
pub radius: f32,
|
||||||
|
pub layer: u32,
|
||||||
|
pub state: String,
|
||||||
|
pub clickable: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tray_slot_index: Option<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,
|
||||||
|
pub started_at_ms: u64,
|
||||||
|
pub duration_limit_ms: u64,
|
||||||
|
pub remaining_ms: u64,
|
||||||
|
pub clear_count: u32,
|
||||||
|
pub total_item_count: u32,
|
||||||
|
pub cleared_item_count: u32,
|
||||||
|
pub board_version: u64,
|
||||||
|
pub items: Vec<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 {
|
||||||
|
item_instance_id: "item-1".to_string(),
|
||||||
|
client_action_id: "action-1".to_string(),
|
||||||
|
snapshot_version: 7,
|
||||||
|
})
|
||||||
|
.expect("payload should serialize");
|
||||||
|
|
||||||
|
assert_eq!(payload["itemInstanceId"], json!("item-1"));
|
||||||
|
assert_eq!(payload["clientActionId"], json!("action-1"));
|
||||||
|
assert_eq!(payload["snapshotVersion"], json!(7));
|
||||||
|
}
|
||||||
|
}
|
||||||
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-combat = { path = "../module-combat", default-features = false, features = ["spacetime-types"] }
|
||||||
module-inventory = { path = "../module-inventory", 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-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-npc = { path = "../module-npc", default-features = false, features = ["spacetime-types"] }
|
||||||
module-puzzle = { path = "../module-puzzle", 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"] }
|
module-progression = { path = "../module-progression", default-features = false, features = ["spacetime-types"] }
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ mod auth;
|
|||||||
mod big_fish;
|
mod big_fish;
|
||||||
mod domain_types;
|
mod domain_types;
|
||||||
mod entry;
|
mod entry;
|
||||||
|
mod match3d;
|
||||||
mod migration;
|
mod migration;
|
||||||
mod puzzle;
|
mod puzzle;
|
||||||
mod runtime;
|
mod runtime;
|
||||||
@@ -41,6 +42,7 @@ pub use auth::*;
|
|||||||
pub use big_fish::*;
|
pub use big_fish::*;
|
||||||
pub use domain_types::*;
|
pub use domain_types::*;
|
||||||
pub use entry::*;
|
pub use entry::*;
|
||||||
|
pub use match3d::*;
|
||||||
pub use migration::*;
|
pub use migration::*;
|
||||||
pub use runtime::*;
|
pub use runtime::*;
|
||||||
|
|
||||||
@@ -2856,7 +2858,9 @@ fn list_custom_world_profile_snapshots(
|
|||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_custom_world_profile_list_snapshot(row: &CustomWorldProfile) -> CustomWorldProfileSnapshot {
|
fn build_custom_world_profile_list_snapshot(
|
||||||
|
row: &CustomWorldProfile,
|
||||||
|
) -> CustomWorldProfileSnapshot {
|
||||||
let mut snapshot = build_custom_world_profile_snapshot(row);
|
let mut snapshot = build_custom_world_profile_snapshot(row);
|
||||||
snapshot.profile_payload_json = build_custom_world_profile_list_payload_json(row);
|
snapshot.profile_payload_json = build_custom_world_profile_list_payload_json(row);
|
||||||
snapshot
|
snapshot
|
||||||
|
|||||||
1642
server-rs/crates/spacetime-module/src/match3d/mod.rs
Normal file
1642
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 spacetimedb_lib::sats::ser::serde::SerializeWrapper;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use crate::match3d::tables::{
|
||||||
|
match3d_agent_message, match3d_agent_session, match3d_runtime_run, match3d_work_profile,
|
||||||
|
};
|
||||||
use crate::puzzle::{
|
use crate::puzzle::{
|
||||||
puzzle_agent_message, puzzle_agent_session, puzzle_runtime_run, puzzle_work_profile,
|
puzzle_agent_message, puzzle_agent_session, puzzle_runtime_run, puzzle_work_profile,
|
||||||
};
|
};
|
||||||
@@ -187,6 +190,10 @@ macro_rules! migration_tables {
|
|||||||
puzzle_agent_message,
|
puzzle_agent_message,
|
||||||
puzzle_work_profile,
|
puzzle_work_profile,
|
||||||
puzzle_runtime_run,
|
puzzle_runtime_run,
|
||||||
|
match3d_agent_session,
|
||||||
|
match3d_agent_message,
|
||||||
|
match3d_work_profile,
|
||||||
|
match3d_runtime_run,
|
||||||
big_fish_creation_session,
|
big_fish_creation_session,
|
||||||
big_fish_agent_message,
|
big_fish_agent_message,
|
||||||
big_fish_asset_slot
|
big_fish_asset_slot
|
||||||
|
|||||||
@@ -1261,8 +1261,9 @@ fn start_puzzle_run_tx(
|
|||||||
}
|
}
|
||||||
let entry_profile = build_puzzle_work_profile_from_row(&entry_profile_row)?;
|
let entry_profile = build_puzzle_work_profile_from_row(&entry_profile_row)?;
|
||||||
let started_at_ms = micros_to_millis(input.started_at_micros);
|
let started_at_ms = micros_to_millis(input.started_at_micros);
|
||||||
let mut run = module_puzzle::start_run_at(input.run_id.clone(), &entry_profile, 0, started_at_ms)
|
let mut run =
|
||||||
.map_err(|error| error.to_string())?;
|
module_puzzle::start_run_at(input.run_id.clone(), &entry_profile, 0, started_at_ms)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
let current_grid_size = run.current_grid_size;
|
let current_grid_size = run.current_grid_size;
|
||||||
let current_profile_id = entry_profile.profile_id.clone();
|
let current_profile_id = entry_profile.profile_id.clone();
|
||||||
hydrate_puzzle_leaderboard_entries(
|
hydrate_puzzle_leaderboard_entries(
|
||||||
@@ -1502,13 +1503,11 @@ fn use_puzzle_runtime_prop_tx(
|
|||||||
let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?;
|
let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?;
|
||||||
let current_run = deserialize_run(&row.snapshot_json)?;
|
let current_run = deserialize_run(&row.snapshot_json)?;
|
||||||
let next_run = match input.prop_kind.as_str() {
|
let next_run = match input.prop_kind.as_str() {
|
||||||
"freezeTime" | "freeze_time" => {
|
"freezeTime" | "freeze_time" => module_puzzle::apply_puzzle_freeze_time_at(
|
||||||
module_puzzle::apply_puzzle_freeze_time_at(
|
¤t_run,
|
||||||
¤t_run,
|
micros_to_millis(input.used_at_micros),
|
||||||
micros_to_millis(input.used_at_micros),
|
)
|
||||||
)
|
.map_err(|error| error.to_string())?,
|
||||||
.map_err(|error| error.to_string())?
|
|
||||||
}
|
|
||||||
"hint" => module_puzzle::set_puzzle_run_paused_at(
|
"hint" => module_puzzle::set_puzzle_run_paused_at(
|
||||||
¤t_run,
|
¤t_run,
|
||||||
false,
|
false,
|
||||||
|
|||||||
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 type { ChangeEvent } from 'react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
@@ -75,15 +75,21 @@ type CreationAgentWorkspaceProps = {
|
|||||||
isBusy?: boolean;
|
isBusy?: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
quickActions?: CreationAgentQuickAction[];
|
quickActions?: CreationAgentQuickAction[];
|
||||||
|
referenceImagePreviewSrc?: string | null;
|
||||||
|
referenceImageLabel?: string | null;
|
||||||
|
referenceImageError?: string | null;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onSubmitText: (text: string, quickActionKey?: string) => void;
|
onSubmitText: (text: string, quickActionKey?: string) => void;
|
||||||
onPrimaryAction: () => void;
|
onPrimaryAction: () => void;
|
||||||
onQuickAction?: (action: CreationAgentQuickAction) => void;
|
onQuickAction?: (action: CreationAgentQuickAction) => void;
|
||||||
|
onReferenceImageChange?: (file: File) => Promise<void> | void;
|
||||||
|
onClearReferenceImage?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AUTO_SCROLL_FOLLOW_THRESHOLD_PX = 96;
|
const AUTO_SCROLL_FOLLOW_THRESHOLD_PX = 96;
|
||||||
const DOCUMENT_INPUT_ACCEPT =
|
const DOCUMENT_INPUT_ACCEPT =
|
||||||
'.txt,.md,.markdown,.csv,.json,text/plain,text/markdown,text/csv,application/json';
|
'.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[] = []) {
|
function uniqueRecommendedReplies(recommendedReplies: string[] = []) {
|
||||||
return [
|
return [
|
||||||
@@ -290,19 +296,26 @@ export function CreationAgentWorkspace({
|
|||||||
isBusy = false,
|
isBusy = false,
|
||||||
error = null,
|
error = null,
|
||||||
quickActions = [],
|
quickActions = [],
|
||||||
|
referenceImagePreviewSrc = null,
|
||||||
|
referenceImageLabel = null,
|
||||||
|
referenceImageError = null,
|
||||||
onBack,
|
onBack,
|
||||||
onSubmitText,
|
onSubmitText,
|
||||||
onPrimaryAction,
|
onPrimaryAction,
|
||||||
onQuickAction,
|
onQuickAction,
|
||||||
|
onReferenceImageChange,
|
||||||
|
onClearReferenceImage,
|
||||||
}: CreationAgentWorkspaceProps) {
|
}: CreationAgentWorkspaceProps) {
|
||||||
const [draftText, setDraftText] = useState('');
|
const [draftText, setDraftText] = useState('');
|
||||||
const [documentInputError, setDocumentInputError] = useState<string | null>(
|
const [documentInputError, setDocumentInputError] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [isParsingDocumentInput, setIsParsingDocumentInput] = useState(false);
|
const [isParsingDocumentInput, setIsParsingDocumentInput] = useState(false);
|
||||||
|
const [isReadingReferenceImage, setIsReadingReferenceImage] = useState(false);
|
||||||
// 统一聊天区只在用户仍停留在底部附近时跟随新内容,避免流式回复持续抢走阅读位置。
|
// 统一聊天区只在用户仍停留在底部附近时跟随新内容,避免流式回复持续抢走阅读位置。
|
||||||
const messageListRef = useRef<HTMLDivElement | null>(null);
|
const messageListRef = useRef<HTMLDivElement | null>(null);
|
||||||
const documentInputRef = useRef<HTMLInputElement | null>(null);
|
const documentInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const referenceImageInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const shouldAutoScrollRef = useRef(true);
|
const shouldAutoScrollRef = useRef(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -376,7 +389,7 @@ export function CreationAgentWorkspace({
|
|||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
const text = draftText.trim();
|
const text = draftText.trim();
|
||||||
if (!text || isBusy || isParsingDocumentInput) {
|
if (!text || isBusy || isParsingDocumentInput || isReadingReferenceImage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,6 +412,10 @@ export function CreationAgentWorkspace({
|
|||||||
documentInputRef.current?.click();
|
documentInputRef.current?.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openReferenceImagePicker = () => {
|
||||||
|
referenceImageInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
const handleDocumentInputChange = async (
|
const handleDocumentInputChange = async (
|
||||||
event: ChangeEvent<HTMLInputElement>,
|
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 (
|
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 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
|
<div
|
||||||
@@ -545,9 +581,36 @@ export function CreationAgentWorkspace({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -560,6 +623,15 @@ export function CreationAgentWorkspace({
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleDocumentInputChange}
|
onChange={handleDocumentInputChange}
|
||||||
/>
|
/>
|
||||||
|
{onReferenceImageChange ? (
|
||||||
|
<input
|
||||||
|
ref={referenceImageInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={REFERENCE_IMAGE_INPUT_ACCEPT}
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleReferenceImageInputChange}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={
|
aria-label={
|
||||||
@@ -575,9 +647,30 @@ export function CreationAgentWorkspace({
|
|||||||
className={`h-4 w-4 ${isParsingDocumentInput ? 'animate-pulse' : ''}`}
|
className={`h-4 w-4 ${isParsingDocumentInput ? 'animate-pulse' : ''}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</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
|
<textarea
|
||||||
value={draftText}
|
value={draftText}
|
||||||
disabled={isBusy || isParsingDocumentInput}
|
disabled={
|
||||||
|
isBusy || isParsingDocumentInput || isReadingReferenceImage
|
||||||
|
}
|
||||||
rows={2}
|
rows={2}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setDraftText(event.target.value);
|
setDraftText(event.target.value);
|
||||||
@@ -595,7 +688,12 @@ export function CreationAgentWorkspace({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="发送"
|
aria-label="发送"
|
||||||
disabled={isBusy || isParsingDocumentInput || !draftText.trim()}
|
disabled={
|
||||||
|
isBusy ||
|
||||||
|
isParsingDocumentInput ||
|
||||||
|
isReadingReferenceImage ||
|
||||||
|
!draftText.trim()
|
||||||
|
}
|
||||||
onClick={submit}
|
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}`}
|
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,
|
||||||
|
Match3DAnchorItemResponse,
|
||||||
|
Match3DAgentSessionSnapshot,
|
||||||
|
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.clearCount} 次
|
||||||
|
</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;
|
onClose: () => void;
|
||||||
onSelectRpg: () => void;
|
onSelectRpg: () => void;
|
||||||
onSelectBigFish: () => void;
|
onSelectBigFish: () => void;
|
||||||
|
onSelectMatch3D: () => void;
|
||||||
onSelectPuzzle: () => void;
|
onSelectPuzzle: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +72,7 @@ export function PlatformEntryCreationTypeModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onSelectRpg,
|
onSelectRpg,
|
||||||
onSelectBigFish,
|
onSelectBigFish,
|
||||||
|
onSelectMatch3D,
|
||||||
onSelectPuzzle,
|
onSelectPuzzle,
|
||||||
}: PlatformEntryCreationTypeModalProps) {
|
}: PlatformEntryCreationTypeModalProps) {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
@@ -103,6 +105,9 @@ export function PlatformEntryCreationTypeModal({
|
|||||||
if (item.id === 'big-fish') {
|
if (item.id === 'big-fish') {
|
||||||
onSelectBigFish();
|
onSelectBigFish();
|
||||||
}
|
}
|
||||||
|
if (item.id === 'match3d') {
|
||||||
|
onSelectMatch3D();
|
||||||
|
}
|
||||||
if (item.id === 'puzzle') {
|
if (item.id === 'puzzle') {
|
||||||
onSelectPuzzle();
|
onSelectPuzzle();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ import type {
|
|||||||
SubmitBigFishInputRequest,
|
SubmitBigFishInputRequest,
|
||||||
} from '../../../packages/shared/src/contracts/bigFish';
|
} from '../../../packages/shared/src/contracts/bigFish';
|
||||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
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 {
|
import type {
|
||||||
PuzzleAgentActionRequest,
|
PuzzleAgentActionRequest,
|
||||||
PuzzleAgentOperationRecord,
|
PuzzleAgentOperationRecord,
|
||||||
@@ -72,6 +80,7 @@ import {
|
|||||||
readCustomWorldAgentUiState,
|
readCustomWorldAgentUiState,
|
||||||
shouldRestoreCustomWorldAgentUiState,
|
shouldRestoreCustomWorldAgentUiState,
|
||||||
} from '../../services/customWorldAgentUiState';
|
} from '../../services/customWorldAgentUiState';
|
||||||
|
import { match3dCreationClient } from '../../services/match3d-creation';
|
||||||
import {
|
import {
|
||||||
buildBigFishGenerationAnchorEntries,
|
buildBigFishGenerationAnchorEntries,
|
||||||
buildMiniGameDraftGenerationProgress,
|
buildMiniGameDraftGenerationProgress,
|
||||||
@@ -510,6 +519,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 CustomWorldCreationHub = lazy(async () => {
|
||||||
const module = await import('../custom-world-home/CustomWorldCreationHub');
|
const module = await import('../custom-world-home/CustomWorldCreationHub');
|
||||||
return {
|
return {
|
||||||
@@ -707,6 +730,11 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
resolveRpgCreationErrorMessage(error, fallback),
|
resolveRpgCreationErrorMessage(error, fallback),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
const resolveMatch3DErrorMessage = useCallback(
|
||||||
|
(error: unknown, fallback: string) =>
|
||||||
|
resolveRpgCreationErrorMessage(error, fallback),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const refreshBigFishShelf = useCallback(async () => {
|
const refreshBigFishShelf = useCallback(async () => {
|
||||||
setIsBigFishLoadingLibrary(true);
|
setIsBigFishLoadingLibrary(true);
|
||||||
@@ -1086,6 +1114,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<
|
const puzzleFlow = usePlatformCreationAgentFlowController<
|
||||||
PuzzleAgentSessionSnapshot,
|
PuzzleAgentSessionSnapshot,
|
||||||
CreatePuzzleAgentSessionRequest,
|
CreatePuzzleAgentSessionRequest,
|
||||||
@@ -1196,6 +1262,12 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
const streamingBigFishReplyText = bigFishFlow.streamingReplyText;
|
const streamingBigFishReplyText = bigFishFlow.streamingReplyText;
|
||||||
const isStreamingBigFishReply = bigFishFlow.isStreamingReply;
|
const isStreamingBigFishReply = bigFishFlow.isStreamingReply;
|
||||||
|
|
||||||
|
const match3dSession = match3dFlow.session;
|
||||||
|
const match3dError = match3dFlow.error;
|
||||||
|
const isMatch3DBusy = match3dFlow.isBusy;
|
||||||
|
const streamingMatch3DReplyText = match3dFlow.streamingReplyText;
|
||||||
|
const isStreamingMatch3DReply = match3dFlow.isStreamingReply;
|
||||||
|
|
||||||
const puzzleSession = puzzleFlow.session;
|
const puzzleSession = puzzleFlow.session;
|
||||||
const puzzleError = puzzleFlow.error;
|
const puzzleError = puzzleFlow.error;
|
||||||
const setPuzzleError = puzzleFlow.setError;
|
const setPuzzleError = puzzleFlow.setError;
|
||||||
@@ -1219,6 +1291,14 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
await bigFishFlow.openWorkspace();
|
await bigFishFlow.openWorkspace();
|
||||||
}, [bigFishFlow]);
|
}, [bigFishFlow]);
|
||||||
|
|
||||||
|
const openMatch3DAgentWorkspace = useCallback(async () => {
|
||||||
|
match3dFlow.setSession(null);
|
||||||
|
match3dFlow.setError(null);
|
||||||
|
match3dFlow.setStreamingReplyText('');
|
||||||
|
match3dFlow.setIsStreamingReply(false);
|
||||||
|
await match3dFlow.openWorkspace();
|
||||||
|
}, [match3dFlow]);
|
||||||
|
|
||||||
const openPuzzleAgentWorkspace = useCallback(async () => {
|
const openPuzzleAgentWorkspace = useCallback(async () => {
|
||||||
setPuzzleRun(null);
|
setPuzzleRun(null);
|
||||||
setPuzzleOperation(null);
|
setPuzzleOperation(null);
|
||||||
@@ -1276,6 +1356,10 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setBigFishRuntimeReturnStage('platform');
|
setBigFishRuntimeReturnStage('platform');
|
||||||
setBigFishGenerationState(null);
|
setBigFishGenerationState(null);
|
||||||
setBigFishError(null);
|
setBigFishError(null);
|
||||||
|
match3dFlow.setSession(null);
|
||||||
|
match3dFlow.setError(null);
|
||||||
|
match3dFlow.setStreamingReplyText('');
|
||||||
|
match3dFlow.setIsStreamingReply(false);
|
||||||
setPuzzleOperation(null);
|
setPuzzleOperation(null);
|
||||||
setPuzzleWorks([]);
|
setPuzzleWorks([]);
|
||||||
setSelectedPuzzleDetail(null);
|
setSelectedPuzzleDetail(null);
|
||||||
@@ -1304,6 +1388,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
authUi?.user,
|
authUi?.user,
|
||||||
|
match3dFlow,
|
||||||
platformBootstrap.canReadProtectedData,
|
platformBootstrap.canReadProtectedData,
|
||||||
persistRpgAgentUiState,
|
persistRpgAgentUiState,
|
||||||
resetAutoSaveTrackingToIdle,
|
resetAutoSaveTrackingToIdle,
|
||||||
@@ -1340,6 +1425,13 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === 'match3d') {
|
||||||
|
runProtectedAction(() => {
|
||||||
|
void openMatch3DAgentWorkspace();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (type === 'puzzle') {
|
if (type === 'puzzle') {
|
||||||
runProtectedAction(() => {
|
runProtectedAction(() => {
|
||||||
void openPuzzleAgentWorkspace();
|
void openPuzzleAgentWorkspace();
|
||||||
@@ -1348,6 +1440,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
openBigFishAgentWorkspace,
|
openBigFishAgentWorkspace,
|
||||||
|
openMatch3DAgentWorkspace,
|
||||||
openPuzzleAgentWorkspace,
|
openPuzzleAgentWorkspace,
|
||||||
prepareCreationLaunch,
|
prepareCreationLaunch,
|
||||||
runProtectedAction,
|
runProtectedAction,
|
||||||
@@ -1364,6 +1457,10 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
bigFishFlow.leaveFlow();
|
bigFishFlow.leaveFlow();
|
||||||
}, [bigFishFlow]);
|
}, [bigFishFlow]);
|
||||||
|
|
||||||
|
const leaveMatch3DFlow = useCallback(() => {
|
||||||
|
match3dFlow.leaveFlow();
|
||||||
|
}, [match3dFlow]);
|
||||||
|
|
||||||
const leavePuzzleFlow = useCallback(() => {
|
const leavePuzzleFlow = useCallback(() => {
|
||||||
setPuzzleOperation(null);
|
setPuzzleOperation(null);
|
||||||
setPuzzleRun(null);
|
setPuzzleRun(null);
|
||||||
@@ -1374,10 +1471,14 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
|
|
||||||
const submitBigFishMessage = bigFishFlow.submitMessage;
|
const submitBigFishMessage = bigFishFlow.submitMessage;
|
||||||
|
|
||||||
|
const submitMatch3DMessage = match3dFlow.submitMessage;
|
||||||
|
|
||||||
const submitPuzzleMessage = puzzleFlow.submitMessage;
|
const submitPuzzleMessage = puzzleFlow.submitMessage;
|
||||||
|
|
||||||
const executeBigFishAction = bigFishFlow.executeAction;
|
const executeBigFishAction = bigFishFlow.executeAction;
|
||||||
|
|
||||||
|
const executeMatch3DAction = match3dFlow.executeAction;
|
||||||
|
|
||||||
const executePuzzleAction = puzzleFlow.executeAction;
|
const executePuzzleAction = puzzleFlow.executeAction;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -3264,6 +3365,58 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
</motion.div>
|
</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' && (
|
{selectionStage === 'puzzle-agent-workspace' && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="puzzle-agent-workspace"
|
key="puzzle-agent-workspace"
|
||||||
@@ -3701,15 +3854,20 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
isBusy={
|
isBusy={
|
||||||
sessionController.isCreatingAgentSession ||
|
sessionController.isCreatingAgentSession ||
|
||||||
isBigFishBusy ||
|
isBigFishBusy ||
|
||||||
|
isMatch3DBusy ||
|
||||||
isPuzzleBusy
|
isPuzzleBusy
|
||||||
}
|
}
|
||||||
error={
|
error={
|
||||||
bigFishError ?? puzzleError ?? sessionController.creationTypeError
|
bigFishError ??
|
||||||
|
match3dError ??
|
||||||
|
puzzleError ??
|
||||||
|
sessionController.creationTypeError
|
||||||
}
|
}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
if (
|
if (
|
||||||
sessionController.isCreatingAgentSession ||
|
sessionController.isCreatingAgentSession ||
|
||||||
isBigFishBusy ||
|
isBigFishBusy ||
|
||||||
|
isMatch3DBusy ||
|
||||||
isPuzzleBusy
|
isPuzzleBusy
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -3726,6 +3884,11 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
void openBigFishAgentWorkspace();
|
void openBigFishAgentWorkspace();
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
onSelectMatch3D={() => {
|
||||||
|
runProtectedAction(() => {
|
||||||
|
void openMatch3DAgentWorkspace();
|
||||||
|
});
|
||||||
|
}}
|
||||||
onSelectPuzzle={() => {
|
onSelectPuzzle={() => {
|
||||||
runProtectedAction(() => {
|
runProtectedAction(() => {
|
||||||
void openPuzzleAgentWorkspace();
|
void openPuzzleAgentWorkspace();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export type PlatformCreationTypeId =
|
export type PlatformCreationTypeId =
|
||||||
| 'rpg'
|
| 'rpg'
|
||||||
| 'big-fish'
|
| 'big-fish'
|
||||||
|
| 'match3d'
|
||||||
| 'puzzle'
|
| 'puzzle'
|
||||||
| 'airp'
|
| 'airp'
|
||||||
| 'visual-novel';
|
| 'visual-novel';
|
||||||
@@ -56,6 +57,13 @@ export const PLATFORM_CREATION_TYPES: PlatformCreationTypeCard[] = [
|
|||||||
badge: '可创建',
|
badge: '可创建',
|
||||||
locked: false,
|
locked: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'match3d',
|
||||||
|
title: '抓大鹅',
|
||||||
|
subtitle: '经典消除玩法',
|
||||||
|
badge: '可创建',
|
||||||
|
locked: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'airp',
|
id: 'airp',
|
||||||
title: 'AIRP',
|
title: 'AIRP',
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export type SelectionStage =
|
|||||||
| 'big-fish-generating'
|
| 'big-fish-generating'
|
||||||
| 'big-fish-result'
|
| 'big-fish-result'
|
||||||
| 'big-fish-runtime'
|
| 'big-fish-runtime'
|
||||||
|
| 'match3d-agent-workspace'
|
||||||
|
| 'match3d-result'
|
||||||
| 'puzzle-agent-workspace'
|
| 'puzzle-agent-workspace'
|
||||||
| 'puzzle-generating'
|
| 'puzzle-generating'
|
||||||
| 'puzzle-result'
|
| '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', () => {
|
it('routes former standalone editor paths back to the main game', () => {
|
||||||
expect(matchAppRoute('/item-editor/tools')).toEqual({
|
expect(matchAppRoute('/item-editor/tools')).toEqual({
|
||||||
kind: 'game',
|
kind: 'game',
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ export type AppRouteMatch =
|
|||||||
| {
|
| {
|
||||||
kind: 'big-fish-playground';
|
kind: 'big-fish-playground';
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
kind: 'match3d-playground';
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
kind: 'game';
|
kind: 'game';
|
||||||
};
|
};
|
||||||
@@ -29,6 +32,7 @@ export type ResolvedAppRoute = {
|
|||||||
|
|
||||||
const GameApp = lazy(() => import('../AuthenticatedApp')) as AppRouteComponent;
|
const GameApp = lazy(() => import('../AuthenticatedApp')) as AppRouteComponent;
|
||||||
const BigFishPlaygroundApp = lazy(() => import('../BigFishPlaygroundApp')) as AppRouteComponent;
|
const BigFishPlaygroundApp = lazy(() => import('../BigFishPlaygroundApp')) as AppRouteComponent;
|
||||||
|
const Match3DPlaygroundApp = lazy(() => import('../Match3DPlaygroundApp')) as AppRouteComponent;
|
||||||
const PuzzlePlaygroundApp = lazy(() => import('../PuzzlePlaygroundApp')) as AppRouteComponent;
|
const PuzzlePlaygroundApp = lazy(() => import('../PuzzlePlaygroundApp')) as AppRouteComponent;
|
||||||
|
|
||||||
function normalizeRoutePath(pathname: string) {
|
function normalizeRoutePath(pathname: string) {
|
||||||
@@ -50,6 +54,12 @@ export function matchAppRoute(pathname: string): AppRouteMatch {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalizedPath === '/match3d') {
|
||||||
|
return {
|
||||||
|
kind: 'match3d-playground',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kind: 'game',
|
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 {
|
return {
|
||||||
kind: 'game',
|
kind: 'game',
|
||||||
loadingEyebrow: '正在载入游戏',
|
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,
|
||||||
|
Match3DAnchorItemResponse,
|
||||||
|
Match3DAgentMessageResponse,
|
||||||
|
Match3DAgentSessionSnapshot,
|
||||||
|
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) => window.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: progressPercent >= 100 ? 'ready_to_compile' : 'collecting',
|
||||||
|
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',
|
||||||
|
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 = 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_compiled',
|
||||||
|
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