diff --git a/docs/technical/MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md b/docs/technical/MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md new file mode 100644 index 00000000..5ac406b4 --- /dev/null +++ b/docs/technical/MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md @@ -0,0 +1,107 @@ +# 抓大鹅 Match3D 领域规则与共享契约 Stage1 方案 + +日期:`2026-04-30` + +## 1. 文档目的 + +本文件承接 [MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md](./MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md),只冻结 B1 + B2 开发范围: + +1. 新增 `module-match3d` 纯领域 crate。 +2. 新增 Rust shared contracts。 +3. 新增 TypeScript shared contracts。 + +本阶段不实现 SpacetimeDB 表、procedure、`spacetime-client` 调用封装、`api-server` facade 和前端页面。 + +## 2. Stage1 边界 + +## 2.1 本阶段做 + +1. 领域层定义创作配置、作品草稿、作品 profile、运行态快照、物品、托盘、点击确认结果。 +2. 领域层提供纯函数: + - 校验创作配置 + - 编译默认草稿 + - 校验发布字段 + - 按确定性 seed 生成初始运行态 + - 刷新 2D 可点击快照 + - 确认点击、入槽、三消、胜利、托盘满失败 + - 确认倒计时失败 +3. Rust / TypeScript shared contracts 提供前后端对齐的请求与响应 DTO。 +4. 运行态采用“前端即时反馈 + 后端权威确认”契约: + - 前端可先播放点击、飞入、入槽、三消、腾格和胜负过渡。 + - 后端确认后返回权威快照。 + - 后端拒绝或快照版本不一致时,前端按权威快照回滚或校正。 + +## 2.2 本阶段不做 + +1. 不新增 SpacetimeDB 表。 +2. 不新增 SpacetimeDB procedure。 +3. 不生成新的 SpacetimeDB bindings。 +4. 不新增 `api-server` 路由。 +5. 不接入平台入口、结果页或运行态 UI。 +6. 不接入真实图片生成。 +7. 不做排行榜与后续关卡推荐。 + +## 3. 领域 crate 设计 + +新增: + +```text +server-rs/crates/module-match3d +``` + +该 crate 是纯领域层,不读写数据库,不访问网络,不依赖浏览器或文件系统。 + +核心类型: + +1. `Match3DCreatorConfig` +2. `Match3DResultDraft` +3. `Match3DWorkProfile` +4. `Match3DRunSnapshot` +5. `Match3DItemSnapshot` +6. `Match3DTraySlot` +7. `Match3DClickConfirmation` + +核心函数: + +1. `build_creator_config` +2. `compile_result_draft` +3. `validate_publish_requirements` +4. `create_work_profile` +5. `publish_work_profile` +6. `start_run_with_seed_at` +7. `confirm_click_at` +8. `resolve_run_timer_at` + +## 4. 即时反馈与权威确认 + +本阶段将点击处理明确拆成两层: + +1. 前端即时反馈层 + - 读取后端快照中的 `boardVersion`、物品位置、层级、半径和 `clickable`。 + - 本地做命中检测和动画。 + - 立即表现飞入、入槽、三消和胜负过渡。 + +2. 后端权威确认层 + - 校验 `runId`、`itemInstanceId`、运行态状态和物品是否仍可点击。 + - 重新计算入槽、三消、托盘满失败和胜利。 + - 返回最新 `Match3DRunSnapshot`。 + - 用 `boardVersion` 帮前端识别是否需要校正。 + +`Flying` 只作为前端表现态,不要求后端逐帧落库。后端只确认物品是否已从 `InBoard` 进入 `InTray` 或 `Cleared`。 + +## 5. 生成规则 Stage1 口径 + +1. `clearCount` 必须是正整数。 +2. `totalItemCount = clearCount * 3`。 +3. 难度范围为 `1~10`。 +4. 首版内置 `10` 种 demo 视觉 key。 +5. 当 `clearCount > 10` 时,复用视觉 key,并保证每种物品数量仍为 `3` 的倍数。 +6. 初始布局使用确定性 seed 生成圆形空间内的 2D 坐标。 +7. 可点击判定只做 2D 近似:若物品被更高层物品完全覆盖,则不可点击;否则可点击。 + +## 6. 验收 + +1. `cargo test -p module-match3d` 通过。 +2. `cargo test -p shared-contracts match3d` 通过。 +3. `npm run check:encoding` 覆盖新增中文文档和新增源码。 +4. 本阶段不要求运行 `npm run api-server:maincloud`,因为未修改后端运行服务入口、SpacetimeDB 表或 `api-server` facade。 diff --git a/docs/technical/README.md b/docs/technical/README.md index 61bdb4d4..32109e70 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -6,6 +6,7 @@ - [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md):冻结 SpacetimeDB 表结构变更约束、自动迁移可接受范围、冲突后的系统行为,以及保留旧数据的增量迁移流程;凡涉及 `spacetime publish`、表字段调整或 `migration.rs` 对齐时优先参考。 - [MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md](./MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md):冻结抓大鹅 Match3D 首版 demo 的独立玩法域、表与 procedure、HTTP facade、前端即时反馈/后端权威确认协议,以及可并行开发包。 +- [MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md](./MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md):冻结抓大鹅 Match3D B1+B2 的纯领域规则 crate、Rust/TypeScript shared contracts,以及 Stage1 不触碰 SpacetimeDB 表和 api-server 的边界。 - [PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md](./PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md):记录平台首页底部 dock 在手机浏览器地址栏展开时脱离可见区域的根因,以及 `100dvh`、固定底部锚点和安全区占位的修复口径。 - [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md):记录 SpacetimeDB private 表迁移 JSON 导出/导入 procedure、迁移操作员授权、HTTP 413 分片导入、Jenkins 自动迁移回灌和导入脚本参数。 - [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md):记录 `Genarrative-Database-Export` / `Genarrative-Database-Import` 两条 SCM-backed 数据库迁移流水线参数、默认 dry-run、token 边界和 `CHUNK_SIZE` 413 规避参数。 diff --git a/docs/technical/SPACETIMEDB_TABLE_CATALOG.md b/docs/technical/SPACETIMEDB_TABLE_CATALOG.md index 344597fd..713f4101 100644 --- a/docs/technical/SPACETIMEDB_TABLE_CATALOG.md +++ b/docs/technical/SPACETIMEDB_TABLE_CATALOG.md @@ -27,6 +27,7 @@ spacetime sql "SELECT * FROM custom_world_gallery_entry" | RPG 运行时 | `story_session`, `story_event`, `npc_state`, `inventory_slot`, `battle_state`, `treasure_record`, `quest_record`, `quest_log`, `player_progression`, `chapter_progression` | | 世界创作 | `custom_world_profile`, `custom_world_session`, `custom_world_agent_session`, `custom_world_agent_message`, `custom_world_agent_operation`, `custom_world_draft_card`, `custom_world_gallery_entry` | | 拼图 | `puzzle_agent_session`, `puzzle_agent_message`, `puzzle_work_profile`, `puzzle_runtime_run` | +| 抓大鹅 Match3D | `match3d_agent_session`, `match3d_agent_message`, `match3d_work_profile`, `match3d_runtime_run` | | 大鱼吃小鱼 | `big_fish_creation_session`, `big_fish_agent_message`, `big_fish_asset_slot`, `big_fish_runtime_run` | | 资产 | `asset_object`, `asset_entity_binding` | | AI 任务 | `ai_task`, `ai_task_stage`, `ai_text_chunk`, `ai_result_reference` | @@ -446,6 +447,53 @@ SELECT * FROM puzzle_runtime_run WHERE run_id = ''; SELECT * FROM puzzle_runtime_run WHERE owner_user_id = '' ORDER BY updated_at DESC; ``` +## 抓大鹅 Match3D 表 + +### `match3d_agent_session` + +- 作用:抓大鹅 Match3D 创作 Agent 会话表,保存种子、配置 JSON、草稿 JSON 和发布 profile 指针。 +- 结构:`session_id PK: String`, `owner_user_id: String`, `seed_text: String`, `current_turn: u32`, `progress_percent: u32`, `stage: String`, `config_json: String`, `draft_json: String`, `last_assistant_reply: String`, `published_profile_id: String`, `created_at: Timestamp`, `updated_at: Timestamp`。 +- 索引:`owner_user_id`。 + +```sql +SELECT * FROM match3d_agent_session WHERE session_id = ''; +SELECT * FROM match3d_agent_session WHERE owner_user_id = '' ORDER BY updated_at DESC; +``` + +### `match3d_agent_message` + +- 作用:抓大鹅 Match3D 创作 Agent 消息流水。 +- 结构:`message_id PK: String`, `session_id: String`, `role: String`, `kind: String`, `text: String`, `created_at: Timestamp`。 +- 索引:`session_id`。 + +```sql +SELECT * FROM match3d_agent_message WHERE session_id = '' ORDER BY created_at ASC; +``` + +### `match3d_work_profile` + +- 作用:抓大鹅 Match3D 作品主表,保存作品基础信息、配置、发布状态和游玩次数。 +- 结构:`profile_id PK: String`, `owner_user_id: String`, `source_session_id: String`, `author_display_name: String`, `game_name: String`, `theme_text: String`, `summary_text: String`, `tags_json: String`, `cover_image_src: String`, `cover_asset_id: String`, `clear_count: u32`, `difficulty: u32`, `config_json: String`, `publication_status: String`, `play_count: u32`, `updated_at: Timestamp`, `published_at: Option`。 +- 索引:`owner_user_id`, `publication_status`。 + +```sql +SELECT * FROM match3d_work_profile WHERE profile_id = ''; +SELECT * FROM match3d_work_profile WHERE owner_user_id = '' ORDER BY updated_at DESC; +SELECT * FROM match3d_work_profile WHERE publication_status = 'Published'; +``` + +### `match3d_runtime_run` + +- 作用:抓大鹅 Match3D 单局运行态表,保存权威快照、快照版本、胜负状态和成绩基础字段。 +- 结构:`run_id PK: String`, `owner_user_id: String`, `profile_id: String`, `status: String`, `snapshot_version: u32`, `started_at_ms: i64`, `duration_limit_ms: i64`, `finished_at_ms: i64`, `elapsed_ms: i64`, `clear_count: u32`, `total_item_count: u32`, `cleared_item_count: u32`, `failure_reason: String`, `snapshot_json: String`, `created_at: Timestamp`, `updated_at: Timestamp`。 +- 索引:`owner_user_id`, `profile_id`。 + +```sql +SELECT * FROM match3d_runtime_run WHERE run_id = ''; +SELECT * FROM match3d_runtime_run WHERE owner_user_id = '' ORDER BY updated_at DESC; +SELECT * FROM match3d_runtime_run WHERE profile_id = ''; +``` + ## 大鱼吃小鱼表 ### `big_fish_creation_session` diff --git a/packages/shared/src/contracts/match3dAgent.ts b/packages/shared/src/contracts/match3dAgent.ts new file mode 100644 index 00000000..924127e4 --- /dev/null +++ b/packages/shared/src/contracts/match3dAgent.ts @@ -0,0 +1,119 @@ +export type Match3DCreationStage = + | 'collecting_config' + | 'draft_ready' + | 'ready_to_publish' + | 'published' + | string; + +export type Match3DAgentMessageRole = 'user' | 'assistant' | 'system' | string; + +export type Match3DAgentMessageKind = + | 'chat' + | 'summary' + | 'action_result' + | 'warning' + | string; + +export type Match3DAnchorStatus = 'confirmed' | 'missing' | 'inferred' | 'locked' | string; + +export interface CreateMatch3DAgentSessionRequest { + seedText?: string; + themeText?: string; + referenceImageSrc?: string | null; + clearCount?: number; + difficulty?: number; +} + +export type CreateMatch3DSessionRequest = CreateMatch3DAgentSessionRequest; + +export interface SendMatch3DAgentMessageRequest { + clientMessageId: string; + text: string; + quickFillRequested?: boolean; + referenceImageSrc?: string | null; +} + +export type SendMatch3DMessageRequest = SendMatch3DAgentMessageRequest; + +export interface ExecuteMatch3DAgentActionRequest { + action: string; + gameName?: string; + summary?: string; + tags?: string[]; + coverImageSrc?: string | null; + clearCount?: number; + difficulty?: number; +} + +export type ExecuteMatch3DActionRequest = ExecuteMatch3DAgentActionRequest; + +export interface Match3DAnchorItemResponse { + key: string; + label: string; + value: string; + status: Match3DAnchorStatus; +} + +export interface Match3DAnchorPackResponse { + theme: Match3DAnchorItemResponse; + clearCount: Match3DAnchorItemResponse; + difficulty: Match3DAnchorItemResponse; +} + +export interface Match3DCreatorConfig { + themeText: string; + referenceImageSrc?: string | null; + clearCount: number; + difficulty: number; +} + +export interface Match3DResultDraft { + gameName: string; + themeText: string; + summaryText?: string; + summary?: string; + tags: string[]; + coverImageSrc?: string | null; + referenceImageSrc?: string | null; + clearCount: number; + difficulty: number; + totalItemCount?: number; + publishReady?: boolean; + blockers?: string[]; +} + +export interface Match3DAgentMessage { + id: string; + role: Match3DAgentMessageRole; + kind: Match3DAgentMessageKind; + text: string; + createdAt: string; +} + +export type Match3DAgentMessageResponse = Match3DAgentMessage; + +export interface Match3DAgentSessionSnapshot { + sessionId: string; + currentTurn: number; + progressPercent: number; + stage: Match3DCreationStage; + anchorPack: Match3DAnchorPackResponse; + config?: Match3DCreatorConfig | null; + draft?: Match3DResultDraft | null; + messages: Match3DAgentMessage[]; + lastAssistantReply?: string | null; + publishedProfileId?: string | null; + updatedAt: string; +} + +export interface Match3DAgentSessionResponse { + session: Match3DAgentSessionSnapshot; +} + +export type Match3DSessionResponse = Match3DAgentSessionResponse; + +export interface Match3DAgentActionResponse { + session: Match3DAgentSessionSnapshot; +} + +export type Match3DActionResponse = Match3DAgentActionResponse; diff --git a/packages/shared/src/contracts/match3dRuntime.ts b/packages/shared/src/contracts/match3dRuntime.ts new file mode 100644 index 00000000..67f70ea6 --- /dev/null +++ b/packages/shared/src/contracts/match3dRuntime.ts @@ -0,0 +1,125 @@ +export type Match3DRunStatus = + | 'running' + | 'won' + | 'failed' + | 'stopped' + | 'Running' + | 'Won' + | 'Failed' + | 'Stopped' + | string; +export type Match3DItemState = + | 'in_board' + | 'in_tray' + | 'cleared' + | 'InBoard' + | 'Flying' + | 'InTray' + | 'Cleared' + | string; +export type Match3DFailureReason = + | 'time_up' + | 'tray_full' + | 'TimeUp' + | 'TrayFull' + | string; +export type Match3DClickRejectReason = + | 'run_not_active' + | 'snapshot_version_mismatch' + | 'item_not_found' + | 'item_not_in_board' + | 'item_not_clickable' + | 'tray_full' + | string; + +export type Match3DClickConfirmStatus = + | 'Accepted' + | 'RejectedNotClickable' + | 'RejectedAlreadyMoved' + | 'RejectedTrayFull' + | 'VersionConflict' + | 'RunFinished'; + +export interface StartMatch3DRunRequest { + profileId: string; +} + +export interface Match3DClickItemRequest { + runId?: string; + itemInstanceId: string; + clientActionId?: string; + snapshotVersion?: number; + clientSnapshotVersion: number; + clientEventId: string; + clickedAtMs: number; +} + +export type ClickMatch3DItemRequest = Match3DClickItemRequest; + +export interface StopMatch3DRunRequest { + clientActionId: string; +} + +export interface Match3DItemSnapshot { + itemInstanceId: string; + itemTypeId: string; + visualKey: string; + x: number; + y: number; + radius: number; + layer: number; + state: Match3DItemState; + clickable: boolean; + traySlotIndex?: number | null; +} + +export interface Match3DTraySlot { + slotIndex: number; + itemInstanceId?: string | null; + itemTypeId?: string | null; + visualKey?: string | null; +} + +export interface Match3DRunSnapshot { + runId: string; + profileId: string; + ownerUserId?: string; + status: Match3DRunStatus; + snapshotVersion: number; + startedAtMs: number; + durationLimitMs: number; + serverNowMs?: number; + remainingMs: number; + clearCount: number; + totalItemCount: number; + clearedItemCount: number; + boardVersion?: number; + items: Match3DItemSnapshot[]; + traySlots: Match3DTraySlot[]; + failureReason?: Match3DFailureReason | null; + lastConfirmedActionId?: string | null; +} + +export interface Match3DClickConfirmation { + accepted: boolean; + rejectReason?: Match3DClickRejectReason | null; + enteredSlotIndex?: number | null; + clearedItemInstanceIds: string[]; + run: Match3DRunSnapshot; +} + +export interface Match3DClickItemResult { + status: Match3DClickConfirmStatus; + run: Match3DRunSnapshot; + acceptedItemInstanceId?: string; + clearedItemInstanceIds: string[]; + failureReason?: Match3DFailureReason | null; +} + +export interface Match3DRunResponse { + run: Match3DRunSnapshot; +} + +export interface Match3DClickResponse { + confirmation: Match3DClickConfirmation; +} diff --git a/packages/shared/src/contracts/match3dWorks.ts b/packages/shared/src/contracts/match3dWorks.ts new file mode 100644 index 00000000..1d5fce5d --- /dev/null +++ b/packages/shared/src/contracts/match3dWorks.ts @@ -0,0 +1,45 @@ +export type Match3DWorkPublicationStatus = 'draft' | 'published' | string; + +export interface PutMatch3DWorkRequest { + gameName: string; + summary: string; + tags: string[]; + coverImageSrc?: string | null; + referenceImageSrc?: string | null; + clearCount: number; + difficulty: number; +} + +export interface Match3DWorkSummary { + workId: string; + profileId: string; + ownerUserId: string; + sourceSessionId?: string | null; + gameName: string; + themeText: string; + summary: string; + tags: string[]; + coverImageSrc?: string | null; + referenceImageSrc?: string | null; + clearCount: number; + difficulty: number; + publicationStatus: Match3DWorkPublicationStatus; + playCount: number; + updatedAt: string; + publishedAt?: string | null; + publishReady: boolean; +} + +export interface Match3DWorkProfile extends Match3DWorkSummary {} + +export interface Match3DWorksResponse { + items: Match3DWorkSummary[]; +} + +export interface Match3DWorkDetailResponse { + item: Match3DWorkProfile; +} + +export interface Match3DWorkMutationResponse { + item: Match3DWorkProfile; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0744e6f2..80b647fe 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -11,6 +11,9 @@ export * from './contracts/rpgCreationFixtures'; export * from './contracts/rpgCreationPreview'; export * from './contracts/rpgCreationResultView'; export * from './contracts/rpgCreationWorkSummary'; +export * from './contracts/match3dAgent'; +export * from './contracts/match3dRuntime'; +export * from './contracts/match3dWorks'; export * from './contracts/puzzleAgentActions'; export * from './contracts/puzzleAgentDraft'; export * from './contracts/puzzleAgentSession'; diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 65d11bce..7d1879b2 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -1562,6 +1562,15 @@ dependencies = [ "spacetimedb", ] +[[package]] +name = "module-match3d" +version = "0.1.0" +dependencies = [ + "serde", + "shared-kernel", + "spacetimedb", +] + [[package]] name = "module-npc" version = "0.1.0" diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 97d39672..239a2566 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -15,6 +15,7 @@ members = [ "crates/module-combat", "crates/module-inventory", "crates/module-custom-world", + "crates/module-match3d", "crates/module-npc", "crates/module-puzzle", "crates/module-progression", @@ -52,4 +53,4 @@ incremental = true [profile.release] opt-level = 3 # 最大优化等级 lto = "thin" # 启用 Thin LTO,平衡编译时间和性能 -codegen-units = 1 # 减少并行代码生成单元,提升优化但增加编译时间 \ No newline at end of file +codegen-units = 1 # 减少并行代码生成单元,提升优化但增加编译时间 diff --git a/server-rs/crates/module-match3d/Cargo.toml b/server-rs/crates/module-match3d/Cargo.toml new file mode 100644 index 00000000..5e5042f3 --- /dev/null +++ b/server-rs/crates/module-match3d/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "module-match3d" +edition.workspace = true +version.workspace = true +license.workspace = true + +[features] +default = [] +spacetime-types = ["dep:spacetimedb"] + +[dependencies] +serde = { version = "1", features = ["derive"] } +shared-kernel = { path = "../shared-kernel" } +spacetimedb = { workspace = true, optional = true } diff --git a/server-rs/crates/module-match3d/src/lib.rs b/server-rs/crates/module-match3d/src/lib.rs new file mode 100644 index 00000000..8ecb2aaf --- /dev/null +++ b/server-rs/crates/module-match3d/src/lib.rs @@ -0,0 +1,996 @@ +use std::{error::Error, fmt}; + +use serde::{Deserialize, Serialize}; +use shared_kernel::{normalize_optional_string, normalize_required_string, normalize_string_list}; +#[cfg(feature = "spacetime-types")] +use spacetimedb::SpacetimeType; + +pub const MATCH3D_SESSION_ID_PREFIX: &str = "match3d-session-"; +pub const MATCH3D_MESSAGE_ID_PREFIX: &str = "match3d-message-"; +pub const MATCH3D_PROFILE_ID_PREFIX: &str = "match3d-profile-"; +pub const MATCH3D_WORK_ID_PREFIX: &str = "match3d-work-"; +pub const MATCH3D_RUN_ID_PREFIX: &str = "match3d-run-"; +pub const MATCH3D_TRAY_SLOT_COUNT: u32 = 7; +pub const MATCH3D_ITEMS_PER_CLEAR: u32 = 3; +pub const MATCH3D_MIN_DIFFICULTY: u32 = 1; +pub const MATCH3D_MAX_DIFFICULTY: u32 = 10; +pub const MATCH3D_DEFAULT_DURATION_LIMIT_MS: u64 = 10 * 60 * 1000; +pub const MATCH3D_BOARD_RADIUS: f32 = 1.0; + +const MATCH3D_DEMO_VISUAL_KEYS: [&str; 10] = [ + "red_circle", + "yellow_triangle", + "purple_diamond", + "green_square", + "blue_star", + "orange_hexagon", + "cyan_capsule", + "pink_heart", + "lime_leaf", + "white_moon", +]; + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Match3DCreationStage { + CollectingConfig, + DraftReady, + ReadyToPublish, + Published, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Match3DPublicationStatus { + Draft, + Published, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Match3DRunStatus { + Running, + Won, + Failed, + Stopped, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Match3DFailureReason { + TimeUp, + TrayFull, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Match3DItemState { + InBoard, + InTray, + Cleared, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Match3DClickRejectReason { + RunNotActive, + SnapshotVersionMismatch, + ItemNotFound, + ItemNotInBoard, + ItemNotClickable, + TrayFull, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Match3DCreatorConfig { + pub theme_text: String, + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Match3DResultDraft { + pub game_name: String, + pub theme_text: String, + pub summary: String, + pub tags: Vec, + pub cover_image_src: Option, + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, + pub publish_ready: bool, + pub blockers: Vec, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Match3DWorkProfile { + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: Option, + pub game_name: String, + pub theme_text: String, + pub summary: String, + pub tags: Vec, + pub cover_image_src: Option, + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, + pub publication_status: Match3DPublicationStatus, + pub play_count: u32, + pub updated_at_micros: i64, + pub published_at_micros: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Match3DItemSnapshot { + pub item_instance_id: String, + pub item_type_id: String, + pub visual_key: String, + pub x: f32, + pub y: f32, + pub radius: f32, + pub layer: u32, + pub state: Match3DItemState, + pub clickable: bool, + pub tray_slot_index: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Match3DTraySlot { + pub slot_index: u32, + pub item_instance_id: Option, + pub item_type_id: Option, + pub visual_key: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Match3DRunSnapshot { + pub run_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub status: Match3DRunStatus, + pub started_at_ms: u64, + pub duration_limit_ms: u64, + pub remaining_ms: u64, + pub clear_count: u32, + pub total_item_count: u32, + pub cleared_item_count: u32, + pub board_version: u64, + pub items: Vec, + pub tray_slots: Vec, + pub failure_reason: Option, + pub last_confirmed_action_id: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Match3DClickInput { + pub run_id: String, + pub owner_user_id: String, + pub item_instance_id: String, + pub client_action_id: String, + pub snapshot_version: u64, + pub clicked_at_ms: u64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Match3DClickConfirmation { + pub accepted: bool, + pub reject_reason: Option, + pub entered_slot_index: Option, + pub cleared_item_instance_ids: Vec, + pub run: Match3DRunSnapshot, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Match3DFieldError { + MissingText, + MissingOwnerUserId, + MissingProfileId, + MissingRunId, + MissingItemId, + InvalidClearCount, + InvalidDifficulty, +} + +impl fmt::Display for Match3DFieldError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingText => write!(f, "必填文本缺失"), + Self::MissingOwnerUserId => write!(f, "owner_user_id 缺失"), + Self::MissingProfileId => write!(f, "profile_id 缺失"), + Self::MissingRunId => write!(f, "run_id 缺失"), + Self::MissingItemId => write!(f, "item_instance_id 缺失"), + Self::InvalidClearCount => write!(f, "需要消除次数必须为正整数"), + Self::InvalidDifficulty => write!(f, "难度必须在 1 到 10 之间"), + } + } +} + +impl Error for Match3DFieldError {} + +impl Match3DCreationStage { + pub fn as_str(self) -> &'static str { + match self { + Self::CollectingConfig => "collecting_config", + Self::DraftReady => "draft_ready", + Self::ReadyToPublish => "ready_to_publish", + Self::Published => "published", + } + } +} + +impl Match3DPublicationStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Draft => "draft", + Self::Published => "published", + } + } +} + +impl Match3DRunStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Running => "running", + Self::Won => "won", + Self::Failed => "failed", + Self::Stopped => "stopped", + } + } +} + +impl Match3DFailureReason { + pub fn as_str(self) -> &'static str { + match self { + Self::TimeUp => "time_up", + Self::TrayFull => "tray_full", + } + } +} + +impl Match3DItemState { + pub fn as_str(self) -> &'static str { + match self { + Self::InBoard => "in_board", + Self::InTray => "in_tray", + Self::Cleared => "cleared", + } + } +} + +impl Match3DClickRejectReason { + pub fn as_str(self) -> &'static str { + match self { + Self::RunNotActive => "run_not_active", + Self::SnapshotVersionMismatch => "snapshot_version_mismatch", + Self::ItemNotFound => "item_not_found", + Self::ItemNotInBoard => "item_not_in_board", + Self::ItemNotClickable => "item_not_clickable", + Self::TrayFull => "tray_full", + } + } +} + +pub fn build_creator_config( + theme_text: &str, + reference_image_src: Option, + clear_count: u32, + difficulty: u32, +) -> Result { + let theme_text = normalize_required_string(theme_text).ok_or(Match3DFieldError::MissingText)?; + if clear_count == 0 { + return Err(Match3DFieldError::InvalidClearCount); + } + if !(MATCH3D_MIN_DIFFICULTY..=MATCH3D_MAX_DIFFICULTY).contains(&difficulty) { + return Err(Match3DFieldError::InvalidDifficulty); + } + + Ok(Match3DCreatorConfig { + theme_text, + reference_image_src: normalize_optional_string(reference_image_src), + clear_count, + difficulty, + }) +} + +pub fn compile_result_draft(config: &Match3DCreatorConfig) -> Match3DResultDraft { + let game_name = format!("{}抓大鹅", config.theme_text); + let summary = format!( + "{}主题,{} 次消除目标,难度 {}。", + config.theme_text, config.clear_count, config.difficulty + ); + let tags = default_tags_for_theme(&config.theme_text); + let blockers = validate_basic_publish_fields(&game_name, &summary, &tags); + + Match3DResultDraft { + game_name, + theme_text: config.theme_text.clone(), + summary, + tags, + cover_image_src: None, + reference_image_src: config.reference_image_src.clone(), + clear_count: config.clear_count, + difficulty: config.difficulty, + publish_ready: blockers.is_empty(), + blockers, + } +} + +pub fn validate_publish_requirements(draft: &Match3DResultDraft) -> Vec { + let mut blockers = validate_basic_publish_fields(&draft.game_name, &draft.summary, &draft.tags); + if draft.clear_count == 0 { + blockers.push("需要消除次数必须为正整数".to_string()); + } + if !(MATCH3D_MIN_DIFFICULTY..=MATCH3D_MAX_DIFFICULTY).contains(&draft.difficulty) { + blockers.push("难度必须在 1 到 10 之间".to_string()); + } + blockers +} + +pub fn create_work_profile( + work_id: String, + profile_id: String, + owner_user_id: String, + source_session_id: Option, + draft: &Match3DResultDraft, + updated_at_micros: i64, +) -> Result { + let work_id = normalize_required_string(work_id).ok_or(Match3DFieldError::MissingText)?; + let profile_id = + normalize_required_string(profile_id).ok_or(Match3DFieldError::MissingProfileId)?; + let owner_user_id = + normalize_required_string(owner_user_id).ok_or(Match3DFieldError::MissingOwnerUserId)?; + + Ok(Match3DWorkProfile { + work_id, + profile_id, + owner_user_id, + source_session_id: normalize_optional_string(source_session_id), + game_name: draft.game_name.clone(), + theme_text: draft.theme_text.clone(), + summary: draft.summary.clone(), + tags: normalize_string_list(draft.tags.clone()), + cover_image_src: draft.cover_image_src.clone(), + reference_image_src: draft.reference_image_src.clone(), + clear_count: draft.clear_count, + difficulty: draft.difficulty, + publication_status: Match3DPublicationStatus::Draft, + play_count: 0, + updated_at_micros, + published_at_micros: None, + }) +} + +pub fn publish_work_profile( + profile: &Match3DWorkProfile, + published_at_micros: i64, +) -> Result { + if profile.clear_count == 0 { + return Err(Match3DFieldError::InvalidClearCount); + } + if !(MATCH3D_MIN_DIFFICULTY..=MATCH3D_MAX_DIFFICULTY).contains(&profile.difficulty) { + return Err(Match3DFieldError::InvalidDifficulty); + } + + let mut next = profile.clone(); + next.publication_status = Match3DPublicationStatus::Published; + next.updated_at_micros = published_at_micros; + next.published_at_micros = Some(published_at_micros); + Ok(next) +} + +pub fn start_run_with_seed_at( + run_id: String, + owner_user_id: String, + profile_id: String, + config: &Match3DCreatorConfig, + seed: u64, + started_at_ms: u64, +) -> Result { + let run_id = normalize_required_string(run_id).ok_or(Match3DFieldError::MissingRunId)?; + let owner_user_id = + normalize_required_string(owner_user_id).ok_or(Match3DFieldError::MissingOwnerUserId)?; + let profile_id = + normalize_required_string(profile_id).ok_or(Match3DFieldError::MissingProfileId)?; + + let total_item_count = config + .clear_count + .checked_mul(MATCH3D_ITEMS_PER_CLEAR) + .ok_or(Match3DFieldError::InvalidClearCount)?; + let mut run = Match3DRunSnapshot { + run_id, + profile_id, + owner_user_id, + status: Match3DRunStatus::Running, + started_at_ms, + duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS, + remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS, + clear_count: config.clear_count, + total_item_count, + cleared_item_count: 0, + board_version: 1, + items: build_initial_items(config.clear_count, config.difficulty, seed), + tray_slots: empty_tray_slots(), + failure_reason: None, + last_confirmed_action_id: None, + }; + refresh_clickable_flags(&mut run); + Ok(run) +} + +pub fn confirm_click_at( + run: &Match3DRunSnapshot, + input: &Match3DClickInput, +) -> Result { + let item_instance_id = normalize_required_string(&input.item_instance_id) + .ok_or(Match3DFieldError::MissingItemId)?; + let client_action_id = normalize_required_string(&input.client_action_id) + .unwrap_or_else(|| "match3d-action-unknown".to_string()); + + let mut next = resolve_run_timer_at(run, input.clicked_at_ms); + if next.status != Match3DRunStatus::Running { + return Ok(rejected(next, Match3DClickRejectReason::RunNotActive)); + } + if input.snapshot_version != next.board_version { + return Ok(rejected( + next, + Match3DClickRejectReason::SnapshotVersionMismatch, + )); + } + + let Some(item_index) = next + .items + .iter() + .position(|item| item.item_instance_id == item_instance_id) + else { + return Ok(rejected(next, Match3DClickRejectReason::ItemNotFound)); + }; + + if next.items[item_index].state != Match3DItemState::InBoard { + return Ok(rejected(next, Match3DClickRejectReason::ItemNotInBoard)); + } + if !next.items[item_index].clickable { + return Ok(rejected(next, Match3DClickRejectReason::ItemNotClickable)); + } + + let Some(slot_index) = first_empty_slot_index(&next.tray_slots) else { + next = fail_run(next, Match3DFailureReason::TrayFull, client_action_id); + return Ok(rejected(next, Match3DClickRejectReason::TrayFull)); + }; + + let item_type_id = next.items[item_index].item_type_id.clone(); + next.items[item_index].state = Match3DItemState::InTray; + next.items[item_index].clickable = false; + next.items[item_index].tray_slot_index = Some(slot_index); + fill_tray_slot(&mut next.tray_slots, slot_index, &next.items[item_index]); + + let cleared_item_instance_ids = clear_first_triple(&mut next, &item_type_id); + compact_tray(&mut next); + next.cleared_item_count = next + .items + .iter() + .filter(|item| item.state == Match3DItemState::Cleared) + .count() as u32; + + if next.cleared_item_count >= next.total_item_count { + next.status = Match3DRunStatus::Won; + } else if first_empty_slot_index(&next.tray_slots).is_none() { + next.status = Match3DRunStatus::Failed; + next.failure_reason = Some(Match3DFailureReason::TrayFull); + } + + refresh_clickable_flags(&mut next); + next.board_version += 1; + next.last_confirmed_action_id = Some(client_action_id); + + Ok(Match3DClickConfirmation { + accepted: true, + reject_reason: None, + entered_slot_index: Some(slot_index), + cleared_item_instance_ids, + run: next, + }) +} + +pub fn resolve_run_timer_at(run: &Match3DRunSnapshot, now_ms: u64) -> Match3DRunSnapshot { + let mut next = run.clone(); + if next.status != Match3DRunStatus::Running { + return next; + } + let elapsed_ms = now_ms.saturating_sub(next.started_at_ms); + next.remaining_ms = next.duration_limit_ms.saturating_sub(elapsed_ms); + if next.remaining_ms == 0 { + next.status = Match3DRunStatus::Failed; + next.failure_reason = Some(Match3DFailureReason::TimeUp); + next.board_version += 1; + } + next +} + +pub fn stop_run_at(run: &Match3DRunSnapshot, stopped_action_id: String) -> Match3DRunSnapshot { + let mut next = run.clone(); + if next.status == Match3DRunStatus::Running { + next.status = Match3DRunStatus::Stopped; + next.board_version += 1; + next.last_confirmed_action_id = normalize_required_string(stopped_action_id); + } + next +} + +pub fn refresh_clickable_flags(run: &mut Match3DRunSnapshot) { + let board_items = run + .items + .iter() + .filter(|item| item.state == Match3DItemState::InBoard) + .cloned() + .collect::>(); + + for item in &mut run.items { + if item.state != Match3DItemState::InBoard { + item.clickable = false; + continue; + } + + item.clickable = !board_items.iter().any(|cover| { + cover.layer > item.layer + && fully_covers(cover.x, cover.y, cover.radius, item.x, item.y, item.radius) + }); + } +} + +fn build_initial_items(clear_count: u32, difficulty: u32, seed: u64) -> Vec { + let mut rng = DeterministicRng::new(seed ^ ((clear_count as u64) << 32) ^ difficulty as u64); + let radius = resolve_item_radius(difficulty); + let mut items = Vec::with_capacity((clear_count * MATCH3D_ITEMS_PER_CLEAR) as usize); + + for clear_index in 0..clear_count { + let visual_index = (clear_index as usize) % MATCH3D_DEMO_VISUAL_KEYS.len(); + let item_type_id = format!("match3d-type-{:02}", visual_index + 1); + let visual_key = MATCH3D_DEMO_VISUAL_KEYS[visual_index].to_string(); + + for copy_index in 0..MATCH3D_ITEMS_PER_CLEAR { + let (x, y) = random_point_in_circle(&mut rng, MATCH3D_BOARD_RADIUS - radius); + let instance_index = clear_index * MATCH3D_ITEMS_PER_CLEAR + copy_index; + items.push(Match3DItemSnapshot { + item_instance_id: format!("match3d-item-{instance_index:04}"), + item_type_id: item_type_id.clone(), + visual_key: visual_key.clone(), + x, + y, + radius, + layer: instance_index, + state: Match3DItemState::InBoard, + clickable: true, + tray_slot_index: None, + }); + } + } + + // 洗牌只改变层级顺序,不改变每组三个的可通关性。 + for index in (1..items.len()).rev() { + let swap_index = (rng.next_u32() as usize) % (index + 1); + items.swap(index, swap_index); + } + for (layer, item) in items.iter_mut().enumerate() { + item.layer = layer as u32; + } + + items +} + +fn resolve_item_radius(difficulty: u32) -> f32 { + let clamped = difficulty.clamp(MATCH3D_MIN_DIFFICULTY, MATCH3D_MAX_DIFFICULTY); + let radius = 0.105 - (clamped as f32 - 1.0) * 0.0055; + radius.max(0.052) +} + +fn random_point_in_circle(rng: &mut DeterministicRng, max_radius: f32) -> (f32, f32) { + for _ in 0..24 { + let x = rng.next_unit_signed() * max_radius; + let y = rng.next_unit_signed() * max_radius; + if x * x + y * y <= max_radius * max_radius { + return (x, y); + } + } + (0.0, 0.0) +} + +fn fully_covers( + cover_x: f32, + cover_y: f32, + cover_radius: f32, + item_x: f32, + item_y: f32, + item_radius: f32, +) -> bool { + let dx = cover_x - item_x; + let dy = cover_y - item_y; + let distance = (dx * dx + dy * dy).sqrt(); + distance + item_radius <= cover_radius * 0.96 +} + +fn empty_tray_slots() -> Vec { + (0..MATCH3D_TRAY_SLOT_COUNT) + .map(|slot_index| Match3DTraySlot { + slot_index, + item_instance_id: None, + item_type_id: None, + visual_key: None, + }) + .collect() +} + +fn first_empty_slot_index(slots: &[Match3DTraySlot]) -> Option { + slots + .iter() + .find(|slot| slot.item_instance_id.is_none()) + .map(|slot| slot.slot_index) +} + +fn fill_tray_slot(slots: &mut [Match3DTraySlot], slot_index: u32, item: &Match3DItemSnapshot) { + if let Some(slot) = slots.iter_mut().find(|slot| slot.slot_index == slot_index) { + slot.item_instance_id = Some(item.item_instance_id.clone()); + slot.item_type_id = Some(item.item_type_id.clone()); + slot.visual_key = Some(item.visual_key.clone()); + } +} + +fn clear_first_triple(run: &mut Match3DRunSnapshot, item_type_id: &str) -> Vec { + let matched_slot_item_ids = run + .tray_slots + .iter() + .filter(|slot| slot.item_type_id.as_deref() == Some(item_type_id)) + .filter_map(|slot| slot.item_instance_id.clone()) + .take(MATCH3D_ITEMS_PER_CLEAR as usize) + .collect::>(); + + if matched_slot_item_ids.len() < MATCH3D_ITEMS_PER_CLEAR as usize { + return Vec::new(); + } + + for item in &mut run.items { + if matched_slot_item_ids.contains(&item.item_instance_id) { + item.state = Match3DItemState::Cleared; + item.clickable = false; + item.tray_slot_index = None; + } + } + for slot in &mut run.tray_slots { + if slot + .item_instance_id + .as_ref() + .is_some_and(|id| matched_slot_item_ids.contains(id)) + { + slot.item_instance_id = None; + slot.item_type_id = None; + slot.visual_key = None; + } + } + + matched_slot_item_ids +} + +fn compact_tray(run: &mut Match3DRunSnapshot) { + let mut occupied = run + .tray_slots + .iter() + .filter_map(|slot| { + Some(( + slot.item_instance_id.clone()?, + slot.item_type_id.clone()?, + slot.visual_key.clone()?, + )) + }) + .collect::>(); + + for slot in &mut run.tray_slots { + slot.item_instance_id = None; + slot.item_type_id = None; + slot.visual_key = None; + } + + for (slot_index, (item_instance_id, item_type_id, visual_key)) in occupied.drain(..).enumerate() + { + let slot_index = slot_index as u32; + if let Some(slot) = run + .tray_slots + .iter_mut() + .find(|slot| slot.slot_index == slot_index) + { + slot.item_instance_id = Some(item_instance_id.clone()); + slot.item_type_id = Some(item_type_id); + slot.visual_key = Some(visual_key); + } + if let Some(item) = run + .items + .iter_mut() + .find(|item| item.item_instance_id == item_instance_id) + { + item.tray_slot_index = Some(slot_index); + } + } +} + +fn fail_run( + mut run: Match3DRunSnapshot, + reason: Match3DFailureReason, + action_id: String, +) -> Match3DRunSnapshot { + run.status = Match3DRunStatus::Failed; + run.failure_reason = Some(reason); + run.board_version += 1; + run.last_confirmed_action_id = Some(action_id); + run +} + +fn rejected( + run: Match3DRunSnapshot, + reject_reason: Match3DClickRejectReason, +) -> Match3DClickConfirmation { + Match3DClickConfirmation { + accepted: false, + reject_reason: Some(reject_reason), + entered_slot_index: None, + cleared_item_instance_ids: Vec::new(), + run, + } +} + +fn validate_basic_publish_fields(game_name: &str, summary: &str, tags: &[String]) -> Vec { + let mut blockers = Vec::new(); + if normalize_required_string(game_name).is_none() { + blockers.push("游戏名称不能为空".to_string()); + } + if normalize_required_string(summary).is_none() { + blockers.push("简介不能为空".to_string()); + } + let normalized_tags = normalize_string_list(tags.to_vec()); + if normalized_tags.is_empty() { + blockers.push("至少需要 1 个标签".to_string()); + } + blockers +} + +fn default_tags_for_theme(theme_text: &str) -> Vec { + let mut tags = vec![ + "抓大鹅".to_string(), + "经典消除".to_string(), + theme_text.to_string(), + ]; + tags.sort(); + tags.dedup(); + tags +} + +struct DeterministicRng { + state: u64, +} + +impl DeterministicRng { + fn new(seed: u64) -> Self { + Self { state: seed.max(1) } + } + + fn next_u32(&mut self) -> u32 { + let mut value = self.state; + value ^= value << 13; + value ^= value >> 7; + value ^= value << 17; + self.state = value; + (value >> 32) as u32 + } + + fn next_unit_signed(&mut self) -> f32 { + let value = self.next_u32() as f32 / u32::MAX as f32; + value * 2.0 - 1.0 + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + + fn test_config(clear_count: u32) -> Match3DCreatorConfig { + build_creator_config("水果", None, clear_count, 4).expect("config should be valid") + } + + fn manual_item(id: &str, type_id: &str, slot: Option) -> Match3DItemSnapshot { + Match3DItemSnapshot { + item_instance_id: id.to_string(), + item_type_id: type_id.to_string(), + visual_key: type_id.to_string(), + x: 0.0, + y: 0.0, + radius: 0.08, + layer: 0, + state: if slot.is_some() { + Match3DItemState::InTray + } else { + Match3DItemState::InBoard + }, + clickable: slot.is_none(), + tray_slot_index: slot, + } + } + + #[test] + fn creator_config_requires_positive_clear_count() { + let error = build_creator_config("水果", None, 0, 3).expect_err("zero should fail"); + assert_eq!(error, Match3DFieldError::InvalidClearCount); + } + + #[test] + fn initial_run_generates_triples() { + let run = start_run_with_seed_at( + "run-1".to_string(), + "user-1".to_string(), + "profile-1".to_string(), + &test_config(12), + 42, + 1_000, + ) + .expect("run should start"); + + assert_eq!(run.total_item_count, 36); + let mut counts = BTreeMap::::new(); + for item in &run.items { + *counts.entry(item.item_type_id.clone()).or_default() += 1; + } + assert!(counts.values().all(|count| count % 3 == 0)); + } + + #[test] + fn clicking_three_same_items_clears_and_wins() { + let mut run = start_run_with_seed_at( + "run-1".to_string(), + "user-1".to_string(), + "profile-1".to_string(), + &test_config(1), + 7, + 10_000, + ) + .expect("run should start"); + for item in &mut run.items { + item.clickable = true; + } + + let ids = run + .items + .iter() + .map(|item| item.item_instance_id.clone()) + .collect::>(); + + for (index, item_id) in ids.iter().enumerate() { + let input = Match3DClickInput { + run_id: run.run_id.clone(), + owner_user_id: run.owner_user_id.clone(), + item_instance_id: item_id.clone(), + client_action_id: format!("action-{index}"), + snapshot_version: run.board_version, + clicked_at_ms: 11_000 + index as u64, + }; + run = confirm_click_at(&run, &input) + .expect("click should confirm") + .run; + } + + assert_eq!(run.status, Match3DRunStatus::Won); + assert_eq!(run.cleared_item_count, 3); + assert!( + run.tray_slots + .iter() + .all(|slot| slot.item_instance_id.is_none()) + ); + } + + #[test] + fn tray_full_fails_when_no_triple_can_clear() { + let mut run = Match3DRunSnapshot { + run_id: "run-full".to_string(), + profile_id: "profile-1".to_string(), + owner_user_id: "user-1".to_string(), + status: Match3DRunStatus::Running, + started_at_ms: 0, + duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS, + remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS, + clear_count: 3, + total_item_count: 9, + cleared_item_count: 0, + board_version: 1, + items: (0..8) + .map(|index| manual_item(&format!("item-{index}"), &format!("type-{index}"), None)) + .collect(), + tray_slots: empty_tray_slots(), + failure_reason: None, + last_confirmed_action_id: None, + }; + + for index in 0..7 { + let input = Match3DClickInput { + run_id: run.run_id.clone(), + owner_user_id: run.owner_user_id.clone(), + item_instance_id: format!("item-{index}"), + client_action_id: format!("action-{index}"), + snapshot_version: run.board_version, + clicked_at_ms: 1_000 + index, + }; + run = confirm_click_at(&run, &input) + .expect("click should confirm") + .run; + } + + assert_eq!(run.status, Match3DRunStatus::Failed); + assert_eq!(run.failure_reason, Some(Match3DFailureReason::TrayFull)); + } + + #[test] + fn timer_expiration_fails_running_run() { + let run = start_run_with_seed_at( + "run-1".to_string(), + "user-1".to_string(), + "profile-1".to_string(), + &test_config(2), + 9, + 1_000, + ) + .expect("run should start"); + + let expired = resolve_run_timer_at(&run, 1_000 + MATCH3D_DEFAULT_DURATION_LIMIT_MS); + + assert_eq!(expired.status, Match3DRunStatus::Failed); + assert_eq!(expired.failure_reason, Some(Match3DFailureReason::TimeUp)); + } + + #[test] + fn fully_covered_item_is_not_clickable() { + let mut run = Match3DRunSnapshot { + run_id: "run-cover".to_string(), + profile_id: "profile-1".to_string(), + owner_user_id: "user-1".to_string(), + status: Match3DRunStatus::Running, + started_at_ms: 0, + duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS, + remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS, + clear_count: 1, + total_item_count: 2, + cleared_item_count: 0, + board_version: 1, + items: vec![ + Match3DItemSnapshot { + layer: 0, + radius: 0.04, + ..manual_item("bottom", "type-a", None) + }, + Match3DItemSnapshot { + layer: 1, + radius: 0.08, + ..manual_item("top", "type-b", None) + }, + ], + tray_slots: empty_tray_slots(), + failure_reason: None, + last_confirmed_action_id: None, + }; + + refresh_clickable_flags(&mut run); + + let bottom = run + .items + .iter() + .find(|item| item.item_instance_id == "bottom") + .expect("bottom item should exist"); + assert!(!bottom.clickable); + } +} diff --git a/server-rs/crates/shared-contracts/src/lib.rs b/server-rs/crates/shared-contracts/src/lib.rs index c54e622c..e71ba254 100644 --- a/server-rs/crates/shared-contracts/src/lib.rs +++ b/server-rs/crates/shared-contracts/src/lib.rs @@ -7,6 +7,9 @@ pub mod big_fish; pub mod big_fish_works; pub mod creation_agent_document_input; pub mod llm; +pub mod match3d_agent; +pub mod match3d_runtime; +pub mod match3d_works; pub mod puzzle_agent; pub mod puzzle_gallery; pub mod puzzle_runtime; diff --git a/server-rs/crates/shared-contracts/src/match3d_agent.rs b/server-rs/crates/shared-contracts/src/match3d_agent.rs new file mode 100644 index 00000000..dd44c097 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/match3d_agent.rs @@ -0,0 +1,137 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreateMatch3DAgentSessionRequest { + #[serde(default)] + pub seed_text: Option, + #[serde(default)] + pub theme_text: Option, + #[serde(default)] + pub reference_image_src: Option, + #[serde(default)] + pub clear_count: Option, + #[serde(default)] + pub difficulty: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SendMatch3DAgentMessageRequest { + pub client_message_id: String, + pub text: String, + #[serde(default)] + pub quick_fill_requested: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ExecuteMatch3DAgentActionRequest { + pub action: String, + #[serde(default)] + pub game_name: Option, + #[serde(default)] + pub summary: Option, + #[serde(default)] + pub tags: Option>, + #[serde(default)] + pub cover_image_src: Option, + #[serde(default)] + pub clear_count: Option, + #[serde(default)] + pub difficulty: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DCreatorConfigResponse { + pub theme_text: String, + #[serde(default)] + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DResultDraftResponse { + pub game_name: String, + pub theme_text: String, + pub summary: String, + pub tags: Vec, + #[serde(default)] + pub cover_image_src: Option, + #[serde(default)] + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, + pub publish_ready: bool, + pub blockers: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DAgentMessageResponse { + pub id: String, + pub role: String, + pub kind: String, + pub text: String, + pub created_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DAgentSessionSnapshotResponse { + pub session_id: String, + pub current_turn: u32, + pub progress_percent: u32, + pub stage: String, + #[serde(default)] + pub config: Option, + #[serde(default)] + pub draft: Option, + pub messages: Vec, + #[serde(default)] + pub last_assistant_reply: Option, + #[serde(default)] + pub published_profile_id: Option, + pub updated_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DAgentSessionResponse { + pub session: Match3DAgentSessionSnapshotResponse, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DAgentActionResponse { + pub session: Match3DAgentSessionSnapshotResponse, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn create_match3d_session_request_uses_camel_case() { + let payload = serde_json::to_value(CreateMatch3DAgentSessionRequest { + seed_text: Some("水果消除".to_string()), + theme_text: Some("水果".to_string()), + reference_image_src: Some("data:image/png;base64,abc".to_string()), + clear_count: Some(4), + difficulty: Some(3), + }) + .expect("payload should serialize"); + + assert_eq!(payload["seedText"], json!("水果消除")); + assert_eq!(payload["themeText"], json!("水果")); + assert_eq!( + payload["referenceImageSrc"], + json!("data:image/png;base64,abc") + ); + assert_eq!(payload["clearCount"], json!(4)); + } +} diff --git a/server-rs/crates/shared-contracts/src/match3d_runtime.rs b/server-rs/crates/shared-contracts/src/match3d_runtime.rs new file mode 100644 index 00000000..fb088e95 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/match3d_runtime.rs @@ -0,0 +1,115 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct StartMatch3DRunRequest { + pub profile_id: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ClickMatch3DItemRequest { + pub item_instance_id: String, + pub client_action_id: String, + pub snapshot_version: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct StopMatch3DRunRequest { + pub client_action_id: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DItemSnapshotResponse { + pub item_instance_id: String, + pub item_type_id: String, + pub visual_key: String, + pub x: f32, + pub y: f32, + pub radius: f32, + pub layer: u32, + pub state: String, + pub clickable: bool, + #[serde(default)] + pub tray_slot_index: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DTraySlotResponse { + pub slot_index: u32, + #[serde(default)] + pub item_instance_id: Option, + #[serde(default)] + pub item_type_id: Option, + #[serde(default)] + pub visual_key: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DRunSnapshotResponse { + pub run_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub status: String, + pub started_at_ms: u64, + pub duration_limit_ms: u64, + pub remaining_ms: u64, + pub clear_count: u32, + pub total_item_count: u32, + pub cleared_item_count: u32, + pub board_version: u64, + pub items: Vec, + pub tray_slots: Vec, + #[serde(default)] + pub failure_reason: Option, + #[serde(default)] + pub last_confirmed_action_id: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DClickConfirmationResponse { + pub accepted: bool, + #[serde(default)] + pub reject_reason: Option, + #[serde(default)] + pub entered_slot_index: Option, + pub cleared_item_instance_ids: Vec, + pub run: Match3DRunSnapshotResponse, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DRunResponse { + pub run: Match3DRunSnapshotResponse, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DClickResponse { + pub confirmation: Match3DClickConfirmationResponse, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn click_match3d_item_request_uses_camel_case() { + let payload = serde_json::to_value(ClickMatch3DItemRequest { + item_instance_id: "item-1".to_string(), + client_action_id: "action-1".to_string(), + snapshot_version: 7, + }) + .expect("payload should serialize"); + + assert_eq!(payload["itemInstanceId"], json!("item-1")); + assert_eq!(payload["clientActionId"], json!("action-1")); + assert_eq!(payload["snapshotVersion"], json!(7)); + } +} diff --git a/server-rs/crates/shared-contracts/src/match3d_works.rs b/server-rs/crates/shared-contracts/src/match3d_works.rs new file mode 100644 index 00000000..a55d1f84 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/match3d_works.rs @@ -0,0 +1,89 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PutMatch3DWorkRequest { + pub game_name: String, + pub summary: String, + pub tags: Vec, + #[serde(default)] + pub cover_image_src: Option, + #[serde(default)] + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DWorkSummaryResponse { + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + #[serde(default)] + pub source_session_id: Option, + pub game_name: String, + pub theme_text: String, + pub summary: String, + pub tags: Vec, + #[serde(default)] + pub cover_image_src: Option, + #[serde(default)] + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, + pub publication_status: String, + pub play_count: u32, + pub updated_at: String, + #[serde(default)] + pub published_at: Option, + pub publish_ready: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DWorkProfileResponse { + #[serde(flatten)] + pub summary: Match3DWorkSummaryResponse, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DWorksResponse { + pub items: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DWorkDetailResponse { + pub item: Match3DWorkProfileResponse, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Match3DWorkMutationResponse { + pub item: Match3DWorkProfileResponse, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn match3d_work_request_uses_camel_case() { + let payload = serde_json::to_value(PutMatch3DWorkRequest { + game_name: "水果抓大鹅".to_string(), + summary: "水果主题".to_string(), + tags: vec!["水果".to_string()], + cover_image_src: None, + reference_image_src: None, + clear_count: 4, + difficulty: 5, + }) + .expect("payload should serialize"); + + assert_eq!(payload["gameName"], json!("水果抓大鹅")); + assert_eq!(payload["clearCount"], json!(4)); + } +} diff --git a/server-rs/crates/spacetime-module/Cargo.toml b/server-rs/crates/spacetime-module/Cargo.toml index 62749ac7..2ae5c223 100644 --- a/server-rs/crates/spacetime-module/Cargo.toml +++ b/server-rs/crates/spacetime-module/Cargo.toml @@ -18,6 +18,7 @@ module-big-fish = { path = "../module-big-fish", default-features = false, featu module-combat = { path = "../module-combat", default-features = false, features = ["spacetime-types"] } module-inventory = { path = "../module-inventory", default-features = false, features = ["spacetime-types"] } module-custom-world = { path = "../module-custom-world", default-features = false, features = ["spacetime-types"] } +module-match3d = { path = "../module-match3d", default-features = false } module-npc = { path = "../module-npc", default-features = false, features = ["spacetime-types"] } module-puzzle = { path = "../module-puzzle", default-features = false, features = ["spacetime-types"] } module-progression = { path = "../module-progression", default-features = false, features = ["spacetime-types"] } diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index c7003157..fe5874c8 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -31,6 +31,7 @@ mod auth; mod big_fish; mod domain_types; mod entry; +mod match3d; mod migration; mod puzzle; mod runtime; @@ -41,6 +42,7 @@ pub use auth::*; pub use big_fish::*; pub use domain_types::*; pub use entry::*; +pub use match3d::*; pub use migration::*; pub use runtime::*; @@ -2856,7 +2858,9 @@ fn list_custom_world_profile_snapshots( Ok(entries) } -fn build_custom_world_profile_list_snapshot(row: &CustomWorldProfile) -> CustomWorldProfileSnapshot { +fn build_custom_world_profile_list_snapshot( + row: &CustomWorldProfile, +) -> CustomWorldProfileSnapshot { let mut snapshot = build_custom_world_profile_snapshot(row); snapshot.profile_payload_json = build_custom_world_profile_list_payload_json(row); snapshot diff --git a/server-rs/crates/spacetime-module/src/match3d/mod.rs b/server-rs/crates/spacetime-module/src/match3d/mod.rs new file mode 100644 index 00000000..40a4f463 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/match3d/mod.rs @@ -0,0 +1,1642 @@ +pub(crate) mod tables; +mod types; + +pub use tables::*; +pub use types::*; + +use crate::*; +use module_match3d::{ + Match3DClickInput as DomainMatch3DClickInput, + Match3DClickRejectReason as DomainMatch3DClickRejectReason, + Match3DCreatorConfig as DomainMatch3DCreatorConfig, + Match3DFailureReason as DomainMatch3DFailureReason, + Match3DItemSnapshot as DomainMatch3DItemSnapshot, Match3DItemState as DomainMatch3DItemState, + Match3DRunSnapshot as DomainMatch3DRunSnapshot, Match3DRunStatus as DomainMatch3DRunStatus, + Match3DTraySlot as DomainMatch3DTraySlot, confirm_click_at as confirm_domain_click_at, + resolve_run_timer_at as resolve_domain_run_timer_at, start_run_with_seed_at, + stop_run_at as stop_domain_run_at, +}; +use serde::Serialize; +use serde::de::DeserializeOwned; + +#[spacetimedb::procedure] +pub fn create_match3d_agent_session( + ctx: &mut ProcedureContext, + input: Match3DAgentSessionCreateInput, +) -> Match3DAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| create_match3d_agent_session_tx(tx, input.clone())) { + Ok(session) => session_result(session), + Err(message) => session_error(message), + } +} + +#[spacetimedb::procedure] +pub fn get_match3d_agent_session( + ctx: &mut ProcedureContext, + input: Match3DAgentSessionGetInput, +) -> Match3DAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| get_match3d_agent_session_tx(tx, input.clone())) { + Ok(session) => session_result(session), + Err(message) => session_error(message), + } +} + +#[spacetimedb::procedure] +pub fn submit_match3d_agent_message( + ctx: &mut ProcedureContext, + input: Match3DAgentMessageSubmitInput, +) -> Match3DAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| submit_match3d_agent_message_tx(tx, input.clone())) { + Ok(session) => session_result(session), + Err(message) => session_error(message), + } +} + +#[spacetimedb::procedure] +pub fn finalize_match3d_agent_message_turn( + ctx: &mut ProcedureContext, + input: Match3DAgentMessageFinalizeInput, +) -> Match3DAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| finalize_match3d_agent_message_turn_tx(tx, input.clone())) { + Ok(session) => session_result(session), + Err(message) => session_error(message), + } +} + +#[spacetimedb::procedure] +pub fn compile_match3d_draft( + ctx: &mut ProcedureContext, + input: Match3DDraftCompileInput, +) -> Match3DAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| compile_match3d_draft_tx(tx, input.clone())) { + Ok(session) => session_result(session), + Err(message) => session_error(message), + } +} + +#[spacetimedb::procedure] +pub fn update_match3d_work( + ctx: &mut ProcedureContext, + input: Match3DWorkUpdateInput, +) -> Match3DWorkProcedureResult { + match ctx.try_with_tx(|tx| update_match3d_work_tx(tx, input.clone())) { + Ok(work) => work_result(work), + Err(message) => work_error(message), + } +} + +#[spacetimedb::procedure] +pub fn publish_match3d_work( + ctx: &mut ProcedureContext, + input: Match3DWorkPublishInput, +) -> Match3DWorkProcedureResult { + match ctx.try_with_tx(|tx| publish_match3d_work_tx(tx, input.clone())) { + Ok(work) => work_result(work), + Err(message) => work_error(message), + } +} + +#[spacetimedb::procedure] +pub fn list_match3d_works( + ctx: &mut ProcedureContext, + input: Match3DWorksListInput, +) -> Match3DWorksProcedureResult { + match ctx.try_with_tx(|tx| list_match3d_works_tx(tx, input.clone())) { + Ok(items) => Match3DWorksProcedureResult { + ok: true, + items_json: Some(to_json_string(&items)), + error_message: None, + }, + Err(message) => Match3DWorksProcedureResult { + ok: false, + items_json: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn get_match3d_work_detail( + ctx: &mut ProcedureContext, + input: Match3DWorkGetInput, +) -> Match3DWorkProcedureResult { + match ctx.try_with_tx(|tx| get_match3d_work_detail_tx(tx, input.clone())) { + Ok(work) => work_result(work), + Err(message) => work_error(message), + } +} + +#[spacetimedb::procedure] +pub fn delete_match3d_work( + ctx: &mut ProcedureContext, + input: Match3DWorkDeleteInput, +) -> Match3DWorksProcedureResult { + match ctx.try_with_tx(|tx| delete_match3d_work_tx(tx, input.clone())) { + Ok(items) => Match3DWorksProcedureResult { + ok: true, + items_json: Some(to_json_string(&items)), + error_message: None, + }, + Err(message) => Match3DWorksProcedureResult { + ok: false, + items_json: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn start_match3d_run( + ctx: &mut ProcedureContext, + input: Match3DRunStartInput, +) -> Match3DRunProcedureResult { + match ctx.try_with_tx(|tx| start_match3d_run_tx(tx, input.clone())) { + Ok(run) => run_result(run), + Err(message) => run_error(message), + } +} + +#[spacetimedb::procedure] +pub fn get_match3d_run( + ctx: &mut ProcedureContext, + input: Match3DRunGetInput, +) -> Match3DRunProcedureResult { + match ctx.try_with_tx(|tx| get_match3d_run_tx(tx, input.clone())) { + Ok(run) => run_result(run), + Err(message) => run_error(message), + } +} + +#[spacetimedb::procedure] +pub fn click_match3d_item( + ctx: &mut ProcedureContext, + input: Match3DRunClickInput, +) -> Match3DClickItemProcedureResult { + match ctx.try_with_tx(|tx| click_match3d_item_tx(tx, input.clone())) { + Ok(result) => result, + Err(message) => Match3DClickItemProcedureResult { + ok: false, + status: MATCH3D_CLICK_REJECTED_NOT_CLICKABLE.to_string(), + run_json: None, + accepted_item_instance_id: None, + cleared_item_instance_ids: Vec::new(), + failure_reason: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn stop_match3d_run( + ctx: &mut ProcedureContext, + input: Match3DRunStopInput, +) -> Match3DRunProcedureResult { + match ctx.try_with_tx(|tx| stop_match3d_run_tx(tx, input.clone())) { + Ok(run) => run_result(run), + Err(message) => run_error(message), + } +} + +#[spacetimedb::procedure] +pub fn restart_match3d_run( + ctx: &mut ProcedureContext, + input: Match3DRunRestartInput, +) -> Match3DRunProcedureResult { + match ctx.try_with_tx(|tx| restart_match3d_run_tx(tx, input.clone())) { + Ok(run) => run_result(run), + Err(message) => run_error(message), + } +} + +#[spacetimedb::procedure] +pub fn finish_match3d_time_up( + ctx: &mut ProcedureContext, + input: Match3DRunTimeUpInput, +) -> Match3DRunProcedureResult { + match ctx.try_with_tx(|tx| finish_match3d_time_up_tx(tx, input.clone())) { + Ok(run) => run_result(run), + Err(message) => run_error(message), + } +} + +fn create_match3d_agent_session_tx( + ctx: &ReducerContext, + input: Match3DAgentSessionCreateInput, +) -> Result { + require_non_empty(&input.session_id, "match3d session_id")?; + require_non_empty(&input.owner_user_id, "match3d owner_user_id")?; + require_non_empty(&input.welcome_message_id, "match3d welcome_message_id")?; + if ctx + .db + .match3d_agent_session() + .session_id() + .find(&input.session_id) + .is_some() + { + return Err("match3d_agent_session.session_id 已存在".to_string()); + } + if ctx + .db + .match3d_agent_message() + .message_id() + .find(&input.welcome_message_id) + .is_some() + { + return Err("match3d_agent_message.message_id 已存在".to_string()); + } + + let config = input + .config_json + .as_deref() + .map(parse_config) + .transpose()? + .unwrap_or_else(|| default_config_from_seed(&input.seed_text)); + validate_config(&config)?; + let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros); + let welcome = input.welcome_message_text.trim(); + + ctx.db + .match3d_agent_session() + .insert(Match3DAgentSessionRow { + session_id: input.session_id.clone(), + owner_user_id: input.owner_user_id.clone(), + seed_text: input.seed_text.trim().to_string(), + current_turn: 0, + progress_percent: 0, + stage: MATCH3D_STAGE_COLLECTING.to_string(), + config_json: to_json_string(&config), + draft_json: String::new(), + last_assistant_reply: welcome.to_string(), + published_profile_id: String::new(), + created_at, + updated_at: created_at, + }); + ctx.db + .match3d_agent_message() + .insert(Match3DAgentMessageRow { + message_id: input.welcome_message_id, + session_id: input.session_id.clone(), + role: MATCH3D_ROLE_ASSISTANT.to_string(), + kind: MATCH3D_KIND_TEXT.to_string(), + text: welcome.to_string(), + created_at, + }); + + get_match3d_agent_session_tx( + ctx, + Match3DAgentSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + +fn get_match3d_agent_session_tx( + ctx: &ReducerContext, + input: Match3DAgentSessionGetInput, +) -> Result { + let row = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?; + build_session_snapshot(ctx, &row) +} + +fn submit_match3d_agent_message_tx( + ctx: &ReducerContext, + input: Match3DAgentMessageSubmitInput, +) -> Result { + require_non_empty(&input.user_message_id, "match3d user_message_id")?; + require_non_empty(&input.user_message_text, "match3d user_message_text")?; + let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?; + if ctx + .db + .match3d_agent_message() + .message_id() + .find(&input.user_message_id) + .is_some() + { + return Err("match3d_agent_message.user_message_id 已存在".to_string()); + } + + let submitted_at = Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros); + ctx.db + .match3d_agent_message() + .insert(Match3DAgentMessageRow { + message_id: input.user_message_id, + session_id: input.session_id.clone(), + role: MATCH3D_ROLE_USER.to_string(), + kind: MATCH3D_KIND_TEXT.to_string(), + text: input.user_message_text.trim().to_string(), + created_at: submitted_at, + }); + replace_session( + ctx, + &session, + Match3DAgentSessionRow { + updated_at: submitted_at, + ..clone_session(&session) + }, + ); + + get_match3d_agent_session_tx( + ctx, + Match3DAgentSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + +fn finalize_match3d_agent_message_turn_tx( + ctx: &ReducerContext, + input: Match3DAgentMessageFinalizeInput, +) -> Result { + let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?; + let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + if let Some(message) = input + .error_message + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + replace_session( + ctx, + &session, + Match3DAgentSessionRow { + updated_at, + ..clone_session(&session) + }, + ); + return Err(message.to_string()); + } + + let next_config = input + .config_json + .as_deref() + .map(parse_config) + .transpose()? + .unwrap_or_else(|| parse_config_or_default(&session.config_json)); + validate_config(&next_config)?; + let assistant_text = input + .assistant_reply_text + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(&session.last_assistant_reply) + .to_string(); + if let Some(message_id) = input + .assistant_message_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if ctx + .db + .match3d_agent_message() + .message_id() + .find(&message_id.to_string()) + .is_some() + { + return Err("match3d_agent_message.assistant_message_id 已存在".to_string()); + } + ctx.db + .match3d_agent_message() + .insert(Match3DAgentMessageRow { + message_id: message_id.to_string(), + session_id: input.session_id.clone(), + role: MATCH3D_ROLE_ASSISTANT.to_string(), + kind: MATCH3D_KIND_TEXT.to_string(), + text: assistant_text.clone(), + created_at: updated_at, + }); + } + + let next_stage = normalize_stage(&input.stage); + replace_session( + ctx, + &session, + Match3DAgentSessionRow { + current_turn: session.current_turn.saturating_add(1), + progress_percent: input.progress_percent.min(100), + stage: next_stage, + config_json: to_json_string(&next_config), + last_assistant_reply: assistant_text, + updated_at, + ..clone_session(&session) + }, + ); + + get_match3d_agent_session_tx( + ctx, + Match3DAgentSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + +fn compile_match3d_draft_tx( + ctx: &ReducerContext, + input: Match3DDraftCompileInput, +) -> Result { + require_non_empty(&input.profile_id, "match3d profile_id")?; + let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?; + let config = parse_config(&session.config_json)?; + validate_config(&config)?; + let tags = input + .tags_json + .as_deref() + .map(parse_tags) + .transpose()? + .filter(|items| !items.is_empty()) + .unwrap_or_else(|| default_tags(&config.theme_text)); + let game_name = + clean_optional(&input.game_name).unwrap_or_else(|| format!("{}抓大鹅", config.theme_text)); + let summary_text = clean_optional(&input.summary_text) + .unwrap_or_else(|| format!("{}主题的经典消除玩法。", config.theme_text)); + let draft = Match3DDraftSnapshot { + profile_id: input.profile_id.clone(), + game_name: game_name.clone(), + theme_text: config.theme_text.clone(), + summary_text: summary_text.clone(), + tags: tags.clone(), + clear_count: config.clear_count, + difficulty: config.difficulty, + }; + let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros); + let work = Match3DWorkProfileRow { + profile_id: input.profile_id.clone(), + owner_user_id: input.owner_user_id.clone(), + source_session_id: input.session_id.clone(), + author_display_name: clean_string(&input.author_display_name, "陶泥主"), + game_name, + theme_text: config.theme_text.clone(), + summary_text, + tags_json: to_json_string(&tags), + cover_image_src: clean_optional(&input.cover_image_src).unwrap_or_default(), + cover_asset_id: clean_optional(&input.cover_asset_id).unwrap_or_default(), + clear_count: config.clear_count, + difficulty: config.difficulty, + config_json: to_json_string(&config), + publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(), + play_count: 0, + updated_at: compiled_at, + published_at: None, + }; + upsert_work(ctx, work); + replace_session( + ctx, + &session, + Match3DAgentSessionRow { + progress_percent: 80, + stage: MATCH3D_STAGE_DRAFT_COMPILED.to_string(), + draft_json: to_json_string(&draft), + published_profile_id: input.profile_id, + last_assistant_reply: "抓大鹅玩法草稿已生成,可以进入结果页编辑基础信息并试玩。" + .to_string(), + updated_at: compiled_at, + ..clone_session(&session) + }, + ); + + get_match3d_agent_session_tx( + ctx, + Match3DAgentSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + +fn update_match3d_work_tx( + ctx: &ReducerContext, + input: Match3DWorkUpdateInput, +) -> Result { + let current = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?; + let tags = parse_tags(&input.tags_json)?; + let config = Match3DCreatorConfigSnapshot { + theme_text: clean_string(&input.theme_text, "经典消除"), + reference_image_src: parse_config_or_default(¤t.config_json).reference_image_src, + clear_count: input.clear_count, + difficulty: input.difficulty, + }; + validate_config(&config)?; + let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + let next = Match3DWorkProfileRow { + profile_id: current.profile_id.clone(), + owner_user_id: current.owner_user_id.clone(), + source_session_id: current.source_session_id.clone(), + author_display_name: current.author_display_name.clone(), + game_name: clean_string(&input.game_name, "未命名抓大鹅"), + theme_text: config.theme_text.clone(), + summary_text: clean_string(&input.summary_text, "经典消除玩法"), + tags_json: to_json_string(&tags), + cover_image_src: input.cover_image_src.trim().to_string(), + cover_asset_id: input.cover_asset_id.trim().to_string(), + clear_count: config.clear_count, + difficulty: config.difficulty, + config_json: to_json_string(&config), + publication_status: current.publication_status.clone(), + play_count: current.play_count, + updated_at, + published_at: current.published_at, + }; + let snapshot = build_work_snapshot(&next)?; + replace_work(ctx, ¤t, next); + Ok(snapshot) +} + +fn publish_match3d_work_tx( + ctx: &ReducerContext, + input: Match3DWorkPublishInput, +) -> Result { + let current = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?; + validate_publishable_work(¤t)?; + let published_at = Timestamp::from_micros_since_unix_epoch(input.published_at_micros); + let next = Match3DWorkProfileRow { + publication_status: MATCH3D_PUBLICATION_PUBLISHED.to_string(), + updated_at: published_at, + published_at: Some(published_at), + ..clone_work(¤t) + }; + let snapshot = build_work_snapshot(&next)?; + if !next.source_session_id.is_empty() { + if let Some(session) = ctx + .db + .match3d_agent_session() + .session_id() + .find(&next.source_session_id) + .filter(|row| row.owner_user_id == input.owner_user_id) + { + replace_session( + ctx, + &session, + Match3DAgentSessionRow { + progress_percent: 100, + stage: MATCH3D_STAGE_PUBLISHED.to_string(), + published_profile_id: next.profile_id.clone(), + updated_at: published_at, + ..clone_session(&session) + }, + ); + } + } + replace_work(ctx, ¤t, next); + Ok(snapshot) +} + +fn list_match3d_works_tx( + ctx: &ReducerContext, + input: Match3DWorksListInput, +) -> Result, String> { + let mut items = ctx + .db + .match3d_work_profile() + .iter() + .filter(|row| { + if input.published_only { + row.publication_status == MATCH3D_PUBLICATION_PUBLISHED + } else { + row.owner_user_id == input.owner_user_id + } + }) + .map(|row| build_work_snapshot(&row)) + .collect::, _>>()?; + items.sort_by(|left, right| { + right + .updated_at_micros + .cmp(&left.updated_at_micros) + .then_with(|| left.profile_id.cmp(&right.profile_id)) + }); + Ok(items) +} + +fn get_match3d_work_detail_tx( + ctx: &ReducerContext, + input: Match3DWorkGetInput, +) -> Result { + let row = ctx + .db + .match3d_work_profile() + .profile_id() + .find(&input.profile_id) + .filter(|row| { + row.owner_user_id == input.owner_user_id + || row.publication_status == MATCH3D_PUBLICATION_PUBLISHED + }) + .ok_or_else(|| "match3d_work_profile 不存在".to_string())?; + build_work_snapshot(&row) +} + +fn delete_match3d_work_tx( + ctx: &ReducerContext, + input: Match3DWorkDeleteInput, +) -> Result, String> { + let work = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?; + ctx.db + .match3d_work_profile() + .profile_id() + .delete(&work.profile_id); + for run in ctx + .db + .match3d_runtime_run() + .iter() + .filter(|row| { + row.profile_id == input.profile_id && row.owner_user_id == input.owner_user_id + }) + .collect::>() + { + ctx.db.match3d_runtime_run().run_id().delete(&run.run_id); + } + list_match3d_works_tx( + ctx, + Match3DWorksListInput { + owner_user_id: input.owner_user_id, + published_only: false, + }, + ) +} + +fn start_match3d_run_tx( + ctx: &ReducerContext, + input: Match3DRunStartInput, +) -> Result { + require_non_empty(&input.run_id, "match3d run_id")?; + if ctx + .db + .match3d_runtime_run() + .run_id() + .find(&input.run_id) + .is_some() + { + return Err("match3d_runtime_run.run_id 已存在".to_string()); + } + let work = ctx + .db + .match3d_work_profile() + .profile_id() + .find(&input.profile_id) + .filter(|row| { + row.owner_user_id == input.owner_user_id + || row.publication_status == MATCH3D_PUBLICATION_PUBLISHED + }) + .ok_or_else(|| "match3d_work_profile 不存在".to_string())?; + let started_at_ms = if input.started_at_ms > 0 { + input.started_at_ms + } else { + current_server_ms(ctx) + }; + let mut snapshot = build_initial_run_snapshot(&input.run_id, &work, started_at_ms); + snapshot.server_now_ms = current_server_ms(ctx); + snapshot.remaining_ms = compute_remaining_ms(&snapshot, snapshot.server_now_ms); + let now = ctx.timestamp; + ctx.db.match3d_runtime_run().insert(row_from_snapshot( + &input.owner_user_id, + &snapshot, + now, + now, + )); + + Ok(snapshot) +} + +fn get_match3d_run_tx( + ctx: &ReducerContext, + input: Match3DRunGetInput, +) -> Result { + let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; + let snapshot = deserialize_snapshot(&row.snapshot_json)?; + let next = confirm_time_up_if_needed(ctx, &row, snapshot, current_server_ms(ctx))?; + Ok(next) +} + +fn click_match3d_item_tx( + ctx: &ReducerContext, + input: Match3DRunClickInput, +) -> Result { + let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; + let snapshot = deserialize_snapshot(&row.snapshot_json)?; + let server_now_ms = current_server_ms(ctx); + let snapshot = confirm_time_up_if_needed(ctx, &row, snapshot, server_now_ms)?; + if snapshot.status != MATCH3D_RUN_RUNNING { + return Ok(click_result( + MATCH3D_CLICK_RUN_FINISHED, + snapshot, + None, + Vec::new(), + )); + } + if snapshot.snapshot_version != input.client_snapshot_version { + return Ok(click_result( + MATCH3D_CLICK_VERSION_CONFLICT, + snapshot, + None, + Vec::new(), + )); + } + let domain_run = domain_snapshot_from_snapshot(&snapshot, &row.owner_user_id); + let confirmation = confirm_domain_click_at( + &domain_run, + &DomainMatch3DClickInput { + run_id: input.run_id.clone(), + owner_user_id: input.owner_user_id.clone(), + item_instance_id: input.item_instance_id.clone(), + client_action_id: clean_string(&input.client_event_id, "match3d-action"), + snapshot_version: input.client_snapshot_version as u64, + clicked_at_ms: to_u64_ms(server_now_ms), + }, + ) + .map_err(|error| error.to_string())?; + let next = snapshot_from_domain(&confirmation.run, server_now_ms); + let status = if confirmation.accepted { + MATCH3D_CLICK_ACCEPTED + } else { + match confirmation.reject_reason { + Some(DomainMatch3DClickRejectReason::RunNotActive) => MATCH3D_CLICK_RUN_FINISHED, + Some(DomainMatch3DClickRejectReason::SnapshotVersionMismatch) => { + MATCH3D_CLICK_VERSION_CONFLICT + } + Some(DomainMatch3DClickRejectReason::ItemNotFound) + | Some(DomainMatch3DClickRejectReason::ItemNotInBoard) => { + MATCH3D_CLICK_REJECTED_ALREADY_MOVED + } + Some(DomainMatch3DClickRejectReason::ItemNotClickable) => { + MATCH3D_CLICK_REJECTED_NOT_CLICKABLE + } + Some(DomainMatch3DClickRejectReason::TrayFull) => MATCH3D_CLICK_REJECTED_TRAY_FULL, + None => MATCH3D_CLICK_REJECTED_NOT_CLICKABLE, + } + }; + if confirmation.accepted + || status == MATCH3D_CLICK_REJECTED_TRAY_FULL + || next.status != snapshot.status + || next.snapshot_version != snapshot.snapshot_version + { + persist_snapshot(ctx, &row, &next, server_now_ms); + } + Ok(click_result( + status, + next, + confirmation + .accepted + .then_some(input.item_instance_id), + confirmation.cleared_item_instance_ids, + )) +} + +fn stop_match3d_run_tx( + ctx: &ReducerContext, + input: Match3DRunStopInput, +) -> Result { + let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; + let stopped_at_ms = input.stopped_at_ms.max(current_server_ms(ctx)); + let snapshot = deserialize_snapshot(&row.snapshot_json)?; + let domain_run = domain_snapshot_from_snapshot(&snapshot, &row.owner_user_id); + let domain_run = stop_domain_run_at(&domain_run, "match3d-stop".to_string()); + let next = snapshot_from_domain(&domain_run, stopped_at_ms); + persist_snapshot(ctx, &row, &next, stopped_at_ms); + Ok(next) +} + +fn restart_match3d_run_tx( + ctx: &ReducerContext, + input: Match3DRunRestartInput, +) -> Result { + let source = find_owned_run(ctx, &input.source_run_id, &input.owner_user_id)?; + start_match3d_run_tx( + ctx, + Match3DRunStartInput { + run_id: input.next_run_id, + owner_user_id: input.owner_user_id, + profile_id: source.profile_id, + started_at_ms: input.restarted_at_ms, + }, + ) +} + +fn finish_match3d_time_up_tx( + ctx: &ReducerContext, + input: Match3DRunTimeUpInput, +) -> Result { + let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; + let snapshot = deserialize_snapshot(&row.snapshot_json)?; + let finished_at_ms = input.finished_at_ms.max(current_server_ms(ctx)); + let domain_run = domain_snapshot_from_snapshot(&snapshot, &row.owner_user_id); + let domain_run = resolve_domain_run_timer_at(&domain_run, to_u64_ms(finished_at_ms)); + let next = snapshot_from_domain(&domain_run, finished_at_ms); + persist_snapshot(ctx, &row, &next, finished_at_ms); + Ok(next) +} + +fn find_owned_session( + ctx: &ReducerContext, + session_id: &str, + owner_user_id: &str, +) -> Result { + require_non_empty(session_id, "match3d session_id")?; + require_non_empty(owner_user_id, "match3d owner_user_id")?; + ctx.db + .match3d_agent_session() + .session_id() + .find(&session_id.to_string()) + .filter(|row| row.owner_user_id == owner_user_id) + .ok_or_else(|| "match3d_agent_session 不存在".to_string()) +} + +fn find_owned_work( + ctx: &ReducerContext, + profile_id: &str, + owner_user_id: &str, +) -> Result { + require_non_empty(profile_id, "match3d profile_id")?; + require_non_empty(owner_user_id, "match3d owner_user_id")?; + ctx.db + .match3d_work_profile() + .profile_id() + .find(&profile_id.to_string()) + .filter(|row| row.owner_user_id == owner_user_id) + .ok_or_else(|| "match3d_work_profile 不存在".to_string()) +} + +fn find_owned_run( + ctx: &ReducerContext, + run_id: &str, + owner_user_id: &str, +) -> Result { + require_non_empty(run_id, "match3d run_id")?; + require_non_empty(owner_user_id, "match3d owner_user_id")?; + ctx.db + .match3d_runtime_run() + .run_id() + .find(&run_id.to_string()) + .filter(|row| row.owner_user_id == owner_user_id) + .ok_or_else(|| "match3d_runtime_run 不存在".to_string()) +} + +fn build_session_snapshot( + ctx: &ReducerContext, + row: &Match3DAgentSessionRow, +) -> Result { + let mut messages = ctx + .db + .match3d_agent_message() + .iter() + .filter(|message| message.session_id == row.session_id) + .map(|message| Match3DAgentMessageSnapshot { + message_id: message.message_id, + session_id: message.session_id, + role: message.role, + kind: message.kind, + text: message.text, + created_at_micros: message.created_at.to_micros_since_unix_epoch(), + }) + .collect::>(); + messages.sort_by(|left, right| { + left.created_at_micros + .cmp(&right.created_at_micros) + .then_with(|| left.message_id.cmp(&right.message_id)) + }); + let config = parse_config(&row.config_json)?; + let draft = if row.draft_json.trim().is_empty() { + None + } else { + Some(parse_json::( + &row.draft_json, + "match3d draft_json", + )?) + }; + + Ok(Match3DAgentSessionSnapshot { + session_id: row.session_id.clone(), + owner_user_id: row.owner_user_id.clone(), + seed_text: row.seed_text.clone(), + current_turn: row.current_turn, + progress_percent: row.progress_percent, + stage: row.stage.clone(), + config, + draft, + messages, + last_assistant_reply: row.last_assistant_reply.clone(), + published_profile_id: empty_to_none(&row.published_profile_id), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + }) +} + +fn build_work_snapshot(row: &Match3DWorkProfileRow) -> Result { + let config = parse_config(&row.config_json)?; + let tags = parse_tags(&row.tags_json)?; + Ok(Match3DWorkSnapshot { + profile_id: row.profile_id.clone(), + owner_user_id: row.owner_user_id.clone(), + source_session_id: row.source_session_id.clone(), + author_display_name: row.author_display_name.clone(), + game_name: row.game_name.clone(), + theme_text: row.theme_text.clone(), + summary_text: row.summary_text.clone(), + tags, + cover_image_src: row.cover_image_src.clone(), + cover_asset_id: row.cover_asset_id.clone(), + clear_count: row.clear_count, + difficulty: row.difficulty, + config, + publication_status: row.publication_status.clone(), + publish_ready: is_work_publish_ready(row), + play_count: row.play_count, + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + published_at_micros: row + .published_at + .map(|value| value.to_micros_since_unix_epoch()), + }) +} + +fn build_initial_run_snapshot( + run_id: &str, + work: &Match3DWorkProfileRow, + started_at_ms: i64, +) -> Match3DRunSnapshot { + let config = parse_config_or_default(&work.config_json); + let domain_config = + domain_config_from_snapshot(&config).unwrap_or_else(|_| fallback_domain_config()); + let domain_started_at_ms = to_u64_ms(started_at_ms); + let seed = deterministic_run_seed(run_id, &work.profile_id, work.clear_count, work.difficulty); + let domain_run = start_run_with_seed_at( + run_id.to_string(), + work.owner_user_id.clone(), + work.profile_id.clone(), + &domain_config, + seed, + domain_started_at_ms, + ) + .unwrap_or_else(|_| { + DomainMatch3DRunSnapshot { + run_id: run_id.to_string(), + profile_id: work.profile_id.clone(), + owner_user_id: work.owner_user_id.clone(), + status: DomainMatch3DRunStatus::Running, + started_at_ms: domain_started_at_ms, + duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS as u64, + remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS as u64, + clear_count: work.clear_count.max(1), + total_item_count: work.clear_count.max(1).saturating_mul(3), + cleared_item_count: 0, + board_version: 1, + items: Vec::new(), + tray_slots: Vec::new(), + failure_reason: None, + last_confirmed_action_id: None, + } + }); + snapshot_from_domain(&domain_run, started_at_ms) +} + +fn fallback_domain_config() -> DomainMatch3DCreatorConfig { + DomainMatch3DCreatorConfig { + theme_text: "经典消除".to_string(), + reference_image_src: None, + clear_count: 1, + difficulty: 3, + } +} + +fn confirm_time_up_if_needed( + ctx: &ReducerContext, + row: &Match3DRuntimeRunRow, + snapshot: Match3DRunSnapshot, + server_now_ms: i64, +) -> Result { + if snapshot.status != MATCH3D_RUN_RUNNING || compute_remaining_ms(&snapshot, server_now_ms) > 0 + { + let mut next = snapshot; + next.server_now_ms = server_now_ms; + next.remaining_ms = compute_remaining_ms(&next, server_now_ms); + return Ok(next); + } + let domain_run = domain_snapshot_from_snapshot(&snapshot, &row.owner_user_id); + let domain_run = resolve_domain_run_timer_at(&domain_run, to_u64_ms(server_now_ms)); + let next = snapshot_from_domain(&domain_run, server_now_ms); + persist_snapshot(ctx, row, &next, server_now_ms); + Ok(next) +} + +fn persist_snapshot( + ctx: &ReducerContext, + row: &Match3DRuntimeRunRow, + snapshot: &Match3DRunSnapshot, + server_now_ms: i64, +) { + let updated_at = Timestamp::from_micros_since_unix_epoch(server_now_ms.saturating_mul(1000)); + let next = row_from_snapshot(&row.owner_user_id, snapshot, row.created_at, updated_at); + ctx.db.match3d_runtime_run().run_id().delete(&row.run_id); + ctx.db.match3d_runtime_run().insert(next); +} + +fn row_from_snapshot( + owner_user_id: &str, + snapshot: &Match3DRunSnapshot, + created_at: Timestamp, + updated_at: Timestamp, +) -> Match3DRuntimeRunRow { + let finished_at_ms = if snapshot.status == MATCH3D_RUN_RUNNING { + 0 + } else { + snapshot.server_now_ms + }; + let elapsed_ms = if finished_at_ms > 0 { + finished_at_ms.saturating_sub(snapshot.started_at_ms) + } else { + snapshot + .server_now_ms + .saturating_sub(snapshot.started_at_ms) + }; + Match3DRuntimeRunRow { + run_id: snapshot.run_id.clone(), + owner_user_id: owner_user_id.to_string(), + profile_id: snapshot.profile_id.clone(), + status: snapshot.status.clone(), + snapshot_version: snapshot.snapshot_version, + started_at_ms: snapshot.started_at_ms, + duration_limit_ms: snapshot.duration_limit_ms, + finished_at_ms, + elapsed_ms, + clear_count: snapshot.clear_count, + total_item_count: snapshot.total_item_count, + cleared_item_count: snapshot.cleared_item_count, + failure_reason: snapshot.failure_reason.clone().unwrap_or_default(), + snapshot_json: to_json_string(snapshot), + created_at, + updated_at, + } +} + +fn click_result( + status: &str, + snapshot: Match3DRunSnapshot, + accepted_item_instance_id: Option, + cleared_item_instance_ids: Vec, +) -> Match3DClickItemProcedureResult { + Match3DClickItemProcedureResult { + ok: true, + status: status.to_string(), + run_json: Some(to_json_string(&snapshot)), + accepted_item_instance_id, + cleared_item_instance_ids, + failure_reason: snapshot.failure_reason, + error_message: None, + } +} + +fn upsert_work(ctx: &ReducerContext, work: Match3DWorkProfileRow) { + if ctx + .db + .match3d_work_profile() + .profile_id() + .find(&work.profile_id) + .is_some() + { + ctx.db + .match3d_work_profile() + .profile_id() + .delete(&work.profile_id); + } + ctx.db.match3d_work_profile().insert(work); +} + +fn replace_session( + ctx: &ReducerContext, + current: &Match3DAgentSessionRow, + next: Match3DAgentSessionRow, +) { + ctx.db + .match3d_agent_session() + .session_id() + .delete(¤t.session_id); + ctx.db.match3d_agent_session().insert(next); +} + +fn replace_work( + ctx: &ReducerContext, + current: &Match3DWorkProfileRow, + next: Match3DWorkProfileRow, +) { + ctx.db + .match3d_work_profile() + .profile_id() + .delete(¤t.profile_id); + ctx.db.match3d_work_profile().insert(next); +} + +fn clone_session(row: &Match3DAgentSessionRow) -> Match3DAgentSessionRow { + Match3DAgentSessionRow { + session_id: row.session_id.clone(), + owner_user_id: row.owner_user_id.clone(), + seed_text: row.seed_text.clone(), + current_turn: row.current_turn, + progress_percent: row.progress_percent, + stage: row.stage.clone(), + config_json: row.config_json.clone(), + draft_json: row.draft_json.clone(), + last_assistant_reply: row.last_assistant_reply.clone(), + published_profile_id: row.published_profile_id.clone(), + created_at: row.created_at, + updated_at: row.updated_at, + } +} + +fn clone_work(row: &Match3DWorkProfileRow) -> Match3DWorkProfileRow { + Match3DWorkProfileRow { + profile_id: row.profile_id.clone(), + owner_user_id: row.owner_user_id.clone(), + source_session_id: row.source_session_id.clone(), + author_display_name: row.author_display_name.clone(), + game_name: row.game_name.clone(), + theme_text: row.theme_text.clone(), + summary_text: row.summary_text.clone(), + tags_json: row.tags_json.clone(), + cover_image_src: row.cover_image_src.clone(), + cover_asset_id: row.cover_asset_id.clone(), + clear_count: row.clear_count, + difficulty: row.difficulty, + config_json: row.config_json.clone(), + publication_status: row.publication_status.clone(), + play_count: row.play_count, + updated_at: row.updated_at, + published_at: row.published_at, + } +} + +fn validate_config(config: &Match3DCreatorConfigSnapshot) -> Result<(), String> { + domain_config_from_snapshot(config) + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +fn validate_publishable_work(row: &Match3DWorkProfileRow) -> Result<(), String> { + if row.game_name.trim().is_empty() { + return Err("match3d 发布需要填写游戏名称".to_string()); + } + if row.cover_image_src.trim().is_empty() { + return Err("match3d 发布需要封面图".to_string()); + } + if parse_tags(&row.tags_json)?.is_empty() { + return Err("match3d 发布需要至少 1 个标签".to_string()); + } + validate_config(&parse_config(&row.config_json)?) +} + +fn is_work_publish_ready(row: &Match3DWorkProfileRow) -> bool { + validate_publishable_work(row).is_ok() +} + +fn default_config_from_seed(seed_text: &str) -> Match3DCreatorConfigSnapshot { + Match3DCreatorConfigSnapshot { + theme_text: clean_string(seed_text, "经典消除"), + reference_image_src: None, + clear_count: 12, + difficulty: 3, + } +} + +fn parse_config_or_default(value: &str) -> Match3DCreatorConfigSnapshot { + parse_config(value).unwrap_or_else(|_| default_config_from_seed("经典消除")) +} + +fn parse_config(value: &str) -> Result { + parse_json(value, "match3d config_json").map(|mut config: Match3DCreatorConfigSnapshot| { + config.theme_text = clean_string(&config.theme_text, "经典消除"); + config.difficulty = config + .difficulty + .clamp(MATCH3D_MIN_DIFFICULTY, MATCH3D_MAX_DIFFICULTY); + config + }) +} + +fn parse_tags(value: &str) -> Result, String> { + let parsed = parse_json::>(value, "match3d tags_json")?; + Ok(normalize_tags(parsed)) +} + +fn default_tags(theme_text: &str) -> Vec { + normalize_tags(vec![ + theme_text.to_string(), + "抓大鹅".to_string(), + "消除".to_string(), + ]) +} + +fn normalize_tags(tags: Vec) -> Vec { + let mut result = Vec::new(); + for tag in tags { + let trimmed = tag.trim(); + if !trimmed.is_empty() && !result.iter().any(|item: &String| item == trimmed) { + result.push(trimmed.to_string()); + } + if result.len() >= 6 { + break; + } + } + result +} + +fn normalize_stage(value: &str) -> String { + match value.trim() { + MATCH3D_STAGE_READY_TO_COMPILE => MATCH3D_STAGE_READY_TO_COMPILE.to_string(), + MATCH3D_STAGE_DRAFT_COMPILED => MATCH3D_STAGE_DRAFT_COMPILED.to_string(), + MATCH3D_STAGE_PUBLISHED => MATCH3D_STAGE_PUBLISHED.to_string(), + _ => MATCH3D_STAGE_COLLECTING.to_string(), + } +} + +fn domain_config_from_snapshot( + config: &Match3DCreatorConfigSnapshot, +) -> Result { + module_match3d::build_creator_config( + &config.theme_text, + config.reference_image_src.clone(), + config.clear_count, + config.difficulty, + ) +} + +fn snapshot_from_domain( + run: &DomainMatch3DRunSnapshot, + server_now_ms: i64, +) -> Match3DRunSnapshot { + Match3DRunSnapshot { + run_id: run.run_id.clone(), + profile_id: run.profile_id.clone(), + status: domain_status_to_text(run.status).to_string(), + snapshot_version: run.board_version.min(u32::MAX as u64) as u32, + started_at_ms: run.started_at_ms.min(i64::MAX as u64) as i64, + duration_limit_ms: run.duration_limit_ms.min(i64::MAX as u64) as i64, + server_now_ms, + remaining_ms: run.remaining_ms.min(i64::MAX as u64) as i64, + clear_count: run.clear_count, + total_item_count: run.total_item_count, + cleared_item_count: run.cleared_item_count, + tray_slots: run + .tray_slots + .iter() + .map(snapshot_tray_slot_from_domain) + .collect(), + items: run.items.iter().map(snapshot_item_from_domain).collect(), + failure_reason: run.failure_reason.map(domain_failure_to_text).map(str::to_string), + } +} + +fn domain_snapshot_from_snapshot( + snapshot: &Match3DRunSnapshot, + owner_user_id: &str, +) -> DomainMatch3DRunSnapshot { + DomainMatch3DRunSnapshot { + run_id: snapshot.run_id.clone(), + profile_id: snapshot.profile_id.clone(), + owner_user_id: owner_user_id.to_string(), + status: domain_status_from_text(&snapshot.status), + started_at_ms: to_u64_ms(snapshot.started_at_ms), + duration_limit_ms: to_u64_ms(snapshot.duration_limit_ms), + remaining_ms: to_u64_ms(snapshot.remaining_ms), + clear_count: snapshot.clear_count, + total_item_count: snapshot.total_item_count, + cleared_item_count: snapshot.cleared_item_count, + board_version: snapshot.snapshot_version as u64, + items: snapshot.items.iter().map(domain_item_from_snapshot).collect(), + tray_slots: snapshot + .tray_slots + .iter() + .map(domain_tray_slot_from_snapshot) + .collect(), + failure_reason: snapshot + .failure_reason + .as_deref() + .map(domain_failure_from_text), + last_confirmed_action_id: None, + } +} + +fn snapshot_item_from_domain(item: &DomainMatch3DItemSnapshot) -> Match3DItemSnapshot { + Match3DItemSnapshot { + item_instance_id: item.item_instance_id.clone(), + item_type_id: item.item_type_id.clone(), + visual_key: item.visual_key.clone(), + x: item.x, + y: item.y, + radius: item.radius, + layer: item.layer, + state: domain_item_state_to_text(item.state).to_string(), + clickable: item.clickable, + } +} + +fn domain_item_from_snapshot(item: &Match3DItemSnapshot) -> DomainMatch3DItemSnapshot { + DomainMatch3DItemSnapshot { + item_instance_id: item.item_instance_id.clone(), + item_type_id: item.item_type_id.clone(), + visual_key: item.visual_key.clone(), + x: item.x, + y: item.y, + radius: item.radius, + layer: item.layer, + state: domain_item_state_from_text(&item.state), + clickable: item.clickable, + tray_slot_index: None, + } +} + +fn snapshot_tray_slot_from_domain(slot: &DomainMatch3DTraySlot) -> Match3DTraySlotSnapshot { + Match3DTraySlotSnapshot { + slot_index: slot.slot_index, + item_instance_id: slot.item_instance_id.clone(), + item_type_id: slot.item_type_id.clone(), + visual_key: slot.visual_key.clone(), + } +} + +fn domain_tray_slot_from_snapshot(slot: &Match3DTraySlotSnapshot) -> DomainMatch3DTraySlot { + DomainMatch3DTraySlot { + slot_index: slot.slot_index, + item_instance_id: slot.item_instance_id.clone(), + item_type_id: slot.item_type_id.clone(), + visual_key: slot.visual_key.clone(), + } +} + +fn domain_status_to_text(status: DomainMatch3DRunStatus) -> &'static str { + match status { + DomainMatch3DRunStatus::Running => MATCH3D_RUN_RUNNING, + DomainMatch3DRunStatus::Won => MATCH3D_RUN_WON, + DomainMatch3DRunStatus::Failed => MATCH3D_RUN_FAILED, + DomainMatch3DRunStatus::Stopped => MATCH3D_RUN_STOPPED, + } +} + +fn domain_status_from_text(value: &str) -> DomainMatch3DRunStatus { + match value { + MATCH3D_RUN_WON | "won" => DomainMatch3DRunStatus::Won, + MATCH3D_RUN_FAILED | "failed" => DomainMatch3DRunStatus::Failed, + MATCH3D_RUN_STOPPED | "stopped" => DomainMatch3DRunStatus::Stopped, + _ => DomainMatch3DRunStatus::Running, + } +} + +fn domain_failure_to_text(reason: DomainMatch3DFailureReason) -> &'static str { + match reason { + DomainMatch3DFailureReason::TimeUp => MATCH3D_FAILURE_TIME_UP, + DomainMatch3DFailureReason::TrayFull => MATCH3D_FAILURE_TRAY_FULL, + } +} + +fn domain_failure_from_text(value: &str) -> DomainMatch3DFailureReason { + match value { + MATCH3D_FAILURE_TRAY_FULL | "tray_full" => DomainMatch3DFailureReason::TrayFull, + _ => DomainMatch3DFailureReason::TimeUp, + } +} + +fn domain_item_state_to_text(state: DomainMatch3DItemState) -> &'static str { + match state { + DomainMatch3DItemState::InBoard => MATCH3D_ITEM_IN_BOARD, + DomainMatch3DItemState::InTray => MATCH3D_ITEM_IN_TRAY, + DomainMatch3DItemState::Cleared => MATCH3D_ITEM_CLEARED, + } +} + +fn domain_item_state_from_text(value: &str) -> DomainMatch3DItemState { + match value { + MATCH3D_ITEM_IN_TRAY | "in_tray" => DomainMatch3DItemState::InTray, + MATCH3D_ITEM_CLEARED | "cleared" => DomainMatch3DItemState::Cleared, + _ => DomainMatch3DItemState::InBoard, + } +} + +fn deterministic_run_seed( + run_id: &str, + profile_id: &str, + clear_count: u32, + difficulty: u32, +) -> u64 { + let mut seed = 0xcbf2_9ce4_8422_2325_u64; + for byte in run_id.bytes().chain(profile_id.bytes()) { + seed ^= byte as u64; + seed = seed.wrapping_mul(0x0000_0100_0000_01b3); + } + seed ^ ((clear_count as u64) << 32) ^ difficulty as u64 +} + +fn to_u64_ms(value: i64) -> u64 { + value.max(0) as u64 +} + +fn compute_remaining_ms(snapshot: &Match3DRunSnapshot, server_now_ms: i64) -> i64 { + snapshot + .duration_limit_ms + .saturating_sub(server_now_ms.saturating_sub(snapshot.started_at_ms)) + .max(0) +} + +fn current_server_ms(ctx: &ReducerContext) -> i64 { + ctx.timestamp + .to_micros_since_unix_epoch() + .saturating_div(1000) +} + +fn clean_optional(value: &Option) -> Option { + value + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn clean_string(value: &str, fallback: &str) -> String { + let trimmed = value.trim(); + if trimmed.is_empty() { + fallback.to_string() + } else { + trimmed.to_string() + } +} + +fn empty_to_none(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn require_non_empty(value: &str, label: &str) -> Result<(), String> { + if value.trim().is_empty() { + Err(format!("{label} 不能为空")) + } else { + Ok(()) + } +} + +fn parse_json(value: &str, label: &str) -> Result { + serde_json::from_str(value).map_err(|error| format!("{label} 非法: {error}")) +} + +fn deserialize_snapshot(value: &str) -> Result { + parse_json(value, "match3d snapshot_json") +} + +fn to_json_string(value: &T) -> String { + serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string()) +} + +fn session_result(session: Match3DAgentSessionSnapshot) -> Match3DAgentSessionProcedureResult { + Match3DAgentSessionProcedureResult { + ok: true, + session_json: Some(to_json_string(&session)), + error_message: None, + } +} + +fn session_error(message: String) -> Match3DAgentSessionProcedureResult { + Match3DAgentSessionProcedureResult { + ok: false, + session_json: None, + error_message: Some(message), + } +} + +fn work_result(work: Match3DWorkSnapshot) -> Match3DWorkProcedureResult { + Match3DWorkProcedureResult { + ok: true, + work_json: Some(to_json_string(&work)), + error_message: None, + } +} + +fn work_error(message: String) -> Match3DWorkProcedureResult { + Match3DWorkProcedureResult { + ok: false, + work_json: None, + error_message: Some(message), + } +} + +fn run_result(run: Match3DRunSnapshot) -> Match3DRunProcedureResult { + Match3DRunProcedureResult { + ok: true, + run_json: Some(to_json_string(&run)), + error_message: None, + } +} + +fn run_error(message: String) -> Match3DRunProcedureResult { + Match3DRunProcedureResult { + ok: false, + run_json: None, + error_message: Some(message), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn match3d_total_items_follow_clear_count() { + let work = Match3DWorkProfileRow { + profile_id: "profile-1".to_string(), + owner_user_id: "user-1".to_string(), + source_session_id: "session-1".to_string(), + author_display_name: "作者".to_string(), + game_name: "水果抓大鹅".to_string(), + theme_text: "水果".to_string(), + summary_text: "水果主题".to_string(), + tags_json: "[\"水果\"]".to_string(), + cover_image_src: "/cover.png".to_string(), + cover_asset_id: String::new(), + clear_count: 4, + difficulty: 3, + config_json: to_json_string(&Match3DCreatorConfigSnapshot { + theme_text: "水果".to_string(), + reference_image_src: None, + clear_count: 4, + difficulty: 3, + }), + publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(), + play_count: 0, + updated_at: Timestamp::from_micros_since_unix_epoch(1), + published_at: None, + }; + let snapshot = build_initial_run_snapshot("run-1", &work, 10); + assert_eq!(snapshot.total_item_count, 12); + assert_eq!(snapshot.items.len(), 12); + } + + #[test] + fn match3d_domain_click_bridge_clears_three_items() { + let snapshot = Match3DRunSnapshot { + run_id: "run-1".to_string(), + profile_id: "profile-1".to_string(), + status: MATCH3D_RUN_RUNNING.to_string(), + snapshot_version: 1, + started_at_ms: 0, + duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS, + server_now_ms: 0, + remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS, + clear_count: 1, + total_item_count: 3, + cleared_item_count: 0, + tray_slots: (0..MATCH3D_TRAY_SLOT_COUNT) + .map(|slot_index| Match3DTraySlotSnapshot { + slot_index, + item_instance_id: (slot_index < 2).then(|| format!("item-{slot_index}")), + item_type_id: (slot_index < 3).then(|| "type-1".to_string()), + visual_key: (slot_index < 3).then(|| "visual-1".to_string()), + }) + .collect(), + items: (0..3) + .map(|index| Match3DItemSnapshot { + item_instance_id: format!("item-{index}"), + item_type_id: "type-1".to_string(), + visual_key: "visual-1".to_string(), + x: 0.0, + y: 0.0, + radius: 0.1, + layer: index, + state: if index < 2 { + MATCH3D_ITEM_IN_TRAY.to_string() + } else { + MATCH3D_ITEM_IN_BOARD.to_string() + }, + clickable: index == 2, + }) + .collect(), + failure_reason: None, + }; + + let domain_run = domain_snapshot_from_snapshot(&snapshot, "user-1"); + let confirmation = confirm_domain_click_at( + &domain_run, + &DomainMatch3DClickInput { + run_id: "run-1".to_string(), + owner_user_id: "user-1".to_string(), + item_instance_id: "item-2".to_string(), + client_action_id: "action-1".to_string(), + snapshot_version: 1, + clicked_at_ms: 10, + }, + ) + .expect("domain click should be confirmed"); + let next = snapshot_from_domain(&confirmation.run, 10); + + assert!(confirmation.accepted); + assert_eq!(confirmation.cleared_item_instance_ids.len(), 3); + assert!( + next + .tray_slots + .iter() + .all(|slot| slot.item_instance_id.is_none()) + ); + assert!( + next + .items + .iter() + .all(|item| item.state == MATCH3D_ITEM_CLEARED) + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/match3d/tables.rs b/server-rs/crates/spacetime-module/src/match3d/tables.rs new file mode 100644 index 00000000..2c9ece38 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/match3d/tables.rs @@ -0,0 +1,86 @@ +use crate::*; + +#[spacetimedb::table( + accessor = match3d_agent_session, + index(accessor = by_match3d_agent_session_owner_user_id, btree(columns = [owner_user_id])) +)] +pub struct Match3DAgentSessionRow { + #[primary_key] + pub(crate) session_id: String, + pub(crate) owner_user_id: String, + pub(crate) seed_text: String, + pub(crate) current_turn: u32, + pub(crate) progress_percent: u32, + pub(crate) stage: String, + pub(crate) config_json: String, + pub(crate) draft_json: String, + pub(crate) last_assistant_reply: String, + pub(crate) published_profile_id: String, + pub(crate) created_at: Timestamp, + pub(crate) updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = match3d_agent_message, + index(accessor = by_match3d_agent_message_session_id, btree(columns = [session_id])) +)] +pub struct Match3DAgentMessageRow { + #[primary_key] + pub(crate) message_id: String, + pub(crate) session_id: String, + pub(crate) role: String, + pub(crate) kind: String, + pub(crate) text: String, + pub(crate) created_at: Timestamp, +} + +#[spacetimedb::table( + accessor = match3d_work_profile, + index(accessor = by_match3d_work_owner_user_id, btree(columns = [owner_user_id])), + index(accessor = by_match3d_work_publication_status, btree(columns = [publication_status])) +)] +pub struct Match3DWorkProfileRow { + #[primary_key] + pub(crate) profile_id: String, + pub(crate) owner_user_id: String, + pub(crate) source_session_id: String, + pub(crate) author_display_name: String, + pub(crate) game_name: String, + pub(crate) theme_text: String, + pub(crate) summary_text: String, + pub(crate) tags_json: String, + pub(crate) cover_image_src: String, + pub(crate) cover_asset_id: String, + pub(crate) clear_count: u32, + pub(crate) difficulty: u32, + pub(crate) config_json: String, + pub(crate) publication_status: String, + pub(crate) play_count: u32, + pub(crate) updated_at: Timestamp, + pub(crate) published_at: Option, +} + +#[spacetimedb::table( + accessor = match3d_runtime_run, + index(accessor = by_match3d_run_owner_user_id, btree(columns = [owner_user_id])), + index(accessor = by_match3d_run_profile_id, btree(columns = [profile_id])) +)] +pub struct Match3DRuntimeRunRow { + #[primary_key] + pub(crate) run_id: String, + pub(crate) owner_user_id: String, + pub(crate) profile_id: String, + pub(crate) status: String, + pub(crate) snapshot_version: u32, + pub(crate) started_at_ms: i64, + pub(crate) duration_limit_ms: i64, + pub(crate) finished_at_ms: i64, + pub(crate) elapsed_ms: i64, + pub(crate) clear_count: u32, + pub(crate) total_item_count: u32, + pub(crate) cleared_item_count: u32, + pub(crate) failure_reason: String, + pub(crate) snapshot_json: String, + pub(crate) created_at: Timestamp, + pub(crate) updated_at: Timestamp, +} diff --git a/server-rs/crates/spacetime-module/src/match3d/types.rs b/server-rs/crates/spacetime-module/src/match3d/types.rs new file mode 100644 index 00000000..17d6dbf2 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/match3d/types.rs @@ -0,0 +1,332 @@ +use crate::*; +use serde::{Deserialize, Serialize}; + +pub const MATCH3D_DEFAULT_DURATION_LIMIT_MS: i64 = 600_000; +pub const MATCH3D_TRAY_SLOT_COUNT: u32 = 7; +pub const MATCH3D_VISUAL_VARIANT_COUNT: u32 = 10; +pub const MATCH3D_MIN_DIFFICULTY: u32 = 1; +pub const MATCH3D_MAX_DIFFICULTY: u32 = 10; + +pub const MATCH3D_STAGE_COLLECTING: &str = "Collecting"; +pub const MATCH3D_STAGE_READY_TO_COMPILE: &str = "ReadyToCompile"; +pub const MATCH3D_STAGE_DRAFT_COMPILED: &str = "DraftCompiled"; +pub const MATCH3D_STAGE_PUBLISHED: &str = "Published"; + +pub const MATCH3D_ROLE_USER: &str = "user"; +pub const MATCH3D_ROLE_ASSISTANT: &str = "assistant"; +pub const MATCH3D_KIND_TEXT: &str = "text"; + +pub const MATCH3D_PUBLICATION_DRAFT: &str = "Draft"; +pub const MATCH3D_PUBLICATION_PUBLISHED: &str = "Published"; + +pub const MATCH3D_RUN_RUNNING: &str = "Running"; +pub const MATCH3D_RUN_WON: &str = "Won"; +pub const MATCH3D_RUN_FAILED: &str = "Failed"; +pub const MATCH3D_RUN_STOPPED: &str = "Stopped"; + +pub const MATCH3D_FAILURE_TIME_UP: &str = "TimeUp"; +pub const MATCH3D_FAILURE_TRAY_FULL: &str = "TrayFull"; + +pub const MATCH3D_CLICK_ACCEPTED: &str = "Accepted"; +pub const MATCH3D_CLICK_REJECTED_NOT_CLICKABLE: &str = "RejectedNotClickable"; +pub const MATCH3D_CLICK_REJECTED_ALREADY_MOVED: &str = "RejectedAlreadyMoved"; +pub const MATCH3D_CLICK_REJECTED_TRAY_FULL: &str = "RejectedTrayFull"; +pub const MATCH3D_CLICK_VERSION_CONFLICT: &str = "VersionConflict"; +pub const MATCH3D_CLICK_RUN_FINISHED: &str = "RunFinished"; + +pub const MATCH3D_ITEM_IN_BOARD: &str = "InBoard"; +pub const MATCH3D_ITEM_IN_TRAY: &str = "InTray"; +pub const MATCH3D_ITEM_CLEARED: &str = "Cleared"; + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DAgentSessionCreateInput { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub welcome_message_id: String, + pub welcome_message_text: String, + pub config_json: Option, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DAgentSessionGetInput { + pub session_id: String, + pub owner_user_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DAgentMessageSubmitInput { + pub session_id: String, + pub owner_user_id: String, + pub user_message_id: String, + pub user_message_text: String, + pub submitted_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DAgentMessageFinalizeInput { + pub session_id: String, + pub owner_user_id: String, + pub assistant_message_id: Option, + pub assistant_reply_text: Option, + pub config_json: Option, + pub progress_percent: u32, + pub stage: String, + pub updated_at_micros: i64, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DDraftCompileInput { + pub session_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub author_display_name: String, + pub game_name: Option, + pub summary_text: Option, + pub tags_json: Option, + pub cover_image_src: Option, + pub cover_asset_id: Option, + pub compiled_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DWorkUpdateInput { + pub profile_id: String, + pub owner_user_id: String, + pub game_name: String, + pub theme_text: String, + pub summary_text: String, + pub tags_json: String, + pub cover_image_src: String, + pub cover_asset_id: String, + pub clear_count: u32, + pub difficulty: u32, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DWorkPublishInput { + pub profile_id: String, + pub owner_user_id: String, + pub published_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DWorksListInput { + pub owner_user_id: String, + pub published_only: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DWorkGetInput { + pub profile_id: String, + pub owner_user_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DWorkDeleteInput { + pub profile_id: String, + pub owner_user_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DRunStartInput { + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub started_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DRunGetInput { + pub run_id: String, + pub owner_user_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DRunClickInput { + pub run_id: String, + pub owner_user_id: String, + pub item_instance_id: String, + pub client_snapshot_version: u32, + pub client_event_id: String, + pub clicked_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DRunStopInput { + pub run_id: String, + pub owner_user_id: String, + pub stopped_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DRunRestartInput { + pub source_run_id: String, + pub next_run_id: String, + pub owner_user_id: String, + pub restarted_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DRunTimeUpInput { + pub run_id: String, + pub owner_user_id: String, + pub finished_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DAgentSessionProcedureResult { + pub ok: bool, + pub session_json: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DWorkProcedureResult { + pub ok: bool, + pub work_json: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DWorksProcedureResult { + pub ok: bool, + pub items_json: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DRunProcedureResult { + pub ok: bool, + pub run_json: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct Match3DClickItemProcedureResult { + pub ok: bool, + pub status: String, + pub run_json: Option, + pub accepted_item_instance_id: Option, + pub cleared_item_instance_ids: Vec, + pub failure_reason: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Match3DCreatorConfigSnapshot { + pub theme_text: String, + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Match3DAgentMessageSnapshot { + pub message_id: String, + pub session_id: String, + pub role: String, + pub kind: String, + pub text: String, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Match3DDraftSnapshot { + pub profile_id: String, + pub game_name: String, + pub theme_text: String, + pub summary_text: String, + pub tags: Vec, + pub clear_count: u32, + pub difficulty: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Match3DAgentSessionSnapshot { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub current_turn: u32, + pub progress_percent: u32, + pub stage: String, + pub config: Match3DCreatorConfigSnapshot, + pub draft: Option, + pub messages: Vec, + pub last_assistant_reply: String, + pub published_profile_id: Option, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Match3DWorkSnapshot { + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: String, + pub author_display_name: String, + pub game_name: String, + pub theme_text: String, + pub summary_text: String, + pub tags: Vec, + pub cover_image_src: String, + pub cover_asset_id: String, + pub clear_count: u32, + pub difficulty: u32, + pub config: Match3DCreatorConfigSnapshot, + pub publication_status: String, + pub publish_ready: bool, + pub play_count: u32, + pub updated_at_micros: i64, + pub published_at_micros: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Match3DItemSnapshot { + pub item_instance_id: String, + pub item_type_id: String, + pub visual_key: String, + pub x: f32, + pub y: f32, + pub radius: f32, + pub layer: u32, + pub state: String, + pub clickable: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Match3DTraySlotSnapshot { + pub slot_index: u32, + pub item_instance_id: Option, + pub item_type_id: Option, + pub visual_key: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Match3DRunSnapshot { + pub run_id: String, + pub profile_id: String, + pub status: String, + pub snapshot_version: u32, + pub started_at_ms: i64, + pub duration_limit_ms: i64, + pub server_now_ms: i64, + pub remaining_ms: i64, + pub clear_count: u32, + pub total_item_count: u32, + pub cleared_item_count: u32, + pub tray_slots: Vec, + pub items: Vec, + pub failure_reason: Option, +} diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 2cd8dea2..82f541cd 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -4,6 +4,9 @@ use spacetimedb_lib::sats::de::serde::DeserializeWrapper; use spacetimedb_lib::sats::ser::serde::SerializeWrapper; use std::collections::HashSet; +use crate::match3d::tables::{ + match3d_agent_message, match3d_agent_session, match3d_runtime_run, match3d_work_profile, +}; use crate::puzzle::{ puzzle_agent_message, puzzle_agent_session, puzzle_runtime_run, puzzle_work_profile, }; @@ -187,6 +190,10 @@ macro_rules! migration_tables { puzzle_agent_message, puzzle_work_profile, puzzle_runtime_run, + match3d_agent_session, + match3d_agent_message, + match3d_work_profile, + match3d_runtime_run, big_fish_creation_session, big_fish_agent_message, big_fish_asset_slot diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index bd44dfe5..38be4b6c 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -1261,8 +1261,9 @@ fn start_puzzle_run_tx( } let entry_profile = build_puzzle_work_profile_from_row(&entry_profile_row)?; let started_at_ms = micros_to_millis(input.started_at_micros); - let mut run = module_puzzle::start_run_at(input.run_id.clone(), &entry_profile, 0, started_at_ms) - .map_err(|error| error.to_string())?; + let mut run = + module_puzzle::start_run_at(input.run_id.clone(), &entry_profile, 0, started_at_ms) + .map_err(|error| error.to_string())?; let current_grid_size = run.current_grid_size; let current_profile_id = entry_profile.profile_id.clone(); hydrate_puzzle_leaderboard_entries( @@ -1502,13 +1503,11 @@ fn use_puzzle_runtime_prop_tx( let row = get_owned_run_row(ctx, &input.run_id, &input.owner_user_id)?; let current_run = deserialize_run(&row.snapshot_json)?; let next_run = match input.prop_kind.as_str() { - "freezeTime" | "freeze_time" => { - module_puzzle::apply_puzzle_freeze_time_at( - ¤t_run, - micros_to_millis(input.used_at_micros), - ) - .map_err(|error| error.to_string())? - } + "freezeTime" | "freeze_time" => module_puzzle::apply_puzzle_freeze_time_at( + ¤t_run, + micros_to_millis(input.used_at_micros), + ) + .map_err(|error| error.to_string())?, "hint" => module_puzzle::set_puzzle_run_paused_at( ¤t_run, false, diff --git a/src/Match3DPlaygroundApp.tsx b/src/Match3DPlaygroundApp.tsx new file mode 100644 index 00000000..68a56989 --- /dev/null +++ b/src/Match3DPlaygroundApp.tsx @@ -0,0 +1,61 @@ +import { useCallback, useRef, useState } from 'react'; + +import type { + Match3DClickItemRequest, + Match3DRunSnapshot, +} from '../packages/shared/src/contracts/match3dRuntime'; +import { Match3DRuntimeShell } from './components/match3d-runtime'; +import { + confirmLocalMatch3DClick, + resolveLocalMatch3DTimer, + startLocalMatch3DRun, +} from './services/match3d-runtime'; + +function buildInitialRun() { + return startLocalMatch3DRun(12); +} + +export default function Match3DPlaygroundApp() { + const [run, setRun] = useState(buildInitialRun); + const authorityRunRef = useRef(run); + + const syncRun = useCallback((nextRun: Match3DRunSnapshot) => { + setRun(nextRun); + }, []); + + const handleClickItem = useCallback(async (payload: Match3DClickItemRequest) => { + const result = await confirmLocalMatch3DClick(authorityRunRef.current, payload); + authorityRunRef.current = result.run; + setRun(result.run); + return result; + }, []); + + const handleRestart = useCallback(() => { + const nextRun = buildInitialRun(); + authorityRunRef.current = nextRun; + setRun(nextRun); + }, []); + + const handleExit = useCallback(() => { + window.location.assign('/'); + }, []); + + const handleTimeExpired = useCallback(() => { + const nextRun = resolveLocalMatch3DTimer(authorityRunRef.current); + authorityRunRef.current = nextRun; + setRun(nextRun); + }, []); + + return ( + + ); +} diff --git a/src/components/creation-agent/CreationAgentWorkspace.tsx b/src/components/creation-agent/CreationAgentWorkspace.tsx index 0bb5ec86..45538f73 100644 --- a/src/components/creation-agent/CreationAgentWorkspace.tsx +++ b/src/components/creation-agent/CreationAgentWorkspace.tsx @@ -1,4 +1,4 @@ -import { ArrowLeft, Paperclip, Send, Sparkles } from 'lucide-react'; +import { ArrowLeft, ImagePlus, Paperclip, Send, Sparkles, X } from 'lucide-react'; import type { ChangeEvent } from 'react'; import { useEffect, useRef, useState } from 'react'; @@ -75,15 +75,21 @@ type CreationAgentWorkspaceProps = { isBusy?: boolean; error?: string | null; quickActions?: CreationAgentQuickAction[]; + referenceImagePreviewSrc?: string | null; + referenceImageLabel?: string | null; + referenceImageError?: string | null; onBack: () => void; onSubmitText: (text: string, quickActionKey?: string) => void; onPrimaryAction: () => void; onQuickAction?: (action: CreationAgentQuickAction) => void; + onReferenceImageChange?: (file: File) => Promise | void; + onClearReferenceImage?: () => void; }; const AUTO_SCROLL_FOLLOW_THRESHOLD_PX = 96; const DOCUMENT_INPUT_ACCEPT = '.txt,.md,.markdown,.csv,.json,text/plain,text/markdown,text/csv,application/json'; +const REFERENCE_IMAGE_INPUT_ACCEPT = 'image/png,image/jpeg,image/webp'; function uniqueRecommendedReplies(recommendedReplies: string[] = []) { return [ @@ -290,19 +296,26 @@ export function CreationAgentWorkspace({ isBusy = false, error = null, quickActions = [], + referenceImagePreviewSrc = null, + referenceImageLabel = null, + referenceImageError = null, onBack, onSubmitText, onPrimaryAction, onQuickAction, + onReferenceImageChange, + onClearReferenceImage, }: CreationAgentWorkspaceProps) { const [draftText, setDraftText] = useState(''); const [documentInputError, setDocumentInputError] = useState( null, ); const [isParsingDocumentInput, setIsParsingDocumentInput] = useState(false); + const [isReadingReferenceImage, setIsReadingReferenceImage] = useState(false); // 统一聊天区只在用户仍停留在底部附近时跟随新内容,避免流式回复持续抢走阅读位置。 const messageListRef = useRef(null); const documentInputRef = useRef(null); + const referenceImageInputRef = useRef(null); const shouldAutoScrollRef = useRef(true); useEffect(() => { @@ -376,7 +389,7 @@ export function CreationAgentWorkspace({ const submit = () => { const text = draftText.trim(); - if (!text || isBusy || isParsingDocumentInput) { + if (!text || isBusy || isParsingDocumentInput || isReadingReferenceImage) { return; } @@ -399,6 +412,10 @@ export function CreationAgentWorkspace({ documentInputRef.current?.click(); }; + const openReferenceImagePicker = () => { + referenceImageInputRef.current?.click(); + }; + const handleDocumentInputChange = async ( event: ChangeEvent, ) => { @@ -426,6 +443,25 @@ export function CreationAgentWorkspace({ } }; + const handleReferenceImageInputChange = async ( + event: ChangeEvent, + ) => { + const file = event.target.files?.[0] ?? null; + event.target.value = ''; + + if (!file || isBusy || isReadingReferenceImage || !onReferenceImageChange) { + return; + } + + setIsReadingReferenceImage(true); + + try { + await onReferenceImageChange(file); + } finally { + setIsReadingReferenceImage(false); + } + }; + return (
- {documentInputError || error ? ( + {referenceImagePreviewSrc ? ( +
+
+ 参考图 +
+
+ {referenceImageLabel || '已选择参考图'} +
+ {onClearReferenceImage ? ( + + ) : null} +
+ ) : null} + + {documentInputError || referenceImageError || error ? (
- {documentInputError || error} + {documentInputError || referenceImageError || error}
) : null} @@ -560,6 +623,15 @@ export function CreationAgentWorkspace({ className="hidden" onChange={handleDocumentInputChange} /> + {onReferenceImageChange ? ( + + ) : null} + {onReferenceImageChange ? ( + + ) : null}