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_CENTER: f32 = 0.5; pub const MATCH3D_BOARD_RADIUS: f32 = 0.5; pub const MATCH3D_BOARD_SAFE_MARGIN: f32 = 0.035; // 中文注释:首版 demo 不接真实图片生成,但水果题材必须先给出可辨认的水果内置视觉键。 const MATCH3D_FRUIT_VISUAL_KEYS: [&str; 10] = [ "watermelon-green", "apple-red", "banana-yellow", "grape-purple", "melon-green", "berry-blue", "peach-pink", "plum-indigo", "lime-lime", "orange-orange", ]; // 中文注释:非水果题材使用颜色形状兜底 key;前端必须逐个渲染,不能统一兜成同一图案。 const MATCH3D_SHAPE_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, /// 领域内部权威快照版本;HTTP DTO 对外映射为 snapshotVersion。 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 mut draft = 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: 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_result_publish_fields(draft); 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 } /// 将结果草稿转换为可保存的作品 profile,实际持久化由 SpacetimeDB 分支负责。 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) } /// 用确定性 seed 生成单局初始快照,便于后端权威复现和测试。 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, &config.theme_text, ), 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 } /// 以 2D 圆形近似判断遮挡:完全被更高层物品覆盖的物品不可点击。 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, theme_text: &str, ) -> Vec { let mut rng = DeterministicRng::new(seed ^ ((clear_count as u64) << 32) ^ difficulty as u64); let base_radius = resolve_item_radius(difficulty); let visual_keys = visual_keys_for_theme(theme_text); 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) % visual_keys.len(); let item_type_id = format!("match3d-type-{:02}", visual_index + 1); let visual_key = visual_keys[visual_index].to_string(); for copy_index in 0..MATCH3D_ITEMS_PER_CLEAR { let radius = resolve_item_radius_variant(base_radius, &visual_key, visual_index, copy_index); let (x, y) = random_point_in_circle(&mut rng, max_spawn_offset(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 visual_keys_for_theme(theme_text: &str) -> &'static [&'static str; 10] { if is_fruit_theme(theme_text) { &MATCH3D_FRUIT_VISUAL_KEYS } else { &MATCH3D_SHAPE_VISUAL_KEYS } } fn is_fruit_theme(theme_text: &str) -> bool { let normalized = theme_text.trim().to_lowercase(); [ "水果", "果蔬", "果物", "fruit", "fruits", "苹果", "香蕉", "葡萄", "西瓜", "草莓", "桃", "李", "柠", "橙", "梨", ] .iter() .any(|marker| normalized.contains(marker)) } 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 resolve_item_radius_variant( base_radius: f32, visual_key: &str, visual_index: usize, copy_index: u32, ) -> f32 { let copy_delta = (copy_index as f32 - 1.0) * 0.002; if is_fruit_visual_key(visual_key) { return (base_radius * fruit_visual_size_scale(visual_key) + copy_delta).clamp(0.04, 0.13); } let type_delta = ((visual_index % 5) as f32 - 2.0) * 0.004; (base_radius + type_delta + copy_delta).clamp(0.045, 0.12) } fn is_fruit_visual_key(visual_key: &str) -> bool { matches!( visual_key, "watermelon-green" | "apple-red" | "banana-yellow" | "grape-purple" | "melon-green" | "berry-blue" | "peach-pink" | "plum-indigo" | "lime-lime" | "orange-orange" | "pear-cyan" ) } fn fruit_visual_size_scale(visual_key: &str) -> f32 { match visual_key { "watermelon-green" => 1.24, "melon-green" => 1.12, "banana-yellow" => 1.04, "apple-red" | "orange-orange" | "peach-pink" | "pear-cyan" => 1.0, "plum-indigo" | "lime-lime" => 0.86, "grape-purple" | "berry-blue" => 0.78, _ => 1.0, } } fn max_spawn_offset(radius: f32) -> f32 { (MATCH3D_BOARD_RADIUS - MATCH3D_BOARD_SAFE_MARGIN - radius).max(0.0) } 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 (MATCH3D_BOARD_CENTER + x, MATCH3D_BOARD_CENTER + y); } } (MATCH3D_BOARD_CENTER, MATCH3D_BOARD_CENTER) } 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 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(), "经典消除".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 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( "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 initial_run_uses_slightly_different_item_sizes() { let run = start_run_with_seed_at( "run-size".to_string(), "user-1".to_string(), "profile-1".to_string(), &test_config(6), 21, 1_000, ) .expect("run should start"); let mut radii = run .items .iter() .map(|item| (item.radius * 1_000.0).round() as u32) .collect::>(); radii.sort(); radii.dedup(); assert!(radii.len() > 1); } #[test] fn fruit_theme_generates_fruit_visuals_inside_board() { let run = start_run_with_seed_at( "run-fruit".to_string(), "user-1".to_string(), "profile-1".to_string(), &test_config(10), 12, 1_000, ) .expect("run should start"); let visual_keys = run .items .iter() .map(|item| item.visual_key.as_str()) .collect::>(); assert!(visual_keys.contains(&"watermelon-green")); assert!(visual_keys.contains(&"apple-red")); assert!(visual_keys.contains(&"banana-yellow")); assert!(!visual_keys.contains(&"red_circle")); for item in &run.items { let dx = item.x - MATCH3D_BOARD_CENTER; let dy = item.y - MATCH3D_BOARD_CENTER; let distance = (dx * dx + dy * dy).sqrt(); assert!( distance + item.radius <= MATCH3D_BOARD_RADIUS - MATCH3D_BOARD_SAFE_MARGIN + 0.0001, "item {} should stay inside board: x={}, y={}, radius={}", item.item_instance_id, item.x, item.y, item.radius ); } } #[test] fn fruit_theme_uses_common_sense_relative_sizes() { let run = start_run_with_seed_at( "run-fruit-size".to_string(), "user-1".to_string(), "profile-1".to_string(), &test_config(10), 27, 1_000, ) .expect("run should start"); let max_radius_for_visual = |visual_key: &str| { run.items .iter() .filter(|item| item.visual_key == visual_key) .map(|item| item.radius) .fold(0.0, f32::max) }; let watermelon = max_radius_for_visual("watermelon-green"); let apple = max_radius_for_visual("apple-red"); let grape = max_radius_for_visual("grape-purple"); assert!(watermelon > apple); assert!(apple > grape); } #[test] fn non_fruit_theme_generates_shape_visuals() { let config = build_creator_config("玩具", None, 3, 4).expect("config should be valid"); let run = start_run_with_seed_at( "run-shapes".to_string(), "user-1".to_string(), "profile-1".to_string(), &config, 13, 1_000, ) .expect("run should start"); let visual_keys = run .items .iter() .map(|item| item.visual_key.as_str()) .collect::>(); assert!(visual_keys.contains(&"red_circle")); assert!(visual_keys.contains(&"yellow_triangle")); assert!(!visual_keys.contains(&"apple-red")); } #[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); } }