use shared_kernel::{normalize_optional_string, normalize_required_string, normalize_string_list}; use crate::commands::{default_tags_for_theme, validate_result_publish_fields}; use crate::{ MATCH3D_BLOCK_VISUAL_KEYS, MATCH3D_BOARD_CENTER, MATCH3D_BOARD_RADIUS, MATCH3D_BOARD_SAFE_MARGIN, MATCH3D_DEFAULT_DURATION_LIMIT_MS, MATCH3D_ITEMS_PER_CLEAR, MATCH3D_MAX_DIFFICULTY, MATCH3D_MAX_ITEM_TYPE_COUNT, MATCH3D_MIN_DIFFICULTY, MATCH3D_TRAY_SLOT_COUNT, Match3DClickConfirmation, Match3DClickInput, Match3DClickRejectReason, Match3DCreatorConfig, Match3DFailureReason, Match3DFieldError, Match3DItemSnapshot, Match3DItemState, Match3DPublicationStatus, Match3DResultDraft, Match3DRunSnapshot, Match3DRunStatus, Match3DTraySlot, Match3DWorkProfile, }; #[derive(Clone, Copy)] struct Match3DSizeTierRule { ratio: f32, radius_scale: f32, relative_volume: f32, tier: &'static str, } const MATCH3D_SIZE_TIER_RULES: [Match3DSizeTierRule; 5] = [ Match3DSizeTierRule { tier: "XL", ratio: 0.20, relative_volume: 1.86, radius_scale: 1.23, }, Match3DSizeTierRule { tier: "L", ratio: 0.30, relative_volume: 1.40, radius_scale: 1.12, }, Match3DSizeTierRule { tier: "M", ratio: 0.30, relative_volume: 1.00, radius_scale: 1.00, }, Match3DSizeTierRule { tier: "XS", ratio: 0.15, relative_volume: 0.73, radius_scale: 0.90, }, Match3DSizeTierRule { tier: "S", ratio: 0.05, relative_volume: 0.44, radius_scale: 0.76, }, ]; 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 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 selected_visual_keys = select_visual_keys(&mut rng, theme_text, clear_count); let item_type_count = resolve_item_type_count(clear_count); let size_tier_plan = resolve_size_tier_plan(item_type_count); 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) % item_type_count; let item_type_id = format!("match3d-type-{:02}", visual_index + 1); let visual_key = selected_visual_keys[visual_index].to_string(); let radius = resolve_item_radius_variant(base_radius, size_tier_plan[visual_index]); for copy_index in 0..MATCH3D_ITEMS_PER_CLEAR { 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 resolve_size_tier_plan(item_type_count: usize) -> Vec { let mut plans = MATCH3D_SIZE_TIER_RULES .iter() .map(|rule| { let exact_count = item_type_count as f32 * rule.ratio; (exact_count.floor() as usize, exact_count.fract(), *rule) }) .collect::>(); let mut assigned_count = plans .iter() .map(|(count, _, _)| *count) .sum::(); let mut remainder_order = (0..plans.len()).collect::>(); remainder_order.sort_by(|left, right| { plans[*right] .1 .partial_cmp(&plans[*left].1) .unwrap_or(std::cmp::Ordering::Equal) }); let mut cursor = 0; while assigned_count < item_type_count { let plan_index = remainder_order[cursor % remainder_order.len()]; plans[plan_index].0 += 1; assigned_count += 1; cursor += 1; } plans .into_iter() .flat_map(|(count, _, rule)| std::iter::repeat(rule).take(count)) .collect() } fn resolve_item_type_count(clear_count: u32) -> usize { clear_count.clamp(1, MATCH3D_MAX_ITEM_TYPE_COUNT) as usize } fn select_visual_keys( rng: &mut DeterministicRng, _theme_text: &str, clear_count: u32, ) -> Vec<&'static str> { let item_type_count = resolve_item_type_count(clear_count); let mut visual_keys = MATCH3D_BLOCK_VISUAL_KEYS.to_vec(); // 中文注释:只打乱类型池顺序,不改变每个类型三件一组的可通关结构。 for index in (1..visual_keys.len()).rev() { let swap_index = (rng.next_u32() as usize) % (index + 1); visual_keys.swap(index, swap_index); } visual_keys.truncate(item_type_count); visual_keys } 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, size_tier: Match3DSizeTierRule) -> f32 { debug_assert!(!size_tier.tier.is_empty()); debug_assert!(size_tier.relative_volume > 0.0); (base_radius * size_tier.radius_scale).clamp(0.045, 0.13) } 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, } } 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::*; use crate::commands::{build_creator_config, validate_publish_requirements}; 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 item_type_count_follows_clear_count_until_twenty_five() { let run = start_run_with_seed_at( "run-types-small".to_string(), "user-1".to_string(), "profile-1".to_string(), &test_config(12), 42, 1_000, ) .expect("run should start"); let mut counts = BTreeMap::::new(); for item in &run.items { *counts.entry(item.item_type_id.clone()).or_default() += 1; } assert_eq!(counts.len(), 12); assert!(counts.values().all(|count| *count == 3)); } #[test] fn visual_key_count_follows_fifteen_clear_count() { let run = start_run_with_seed_at( "run-types-fifteen".to_string(), "user-1".to_string(), "profile-1".to_string(), &test_config(15), 42, 1_000, ) .expect("run should start"); let mut counts = BTreeMap::::new(); let mut item_types_by_visual_key = BTreeMap::>::new(); for item in &run.items { *counts.entry(item.visual_key.clone()).or_default() += 1; item_types_by_visual_key .entry(item.visual_key.clone()) .or_default() .push(item.item_type_id.clone()); } assert_eq!(counts.len(), 15); assert!(counts.values().all(|count| *count == 3)); assert!(item_types_by_visual_key.values().all(|item_type_ids| { item_type_ids .iter() .all(|item_type_id| item_type_id == &item_type_ids[0]) })); } #[test] fn item_type_count_is_capped_at_twenty_five() { let run = start_run_with_seed_at( "run-types-large".to_string(), "user-1".to_string(), "profile-1".to_string(), &test_config(100), 42, 1_000, ) .expect("run should start"); let mut counts = BTreeMap::::new(); for item in &run.items { *counts.entry(item.item_type_id.clone()).or_default() += 1; } assert_eq!(counts.len(), 25); 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 size_tier_plan_follows_ratio_for_twenty_five_types() { let plan = resolve_size_tier_plan(25); let mut counts = BTreeMap::<&str, usize>::new(); for rule in plan { *counts.entry(rule.tier).or_default() += 1; match rule.tier { "XL" => assert!((1.60..=2.30).contains(&rule.relative_volume)), "L" => assert!((1.25..=1.60).contains(&rule.relative_volume)), "M" => assert_eq!(rule.relative_volume, 1.00), "XS" => assert!((0.65..=0.85).contains(&rule.relative_volume)), "S" => assert!((0.35..=0.50).contains(&rule.relative_volume)), _ => panic!("unknown size tier"), } } assert_eq!(counts.get("XL"), Some(&5)); assert_eq!(counts.get("L"), Some(&8)); assert_eq!(counts.get("M"), Some(&7)); assert_eq!(counts.get("XS"), Some(&4)); assert_eq!(counts.get("S"), Some(&1)); } #[test] fn same_visual_key_keeps_one_size_in_run() { let run = start_run_with_seed_at( "run-size-unique".to_string(), "user-1".to_string(), "profile-1".to_string(), &test_config(30), 42, 1_000, ) .expect("run should start"); let mut radii_by_visual_key = BTreeMap::>::new(); for item in &run.items { radii_by_visual_key .entry(item.visual_key.clone()) .or_default() .push((item.radius * 10_000.0).round() as u32); } assert_eq!(radii_by_visual_key.len(), 25); assert!(radii_by_visual_key.values().all(|radii| { radii.iter().all(|radius| radius == &radii[0]) })); } #[test] fn block_visuals_stay_inside_board() { let run = start_run_with_seed_at( "run-blocks".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.iter().all(|visual_key| visual_key.starts_with("block-"))); 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 twenty_five_or_less_does_not_repeat_visual_keys() { let run = start_run_with_seed_at( "run-block-unique".to_string(), "user-1".to_string(), "profile-1".to_string(), &test_config(25), 27, 1_000, ) .expect("run should start"); let mut counts = BTreeMap::::new(); for item in &run.items { *counts.entry(item.visual_key.clone()).or_default() += 1; } assert_eq!(counts.len(), 25); assert!(counts.values().all(|count| *count == 3)); } #[test] fn block_visuals_have_different_relative_sizes() { let config = build_creator_config("玩具", None, 3, 4).expect("config should be valid"); let run = start_run_with_seed_at( "run-block-size".to_string(), "user-1".to_string(), "profile-1".to_string(), &config, 13, 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 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); } }