diff --git a/docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md b/docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md index 18d7de4e..317ef0b6 100644 --- a/docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md +++ b/docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md @@ -190,8 +190,8 @@ Rust DTO 只承载 HTTP contract 和跨 crate 稳定模型,不直接暴露 `mo 8. `draft_json: String`,序列化草稿结果。 9. `last_assistant_reply: String`。 10. `published_profile_id: String`,未发布为空字符串。 -11. `created_at: i64`。 -12. `updated_at: i64`。 +11. `created_at: Timestamp`。 +12. `updated_at: Timestamp`。 ## 5.2 `match3d_agent_message` @@ -204,7 +204,7 @@ Rust DTO 只承载 HTTP contract 和跨 crate 稳定模型,不直接暴露 `mo 3. `role: String`,建议值:`user`、`assistant`、`system`。 4. `kind: String`,建议值:`text`、`action`、`error`。 5. `text: String`。 -6. `created_at: i64`。 +6. `created_at: Timestamp`。 ## 5.3 `match3d_work_profile` @@ -227,8 +227,8 @@ Rust DTO 只承载 HTTP contract 和跨 crate 稳定模型,不直接暴露 `mo 13. `config_json: String`。 14. `publication_status: String`,建议值:`Draft`、`Published`。 15. `play_count: u32`。 -16. `updated_at: i64`。 -17. `published_at: i64`,未发布为 `0`。 +16. `updated_at: Timestamp`。 +17. `published_at: Option`,未发布为 `None`。 ## 5.4 `match3d_runtime_run` @@ -250,8 +250,8 @@ Rust DTO 只承载 HTTP contract 和跨 crate 稳定模型,不直接暴露 `mo 12. `cleared_item_count: u32`。 13. `failure_reason: String`,建议值为空、`TimeUp`、`TrayFull`。 14. `snapshot_json: String`,序列化 `Match3DRunSnapshot`。 -15. `created_at: i64`。 -16. `updated_at: i64`。 +15. `created_at: Timestamp`。 +16. `updated_at: Timestamp`。 ## 5.5 `match3d_play_record` @@ -687,6 +687,14 @@ F3 运行态即时反馈分支可以先用本地 mock snapshot 开发,但必 2. 与 `module-match3d` 规则接线。 3. `spacetime build` 或仓库现有等价脚本通过。 +B3 当前落地状态: + +1. `server-rs/crates/spacetime-module/src/match3d/` 已承载 Match3D 的表、procedure 输入输出类型和 procedure 实现,并由 `server-rs/crates/spacetime-module/src/lib.rs` 挂载导出。 +2. `migration.rs` 已纳入 `match3d_agent_session`、`match3d_agent_message`、`match3d_work_profile`、`match3d_runtime_run` 四张表,后续字段变更继续按 `SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md` 追加兼容字段。 +3. 运行态 `start_match3d_run`、`click_match3d_item`、`stop_match3d_run`、`finish_match3d_time_up` 通过适配层调用 `module-match3d` 的领域规则,SpacetimeDB 层只负责归属校验、事务写入、权威快照持久化和 procedure JSON 返回。 +4. B3 对外仍返回当前首版快照字段 `snapshotVersion / clientSnapshotVersion` 对应语义;`module-match3d` 内部的 `board_version` 只在适配层中转换,避免影响并行中的 B4/F3 接入。 +5. SpacetimeDB module 的有效验收命令是 `spacetime build --module-path crates/spacetime-module`;不要用普通 native `cargo test -p spacetime-module` 作为验收口径,因为该 crate 会链接 SpacetimeDB 宿主符号。 + ### F1:创作入口与 Agent UI 写入范围: 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 index 5ac406b4..8d57c6c3 100644 --- a/docs/technical/MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md +++ b/docs/technical/MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md @@ -51,6 +51,8 @@ server-rs/crates/module-match3d 该 crate 是纯领域层,不读写数据库,不访问网络,不依赖浏览器或文件系统。 +本阶段虽然不落 SpacetimeDB 表和 procedure,但领域模型已经为后续 SpacetimeDB 接入预留 `spacetime-types` feature。后续在 `spacetime-module` 内使用这些类型时,仍必须遵守 reducer 确定性、`ctx.sender()` 鉴权和表结构迁移约束。 + 核心类型: 1. `Match3DCreatorConfig` @@ -89,6 +91,8 @@ server-rs/crates/module-match3d `Flying` 只作为前端表现态,不要求后端逐帧落库。后端只确认物品是否已从 `InBoard` 进入 `InTray` 或 `Cleared`。 +运行态领域内部使用 `board_version` 表示权威快照版本;HTTP 与 TypeScript shared contracts 对外使用 `snapshotVersion` / `clientSnapshotVersion`,由后续 `api-server` facade 做字段映射。 + ## 5. 生成规则 Stage1 口径 1. `clearCount` 必须是正整数。 diff --git a/docs/technical/MATCH3D_F1_CREATION_ENTRY_AND_AGENT_UI_2026-04-30.md b/docs/technical/MATCH3D_F1_CREATION_ENTRY_AND_AGENT_UI_2026-04-30.md new file mode 100644 index 00000000..b4adf225 --- /dev/null +++ b/docs/technical/MATCH3D_F1_CREATION_ENTRY_AND_AGENT_UI_2026-04-30.md @@ -0,0 +1,91 @@ +# 抓大鹅 Match3D F1 创作入口与 Agent UI 落地记录 2026-04-30 + +## 1. 阶段边界 + +本文件承接《MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md》的 F1 包。 + +F1 只处理前端创作入口、Agent 工作区和等待后端 B5 facade 前的 mock client。它不实现运行态规则,不修改 SpacetimeDB 表,不接 `api-server` 路由。 + +## 2. 本阶段写入范围 + +1. `src/components/platform-entry/` +2. `src/components/match3d-creation/` +3. `src/services/match3d-creation/` +4. `packages/shared/src/contracts/match3dAgent.ts` + +其中 `packages/shared/src/contracts/match3dAgent.ts` 作为 F1 与后续 B5 的 DTO 对齐点,F1 mock client 不自建脱离共享契约的临时类型。 + +## 3. 入口接入 + +平台入口新增可见创作类型: + +```text +id: match3d +title: 抓大鹅 +subtitle: 经典消除玩法 +badge: 可创建 +``` + +入口来源统一走 `getVisiblePlatformCreationTypes()`,因此创作首页首屏卡带与“选择创作类型”弹层会同时出现抓大鹅。 + +## 4. Agent 工作区 + +新增 `Match3DAgentWorkspace`,复用通用 `CreationAgentWorkspace`。 + +Agent 只收集三类锚点: + +1. 题材主题。 +2. 需要消除次数。 +3. 难度。 + +工作区支持参考图片上传入口。图片在 F1 中先以 Data URL 形式随消息 payload 带给 mock client;B5 接入后由后端 facade 替换为正式资产上传与引用。 + +UI 中不默认展示玩法规则长文,只展示进度、锚点、聊天内容和必要按钮。 + +## 5. mock client + +新增 `src/services/match3d-creation/match3dCreationClient.ts`。 + +mock client 提供: + +1. `createMatch3DCreationSession` +2. `getMatch3DCreationSession` +3. `streamMatch3DCreationMessage` +4. `executeMatch3DCreationAction` + +mock 行为: + +1. 创建本地会话。 +2. 从中文输入中提取题材、消除次数和难度。 +3. 支持“自动配置”。 +4. 当三项配置完整时允许执行 `match3d_compile_draft`。 +5. 编译后返回 `draft_ready` 会话和草稿。 + +## 6. 结果承接 + +F1 新增 `Match3DDraftReadyView` 作为草稿生成后的临时承接页,只展示草稿基础信息并允许返回 Agent 修改。 + +正式结果页的基础信息编辑、封面图、试玩、发布由 F2 接入,F1 不在这里模拟发布。 + +## 7. 后续替换点 + +B5 完成后,只需要把 `match3dCreationClient` 的本地 Map mock 替换为 HTTP/SSE facade: + +```text +POST /api/creation/match3d/sessions +GET /api/creation/match3d/sessions/:sessionId +POST /api/creation/match3d/sessions/:sessionId/messages/stream +POST /api/creation/match3d/sessions/:sessionId/compile +``` + +`PlatformEntryFlowShellImpl` 与 `Match3DAgentWorkspace` 不应再改一轮业务字段。 + +## 8. 验收口径 + +1. 创作首页能看到“抓大鹅 / 经典消除玩法”。 +2. 弹层选择“抓大鹅”能进入 Agent 工作区。 +3. 输入题材、消除次数、难度后进度到 `100%`。 +4. 点击“生成结果页”进入草稿承接页。 +5. 可从草稿承接页返回 Agent 修改。 +6. `npm run check:encoding` 通过。 +7. `npm run typecheck` 通过。 diff --git a/docs/technical/README.md b/docs/technical/README.md index 32109e70..c090e18b 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -7,6 +7,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 的边界。 +- [MATCH3D_F1_CREATION_ENTRY_AND_AGENT_UI_2026-04-30.md](./MATCH3D_F1_CREATION_ENTRY_AND_AGENT_UI_2026-04-30.md):记录抓大鹅 F1 创作入口、Agent 工作区、参考图入口、本地 mock client 与后续 B5 HTTP facade 替换点。 - [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/packages/shared/src/contracts/match3dAgent.ts b/packages/shared/src/contracts/match3dAgent.ts index 924127e4..37ee3667 100644 --- a/packages/shared/src/contracts/match3dAgent.ts +++ b/packages/shared/src/contracts/match3dAgent.ts @@ -1,6 +1,13 @@ +/** + * 抓大鹅 Match3D 创作 Agent 共享契约。 + * 字段按 HTTP facade 的 camelCase DTO 命名,后端领域层 snake_case 字段由 facade 映射。 + */ export type Match3DCreationStage = + | 'collecting' | 'collecting_config' + | 'ready_to_compile' | 'draft_ready' + | 'draft_compiled' | 'ready_to_publish' | 'published' | string; diff --git a/packages/shared/src/contracts/match3dRuntime.ts b/packages/shared/src/contracts/match3dRuntime.ts index 67f70ea6..e613db93 100644 --- a/packages/shared/src/contracts/match3dRuntime.ts +++ b/packages/shared/src/contracts/match3dRuntime.ts @@ -1,3 +1,7 @@ +/** + * 抓大鹅 Match3D 运行态共享契约。 + * 前端可以使用 Flying 做即时表现;后端权威快照只应返回 InBoard、InTray、Cleared。 + */ export type Match3DRunStatus = | 'running' | 'won' diff --git a/packages/shared/src/contracts/match3dWorks.ts b/packages/shared/src/contracts/match3dWorks.ts index 1d5fce5d..1cf23a4b 100644 --- a/packages/shared/src/contracts/match3dWorks.ts +++ b/packages/shared/src/contracts/match3dWorks.ts @@ -1,3 +1,7 @@ +/** + * 抓大鹅 Match3D 作品读写共享契约。 + * 首版作品发布必须补齐游戏名称、标签、封面、题材、消除次数和难度。 + */ export type Match3DWorkPublicationStatus = 'draft' | 'published' | string; export interface PutMatch3DWorkRequest { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 80b647fe..58e603bd 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -3,14 +3,6 @@ export * from './contracts/auth'; export type * from './contracts/bigFish'; export * from './contracts/common'; export type * from './contracts/customWorldAgent'; -export * from './contracts/rpgAgentActions'; -export * from './contracts/rpgAgentAnchors'; -export * from './contracts/rpgAgentDraft'; -export * from './contracts/rpgAgentSession'; -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'; @@ -20,6 +12,14 @@ export * from './contracts/puzzleAgentSession'; export * from './contracts/puzzleResultPreview'; export * from './contracts/puzzleRuntimeSession'; export * from './contracts/puzzleWorkSummary'; +export * from './contracts/rpgAgentActions'; +export * from './contracts/rpgAgentAnchors'; +export * from './contracts/rpgAgentDraft'; +export * from './contracts/rpgAgentSession'; +export * from './contracts/rpgCreationFixtures'; +export * from './contracts/rpgCreationPreview'; +export * from './contracts/rpgCreationResultView'; +export * from './contracts/rpgCreationWorkSummary'; export * from './contracts/rpgRuntimeChat'; export * from './contracts/rpgRuntimeQuestAssist'; export * from './contracts/rpgRuntimeStoryAction'; diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 7d1879b2..feb72263 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -2691,6 +2691,7 @@ dependencies = [ "module-combat", "module-custom-world", "module-inventory", + "module-match3d", "module-npc", "module-progression", "module-puzzle", diff --git a/server-rs/crates/module-match3d/src/lib.rs b/server-rs/crates/module-match3d/src/lib.rs index 8ecb2aaf..2c3f901d 100644 --- a/server-rs/crates/module-match3d/src/lib.rs +++ b/server-rs/crates/module-match3d/src/lib.rs @@ -17,6 +17,7 @@ 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; +// 首版 demo 使用固定 10 组颜色形状 key;后续真实题材素材接入时仍保持 item_type_id 三个一组。 const MATCH3D_DEMO_VISUAL_KEYS: [&str; 10] = [ "red_circle", "yellow_triangle", @@ -163,6 +164,7 @@ pub struct Match3DRunSnapshot { pub clear_count: u32, pub total_item_count: u32, pub cleared_item_count: u32, + /// 领域内部权威快照版本;HTTP DTO 对外映射为 snapshotVersion。 pub board_version: u64, pub items: Vec, pub tray_slots: Vec, @@ -303,6 +305,7 @@ pub fn build_creator_config( }) } +/// 根据已确认的题材、消除次数和难度编译首版结果草稿。 pub fn compile_result_draft(config: &Match3DCreatorConfig) -> Match3DResultDraft { let game_name = format!("{}抓大鹅", config.theme_text); let summary = format!( @@ -310,9 +313,7 @@ pub fn compile_result_draft(config: &Match3DCreatorConfig) -> Match3DResultDraft 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 { + let mut draft = Match3DResultDraft { game_name, theme_text: config.theme_text.clone(), summary, @@ -321,13 +322,18 @@ pub fn compile_result_draft(config: &Match3DCreatorConfig) -> Match3DResultDraft reference_image_src: config.reference_image_src.clone(), clear_count: config.clear_count, difficulty: config.difficulty, - publish_ready: blockers.is_empty(), - blockers, - } + publish_ready: false, + blockers: Vec::new(), + }; + draft.blockers = validate_result_publish_fields(&draft); + draft.publish_ready = draft.blockers.is_empty(); + + draft } +/// 校验发布所需基础字段;试玩通关不是首版发布门槛。 pub fn validate_publish_requirements(draft: &Match3DResultDraft) -> Vec { - let mut blockers = validate_basic_publish_fields(&draft.game_name, &draft.summary, &draft.tags); + let mut blockers = validate_result_publish_fields(draft); if draft.clear_count == 0 { blockers.push("需要消除次数必须为正整数".to_string()); } @@ -337,6 +343,7 @@ pub fn validate_publish_requirements(draft: &Match3DResultDraft) -> Vec blockers } +/// 将结果草稿转换为可保存的作品 profile,实际持久化由 SpacetimeDB 分支负责。 pub fn create_work_profile( work_id: String, profile_id: String, @@ -371,6 +378,7 @@ pub fn create_work_profile( }) } +/// 发布作品时只改变发布状态和时间戳,不在领域层写数据库。 pub fn publish_work_profile( profile: &Match3DWorkProfile, published_at_micros: i64, @@ -389,6 +397,7 @@ pub fn publish_work_profile( Ok(next) } +/// 用确定性 seed 生成单局初始快照,便于后端权威复现和测试。 pub fn start_run_with_seed_at( run_id: String, owner_user_id: String, @@ -428,6 +437,7 @@ pub fn start_run_with_seed_at( Ok(run) } +/// 后端权威确认一次点击:校验版本、可点击性、入槽、三消和胜负。 pub fn confirm_click_at( run: &Match3DRunSnapshot, input: &Match3DClickInput, @@ -502,6 +512,7 @@ pub fn confirm_click_at( }) } +/// 根据权威时间刷新剩余时间;前端本地倒计时归零后仍需走后端确认。 pub fn resolve_run_timer_at(run: &Match3DRunSnapshot, now_ms: u64) -> Match3DRunSnapshot { let mut next = run.clone(); if next.status != Match3DRunStatus::Running { @@ -517,6 +528,7 @@ pub fn resolve_run_timer_at(run: &Match3DRunSnapshot, now_ms: u64) -> Match3DRun next } +/// 停止当前运行态,用于试玩或玩家主动退出。 pub fn stop_run_at(run: &Match3DRunSnapshot, stopped_action_id: String) -> Match3DRunSnapshot { let mut next = run.clone(); if next.status == Match3DRunStatus::Running { @@ -527,6 +539,7 @@ pub fn stop_run_at(run: &Match3DRunSnapshot, stopped_action_id: String) -> Match next } +/// 以 2D 圆形近似判断遮挡:完全被更高层物品覆盖的物品不可点击。 pub fn refresh_clickable_flags(run: &mut Match3DRunSnapshot) { let board_items = run .items @@ -761,6 +774,19 @@ fn validate_basic_publish_fields(game_name: &str, summary: &str, tags: &[String] blockers } +fn validate_result_publish_fields(draft: &Match3DResultDraft) -> Vec { + let mut blockers = validate_basic_publish_fields(&draft.game_name, &draft.summary, &draft.tags); + if draft + .cover_image_src + .as_deref() + .and_then(normalize_required_string) + .is_none() + { + blockers.push("封面图不能为空".to_string()); + } + blockers +} + fn default_tags_for_theme(theme_text: &str) -> Vec { let mut tags = vec![ "抓大鹅".to_string(), @@ -831,6 +857,17 @@ mod tests { assert_eq!(error, Match3DFieldError::InvalidClearCount); } + #[test] + fn draft_requires_cover_before_publish() { + let mut draft = compile_result_draft(&test_config(2)); + + assert!(!draft.publish_ready); + assert!(draft.blockers.contains(&"封面图不能为空".to_string())); + + draft.cover_image_src = Some("https://example.com/cover.png".to_string()); + assert!(validate_publish_requirements(&draft).is_empty()); + } + #[test] fn initial_run_generates_triples() { let run = start_run_with_seed_at( diff --git a/server-rs/crates/shared-contracts/src/match3d_runtime.rs b/server-rs/crates/shared-contracts/src/match3d_runtime.rs index fb088e95..46952cf2 100644 --- a/server-rs/crates/shared-contracts/src/match3d_runtime.rs +++ b/server-rs/crates/shared-contracts/src/match3d_runtime.rs @@ -9,9 +9,12 @@ pub struct StartMatch3DRunRequest { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ClickMatch3DItemRequest { + #[serde(default)] + pub run_id: Option, pub item_instance_id: String, - pub client_action_id: String, - pub snapshot_version: u64, + pub client_snapshot_version: u64, + pub client_event_id: String, + pub clicked_at_ms: u64, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -55,13 +58,16 @@ pub struct Match3DRunSnapshotResponse { pub profile_id: String, pub owner_user_id: String, pub status: String, + /// 对外 HTTP 快照版本。领域层内部字段名为 board_version,facade 需要在这里完成映射。 + pub snapshot_version: u64, pub started_at_ms: u64, pub duration_limit_ms: u64, + #[serde(default)] + pub server_now_ms: Option, 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)] @@ -102,14 +108,18 @@ mod tests { #[test] fn click_match3d_item_request_uses_camel_case() { let payload = serde_json::to_value(ClickMatch3DItemRequest { + run_id: Some("run-1".to_string()), item_instance_id: "item-1".to_string(), - client_action_id: "action-1".to_string(), - snapshot_version: 7, + client_snapshot_version: 7, + client_event_id: "event-1".to_string(), + clicked_at_ms: 12_345, }) .expect("payload should serialize"); + assert_eq!(payload["runId"], json!("run-1")); assert_eq!(payload["itemInstanceId"], json!("item-1")); - assert_eq!(payload["clientActionId"], json!("action-1")); - assert_eq!(payload["snapshotVersion"], json!(7)); + assert_eq!(payload["clientSnapshotVersion"], json!(7)); + assert_eq!(payload["clientEventId"], json!("event-1")); + assert_eq!(payload["clickedAtMs"], json!(12_345)); } } diff --git a/server-rs/crates/spacetime-module/src/match3d/mod.rs b/server-rs/crates/spacetime-module/src/match3d/mod.rs index 40a4f463..2d49ddf0 100644 --- a/server-rs/crates/spacetime-module/src/match3d/mod.rs +++ b/server-rs/crates/spacetime-module/src/match3d/mod.rs @@ -775,9 +775,7 @@ fn click_match3d_item_tx( Ok(click_result( status, next, - confirmation - .accepted - .then_some(input.item_instance_id), + confirmation.accepted.then_some(input.item_instance_id), confirmation.cleared_item_instance_ids, )) } @@ -790,6 +788,7 @@ fn stop_match3d_run_tx( 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 = resolve_domain_run_timer_at(&domain_run, to_u64_ms(stopped_at_ms)); 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); @@ -966,24 +965,22 @@ fn build_initial_run_snapshot( 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, - } + .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) } @@ -1256,10 +1253,7 @@ fn domain_config_from_snapshot( ) } -fn snapshot_from_domain( - run: &DomainMatch3DRunSnapshot, - server_now_ms: i64, -) -> Match3DRunSnapshot { +fn snapshot_from_domain(run: &DomainMatch3DRunSnapshot, server_now_ms: i64) -> Match3DRunSnapshot { Match3DRunSnapshot { run_id: run.run_id.clone(), profile_id: run.profile_id.clone(), @@ -1278,7 +1272,10 @@ fn snapshot_from_domain( .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), + failure_reason: run + .failure_reason + .map(domain_failure_to_text) + .map(str::to_string), } } @@ -1298,7 +1295,11 @@ fn domain_snapshot_from_snapshot( 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(), + items: snapshot + .items + .iter() + .map(domain_item_from_snapshot) + .collect(), tray_slots: snapshot .tray_slots .iter() @@ -1627,14 +1628,12 @@ mod tests { assert!(confirmation.accepted); assert_eq!(confirmation.cleared_item_instance_ids.len(), 3); assert!( - next - .tray_slots + next.tray_slots .iter() .all(|slot| slot.item_instance_id.is_none()) ); assert!( - next - .items + next.items .iter() .all(|item| item.state == MATCH3D_ITEM_CLEARED) ); diff --git a/src/components/match3d-creation/Match3DAgentWorkspace.tsx b/src/components/match3d-creation/Match3DAgentWorkspace.tsx index d04781b2..0c97e905 100644 --- a/src/components/match3d-creation/Match3DAgentWorkspace.tsx +++ b/src/components/match3d-creation/Match3DAgentWorkspace.tsx @@ -2,8 +2,8 @@ import { useState } from 'react'; import type { ExecuteMatch3DActionRequest, - Match3DAnchorItemResponse, Match3DAgentSessionSnapshot, + Match3DAnchorItemResponse, SendMatch3DMessageRequest, } from '../../../packages/shared/src/contracts/match3dAgent'; import { diff --git a/src/components/match3d-creation/Match3DDraftReadyView.tsx b/src/components/match3d-creation/Match3DDraftReadyView.tsx index f4cc5de6..c4463457 100644 --- a/src/components/match3d-creation/Match3DDraftReadyView.tsx +++ b/src/components/match3d-creation/Match3DDraftReadyView.tsx @@ -60,10 +60,10 @@ export function Match3DDraftReadyView({
- 消除 + 物品
- {draft.clearCount} 次 + {draft.totalItemCount ?? draft.clearCount * 3} 件
diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 3b09bd56..b8a07043 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -1264,9 +1264,13 @@ export function PlatformEntryFlowShellImpl({ const match3dSession = match3dFlow.session; const match3dError = match3dFlow.error; + const setMatch3DSession = match3dFlow.setSession; + const setMatch3DError = match3dFlow.setError; const isMatch3DBusy = match3dFlow.isBusy; const streamingMatch3DReplyText = match3dFlow.streamingReplyText; + const setStreamingMatch3DReplyText = match3dFlow.setStreamingReplyText; const isStreamingMatch3DReply = match3dFlow.isStreamingReply; + const setIsStreamingMatch3DReply = match3dFlow.setIsStreamingReply; const puzzleSession = puzzleFlow.session; const puzzleError = puzzleFlow.error; @@ -1292,12 +1296,18 @@ export function PlatformEntryFlowShellImpl({ }, [bigFishFlow]); const openMatch3DAgentWorkspace = useCallback(async () => { - match3dFlow.setSession(null); - match3dFlow.setError(null); - match3dFlow.setStreamingReplyText(''); - match3dFlow.setIsStreamingReply(false); + setMatch3DSession(null); + setMatch3DError(null); + setStreamingMatch3DReplyText(''); + setIsStreamingMatch3DReply(false); await match3dFlow.openWorkspace(); - }, [match3dFlow]); + }, [ + match3dFlow, + setIsStreamingMatch3DReply, + setMatch3DError, + setMatch3DSession, + setStreamingMatch3DReplyText, + ]); const openPuzzleAgentWorkspace = useCallback(async () => { setPuzzleRun(null); @@ -1356,10 +1366,10 @@ export function PlatformEntryFlowShellImpl({ setBigFishRuntimeReturnStage('platform'); setBigFishGenerationState(null); setBigFishError(null); - match3dFlow.setSession(null); - match3dFlow.setError(null); - match3dFlow.setStreamingReplyText(''); - match3dFlow.setIsStreamingReply(false); + setMatch3DSession(null); + setMatch3DError(null); + setStreamingMatch3DReplyText(''); + setIsStreamingMatch3DReply(false); setPuzzleOperation(null); setPuzzleWorks([]); setSelectedPuzzleDetail(null); @@ -1388,17 +1398,20 @@ export function PlatformEntryFlowShellImpl({ } }, [ authUi?.user, - match3dFlow, platformBootstrap.canReadProtectedData, persistRpgAgentUiState, resetAutoSaveTrackingToIdle, resetRpgSessionViewState, selectionStage, setBigFishError, + setIsStreamingMatch3DReply, + setMatch3DError, + setMatch3DSession, setPuzzleError, setRpgCustomWorldError, setRpgGeneratedCustomWorldProfile, setSelectionStage, + setStreamingMatch3DReplyText, ]); const handleCreationHubCreateType = useCallback( @@ -1492,6 +1505,14 @@ export function PlatformEntryFlowShellImpl({ } }, [bigFishRun, bigFishSession, selectionStage, setSelectionStage]); + useEffect(() => { + if (selectionStage === 'match3d-result' && !match3dSession?.draft) { + setSelectionStage( + match3dSession ? 'match3d-agent-workspace' : 'platform', + ); + } + }, [match3dSession, selectionStage, setSelectionStage]); + const startBigFishRun = useCallback(() => { if (!bigFishSession) { return; @@ -2897,11 +2918,13 @@ export function PlatformEntryFlowShellImpl({ : (platformBootstrap.platformError ?? sessionController.agentWorkspaceRestoreError ?? bigFishError ?? + match3dError ?? puzzleError) } onRetry={() => { platformBootstrap.setPlatformError(null); setBigFishError(null); + setMatch3DError(null); setPuzzleError(null); void platformBootstrap.refreshCustomWorldWorks().catch((error) => { platformBootstrap.setPlatformError( @@ -2914,11 +2937,15 @@ export function PlatformEntryFlowShellImpl({ void refreshPuzzleShelf(); }} createError={ - sessionController.creationTypeError ?? bigFishError ?? puzzleError + sessionController.creationTypeError ?? + bigFishError ?? + match3dError ?? + puzzleError } createBusy={ sessionController.isCreatingAgentSession || isBigFishBusy || + isMatch3DBusy || isPuzzleBusy } onCreateType={handleCreationHubCreateType} @@ -3078,7 +3105,12 @@ export function PlatformEntryFlowShellImpl({ { setPublicWorkDetailError(null); diff --git a/src/services/match3d-creation/match3dCreationClient.ts b/src/services/match3d-creation/match3dCreationClient.ts index 20c44999..054b09e5 100644 --- a/src/services/match3d-creation/match3dCreationClient.ts +++ b/src/services/match3d-creation/match3dCreationClient.ts @@ -2,9 +2,9 @@ import type { CreateMatch3DSessionRequest, ExecuteMatch3DActionRequest, Match3DActionResponse, - Match3DAnchorItemResponse, Match3DAgentMessageResponse, Match3DAgentSessionSnapshot, + Match3DAnchorItemResponse, Match3DCreatorConfig, Match3DSessionResponse, SendMatch3DMessageRequest, @@ -24,7 +24,7 @@ let match3dSessionCounter = 0; const mockSessions = new Map(); function delay(ms = MOCK_RESPONSE_DELAY_MS) { - return new Promise((resolve) => window.setTimeout(resolve, ms)); + return new Promise((resolve) => globalThis.setTimeout(resolve, ms)); } function nowIso() { @@ -189,7 +189,7 @@ function updateSessionConfig( return { ...session, progressPercent, - stage: progressPercent >= 100 ? 'ready_to_compile' : 'collecting', + stage: 'collecting_config', anchorPack: buildAnchorPack(partialConfig), config, updatedAt: nowIso(), @@ -237,7 +237,7 @@ export async function createMatch3DCreationSession( sessionId, currentTurn: 0, progressPercent: 0, - stage: 'collecting', + stage: 'collecting_config', anchorPack: buildAnchorPack(partialConfig), config: null, draft: null, @@ -271,7 +271,7 @@ export async function streamMatch3DCreationMessage( await delay(120); const session = ensureMockSession(sessionId); const text = payload.text.trim(); - const currentConfig = session.config ?? { + const currentConfig: Partial = session.config ?? { themeText: session.anchorPack.theme.value, clearCount: Number(session.anchorPack.clearCount.value) || undefined, difficulty: Number(session.anchorPack.difficulty.value) || undefined, @@ -337,7 +337,7 @@ export async function executeMatch3DCreationAction( const nextSession = { ...session, - stage: 'draft_compiled', + stage: 'draft_ready', progressPercent: 100, config, draft: buildDraft(config),