use std::collections::{BTreeSet, HashMap, VecDeque}; use shared_kernel::normalize_required_string; use crate::{ PUZZLE_CLEAR_LEVEL_DURATION_SECONDS, PuzzleClearBoard, PuzzleClearCard, PuzzleClearCell, PuzzleClearDeck, PuzzleClearElimination, PuzzleClearError, PuzzleClearLevelConfig, PuzzleClearMove, PuzzleClearOrientation, PuzzleClearPatternGroup, PuzzleClearRunSnapshot, PuzzleClearRunStatus, PuzzleClearShapeKind, PuzzleClearShapeQuota, }; pub fn puzzle_clear_level_configs() -> Vec { vec![PuzzleClearLevelConfig { level_index: 1, board_size: 6, target_clears: 35, duration_seconds: PUZZLE_CLEAR_LEVEL_DURATION_SECONDS, unlocked_shapes: vec![ PuzzleClearShapeKind::OneByTwo, PuzzleClearShapeKind::OneByThree, PuzzleClearShapeKind::TwoByTwo, PuzzleClearShapeKind::TwoByThree, ], }] } pub fn puzzle_clear_shape_quotas() -> Vec { vec![ PuzzleClearShapeQuota { shape: PuzzleClearShapeKind::OneByTwo, count: 23, }, PuzzleClearShapeQuota { shape: PuzzleClearShapeKind::OneByThree, count: 5, }, PuzzleClearShapeQuota { shape: PuzzleClearShapeKind::TwoByTwo, count: 4, }, PuzzleClearShapeQuota { shape: PuzzleClearShapeKind::TwoByThree, count: 3, }, ] } pub fn plan_puzzle_clear_pattern_groups( cell_size: u32, ) -> Result, PuzzleClearError> { if cell_size == 0 { return Err(PuzzleClearError::InvalidBoard); } Ok(puzzle_clear_pattern_group_specs() .into_iter() .map(|spec| PuzzleClearPatternGroup { group_id: spec.group_id.to_string(), shape: spec.shape, width: spec.width, height: spec.height, atlas_x: spec.atlas_col * cell_size, atlas_y: spec.atlas_row * cell_size, atlas_width: spec.width * cell_size, atlas_height: spec.height * cell_size, }) .collect()) } pub fn build_cards_from_groups( groups: &[PuzzleClearPatternGroup], image_prefix: &str, ) -> Vec { let mut cards = Vec::new(); for group in groups { let orientation = if group.width >= group.height { PuzzleClearOrientation::Horizontal } else { PuzzleClearOrientation::Vertical }; for y in 0..group.height { for x in 0..group.width { cards.push(PuzzleClearCard { card_id: format!("{}-part-{x}-{y}", group.group_id), group_id: group.group_id.clone(), shape: group.shape, orientation, part_x: x, part_y: y, image_src: format!( "{}/{}-part-{x}-{y}.png", image_prefix.trim_end_matches('/'), group.group_id ), image_object_key: format!( "{}/{}-part-{x}-{y}.png", image_prefix.trim_start_matches('/').trim_end_matches('/'), group.group_id ), asset_object_id: format!("{}-part-{x}-{y}-object", group.group_id), source_atlas_cell: format!("{}:{x}:{y}", group.group_id), }); } } } cards } pub fn create_puzzle_clear_board( level: &PuzzleClearLevelConfig, seed: &str, cards: Vec, ) -> Result { if level.board_size == 0 { return Err(PuzzleClearError::InvalidLevel); } let total = (level.board_size * level.board_size) as usize; if cards.len() < total { return Err(PuzzleClearError::EmptyDeck); } let mut rng = DeterministicRng::new(seed, &format!("level-{}", level.level_index)); let mut selected = cards.into_iter().take(total).collect::>(); shuffle(&mut selected, &mut rng); let mut cells = Vec::with_capacity(total); for row in 0..level.board_size { for col in 0..level.board_size { let index = (row * level.board_size + col) as usize; cells.push(PuzzleClearCell { row, col, card: selected.get(index).cloned(), locked_group_id: None, }); } } let mut board = PuzzleClearBoard { rows: level.board_size, cols: level.board_size, cells, }; ensure_board_has_playable_move(&mut board)?; Ok(board) } pub fn start_puzzle_clear_run( run_id: String, owner_user_id: String, profile_id: String, board: PuzzleClearBoard, deck: PuzzleClearDeck, started_at_ms: u64, ) -> Result { let run_id = normalize_required_string(run_id).ok_or(PuzzleClearError::MissingRunId)?; let owner_user_id = normalize_required_string(owner_user_id).ok_or(PuzzleClearError::MissingOwnerUserId)?; let profile_id = normalize_required_string(profile_id).ok_or(PuzzleClearError::MissingProfileId)?; validate_board(&board)?; if !has_playable_move(&board) { return Err(PuzzleClearError::NoPlayableMove); } Ok(PuzzleClearRunSnapshot { run_id, owner_user_id, profile_id, status: PuzzleClearRunStatus::Playing, level_index: 1, clears_done: 0, board, deck, started_at_ms, level_started_at_ms: started_at_ms, finished_at_ms: None, }) } pub fn apply_puzzle_clear_swap( run: &PuzzleClearRunSnapshot, player_move: PuzzleClearMove, now_ms: u64, ) -> Result { ensure_run_playing(run)?; ensure_not_expired(run, now_ms)?; let mut next = run.clone(); swap_cells(&mut next.board, &player_move)?; let mut resolved_clears = resolve_eliminations_and_refill(&mut next.board, &mut next.deck)?; next.clears_done = next.clears_done.saturating_add(resolved_clears); if resolved_clears == 0 { if has_empty_cell(&next.board) { apply_gravity_and_refill(&mut next.board, &mut next.deck)?; resolved_clears = resolve_eliminations_and_refill(&mut next.board, &mut next.deck)?; next.clears_done = next.clears_done.saturating_add(resolved_clears); } } mark_completed_local_groups(&mut next.board); if has_remaining_cards(&next.board) { ensure_board_has_playable_move(&mut next.board)?; } let level = puzzle_clear_level_configs() .into_iter() .find(|config| config.level_index == next.level_index) .ok_or(PuzzleClearError::InvalidLevel)?; if next.clears_done >= level.target_clears && !has_remaining_cards(&next.board) { next.status = if next.level_index >= max_puzzle_clear_level_index() { PuzzleClearRunStatus::Finished } else { PuzzleClearRunStatus::LevelCleared }; next.finished_at_ms = Some(now_ms); } Ok(next) } pub fn fail_puzzle_clear_level_on_timeout( run: &PuzzleClearRunSnapshot, now_ms: u64, ) -> Result { ensure_run_playing(run)?; let mut next = run.clone(); if is_level_expired(run, now_ms) { next.status = PuzzleClearRunStatus::LevelFailed; next.finished_at_ms = Some(now_ms); } Ok(next) } pub fn retry_puzzle_clear_level( run: &PuzzleClearRunSnapshot, board: PuzzleClearBoard, deck: PuzzleClearDeck, restarted_at_ms: u64, ) -> Result { validate_board(&board)?; if !has_playable_move(&board) { return Err(PuzzleClearError::NoPlayableMove); } Ok(PuzzleClearRunSnapshot { status: PuzzleClearRunStatus::Playing, board, deck, level_started_at_ms: restarted_at_ms, finished_at_ms: None, ..run.clone() }) } pub fn advance_puzzle_clear_level( run: &PuzzleClearRunSnapshot, board: PuzzleClearBoard, deck: PuzzleClearDeck, started_at_ms: u64, ) -> Result { if run.status != PuzzleClearRunStatus::LevelCleared { return Err(PuzzleClearError::RunNotPlaying); } validate_board(&board)?; if !has_playable_move(&board) { return Err(PuzzleClearError::NoPlayableMove); } Ok(PuzzleClearRunSnapshot { status: PuzzleClearRunStatus::Playing, level_index: run.level_index.saturating_add(1), clears_done: 0, board, deck, level_started_at_ms: started_at_ms, finished_at_ms: None, ..run.clone() }) } pub fn has_playable_move(board: &PuzzleClearBoard) -> bool { let Ok(()) = validate_board(board) else { return false; }; if !find_eliminations(board).is_empty() { return false; } for row in 0..board.rows { for col in 0..board.cols { if card_at(board, row, col).is_none() { continue; }; for next_row in 0..board.rows { for next_col in 0..board.cols { if row == next_row && col == next_col { continue; } let mut candidate = board.clone(); if swap_cells( &mut candidate, &PuzzleClearMove { from_row: row, from_col: col, to_row: next_row, to_col: next_col, }, ) .is_err() { continue; } if !find_eliminations(&candidate).is_empty() { return true; } } } } } false } pub fn find_eliminations(board: &PuzzleClearBoard) -> Vec { let mut by_group: HashMap> = HashMap::new(); for cell in &board.cells { if let Some(card) = &cell.card { by_group.entry(card.group_id.clone()).or_default().push(( cell.row, cell.col, card.clone(), )); } } let mut eliminations = Vec::new(); for (group_id, entries) in by_group { let Some(first) = entries.first().map(|(_, _, card)| card.clone()) else { continue; }; let (width, height) = first.shape.dimensions(first.orientation); if entries.len() != (width * height) as usize { continue; } let min_row = entries.iter().map(|(row, _, _)| *row).min().unwrap_or(0); let min_col = entries.iter().map(|(_, col, _)| *col).min().unwrap_or(0); let mut expected = BTreeSet::new(); for y in 0..height { for x in 0..width { expected.insert((min_row + y, min_col + x, x, y)); } } let actual = entries .iter() .map(|(row, col, card)| (*row, *col, card.part_x, card.part_y)) .collect::>(); if actual == expected { eliminations.push(PuzzleClearElimination { group_id, positions: entries .into_iter() .map(|(row, col, _)| (row, col)) .collect(), }); } } eliminations } pub fn apply_gravity_and_refill( board: &mut PuzzleClearBoard, deck: &mut PuzzleClearDeck, ) -> Result<(), PuzzleClearError> { validate_board(board)?; if deck.ready_columns.len() < board.cols as usize { return Err(PuzzleClearError::EmptyDeck); } for col in 0..board.cols { let mut segment_start = 0; while segment_start < board.rows { while segment_start < board.rows && is_locked_cell(board, segment_start, col) { segment_start += 1; } let start = segment_start; while segment_start < board.rows && !is_locked_cell(board, segment_start, col) { segment_start += 1; } if start < segment_start { refill_unlocked_column_segment(board, deck, col, start, segment_start)?; } } } Ok(()) } fn ensure_run_playing(run: &PuzzleClearRunSnapshot) -> Result<(), PuzzleClearError> { if run.status == PuzzleClearRunStatus::Playing { Ok(()) } else { Err(PuzzleClearError::RunNotPlaying) } } fn ensure_not_expired(run: &PuzzleClearRunSnapshot, now_ms: u64) -> Result<(), PuzzleClearError> { if is_level_expired(run, now_ms) { Err(PuzzleClearError::LevelExpired) } else { Ok(()) } } fn is_level_expired(run: &PuzzleClearRunSnapshot, now_ms: u64) -> bool { now_ms.saturating_sub(run.level_started_at_ms) > u64::from(PUZZLE_CLEAR_LEVEL_DURATION_SECONDS) * 1000 } fn validate_board(board: &PuzzleClearBoard) -> Result<(), PuzzleClearError> { if board.rows == 0 || board.cols == 0 || board.cells.len() != (board.rows * board.cols) as usize { return Err(PuzzleClearError::InvalidBoard); } Ok(()) } fn has_empty_cell(board: &PuzzleClearBoard) -> bool { board.cells.iter().any(|cell| cell.card.is_none()) } fn has_remaining_cards(board: &PuzzleClearBoard) -> bool { board.cells.iter().any(|cell| cell.card.is_some()) } fn ensure_board_has_playable_move(board: &mut PuzzleClearBoard) -> Result<(), PuzzleClearError> { if find_eliminations(board).is_empty() && has_playable_move(board) { return Ok(()); } let snapshot = board.clone(); for from_row in 0..board.rows { for from_col in 0..board.cols { if cell(&snapshot, from_row, from_col) .and_then(|cell| cell.locked_group_id.as_ref()) .is_some() { continue; } for to_row in 0..board.rows { for to_col in 0..board.cols { if from_row == to_row && from_col == to_col { continue; } if cell(&snapshot, to_row, to_col) .and_then(|cell| cell.locked_group_id.as_ref()) .is_some() { continue; } let mut candidate = snapshot.clone(); if swap_positions(&mut candidate, from_row, from_col, to_row, to_col).is_err() { continue; } if find_eliminations(&candidate).is_empty() && has_playable_move(&candidate) { *board = candidate; return Ok(()); } } } } } Err(PuzzleClearError::NoPlayableMove) } fn mark_completed_local_groups(board: &mut PuzzleClearBoard) { for elimination in find_local_completed_groups(board) { for (row, col) in elimination.positions { if let Some(cell) = cell_mut(board, row, col) { cell.locked_group_id = Some(elimination.group_id.clone()); } } } } fn find_local_completed_groups(board: &PuzzleClearBoard) -> Vec { let mut by_group: HashMap> = HashMap::new(); for cell in &board.cells { if let Some(card) = &cell.card { by_group.entry(card.group_id.clone()).or_default().push(( cell.row, cell.col, card.clone(), )); } } by_group .into_iter() .filter_map(|(group_id, entries)| { let Some(first) = entries.first().map(|(_, _, card)| card.clone()) else { return None; }; if entries.len() < 2 || first.shape == PuzzleClearShapeKind::OneByTwo { return None; } let mut ordered = entries.clone(); ordered.sort_by_key(|(_, _, card)| (card.part_y, card.part_x)); let adjacent = ordered.windows(2).all(|pair| { let a = &pair[0].2; let b = &pair[1].2; manhattan_part_distance(a, b) == 1 && are_neighbors(pair[0].0, pair[0].1, pair[1].0, pair[1].1) }); adjacent.then(|| PuzzleClearElimination { group_id, positions: entries .into_iter() .map(|(row, col, _)| (row, col)) .collect(), }) }) .collect() } fn clear_locked_group(board: &mut PuzzleClearBoard, group_id: &str) { for cell in &mut board.cells { if cell.locked_group_id.as_deref() == Some(group_id) { cell.locked_group_id = None; } } } fn clear_elimination(board: &mut PuzzleClearBoard, elimination: &PuzzleClearElimination) { for (row, col) in &elimination.positions { if let Some(cell) = cell_mut(board, *row, *col) { cell.card = None; cell.locked_group_id = None; } } } fn resolve_eliminations_and_refill( board: &mut PuzzleClearBoard, deck: &mut PuzzleClearDeck, ) -> Result { let mut resolved_clears: u32 = 0; let max_passes = board.rows.saturating_mul(board.cols); for _ in 0..max_passes { let eliminations = find_eliminations(board); if eliminations.is_empty() { break; } for elimination in eliminations { clear_elimination(board, &elimination); resolved_clears = resolved_clears.saturating_add(1); } apply_gravity_and_refill(board, deck)?; } Ok(resolved_clears) } fn swap_cells( board: &mut PuzzleClearBoard, player_move: &PuzzleClearMove, ) -> Result<(), PuzzleClearError> { let Some(from_cell) = cell(board, player_move.from_row, player_move.from_col) else { return Err(PuzzleClearError::InvalidPosition); }; let Some(_to_cell) = cell(board, player_move.to_row, player_move.to_col) else { return Err(PuzzleClearError::InvalidPosition); }; if let Some(group_id) = cell(board, player_move.from_row, player_move.from_col) .and_then(|cell| cell.locked_group_id.clone()) { return move_locked_group(board, group_id.as_str(), player_move); } let target_locked_group = cell(board, player_move.to_row, player_move.to_col) .and_then(|cell| cell.locked_group_id.clone()); let source_card = from_cell.card.clone(); if source_card.is_none() { return Err(PuzzleClearError::MissingCard); } let target_card = cell(board, player_move.to_row, player_move.to_col).and_then(|cell| cell.card.clone()); if target_card.is_none() { set_card(board, player_move.to_row, player_move.to_col, source_card)?; set_card(board, player_move.from_row, player_move.from_col, None)?; if let Some(group_id) = target_locked_group { clear_locked_group(board, group_id.as_str()); } return Ok(()); } swap_positions( board, player_move.from_row, player_move.from_col, player_move.to_row, player_move.to_col, )?; if let Some(group_id) = target_locked_group { clear_locked_group(board, group_id.as_str()); } Ok(()) } fn move_locked_group( board: &mut PuzzleClearBoard, group_id: &str, player_move: &PuzzleClearMove, ) -> Result<(), PuzzleClearError> { let delta_row = i64::from(player_move.to_row) - i64::from(player_move.from_row); let delta_col = i64::from(player_move.to_col) - i64::from(player_move.from_col); if delta_row == 0 && delta_col == 0 { return Ok(()); } let mut old_positions = board .cells .iter() .filter(|cell| cell.locked_group_id.as_deref() == Some(group_id)) .map(|cell| (cell.row, cell.col)) .collect::>(); if old_positions.is_empty() { return Err(PuzzleClearError::InvalidPosition); } old_positions.sort_unstable(); let mut new_positions = Vec::with_capacity(old_positions.len()); for (row, col) in &old_positions { let next_row = i64::from(*row) + delta_row; let next_col = i64::from(*col) + delta_col; if next_row < 0 || next_col < 0 || next_row >= i64::from(board.rows) || next_col >= i64::from(board.cols) { return Err(PuzzleClearError::InvalidPosition); } new_positions.push((next_row as u32, next_col as u32)); } let old_set = old_positions.iter().copied().collect::>(); let new_set = new_positions.iter().copied().collect::>(); if old_set == new_set { return Ok(()); } let old_only = old_set.difference(&new_set).copied().collect::>(); let new_only = new_set.difference(&old_set).copied().collect::>(); if old_only.len() != new_only.len() { return Err(PuzzleClearError::InvalidPosition); } let snapshot = board .cells .iter() .map(|cell| ((cell.row, cell.col), cell.clone())) .collect::>(); let mut next_by_position = snapshot.clone(); let mut displaced_locked_groups = BTreeSet::new(); for (old_position, new_position) in old_positions.iter().zip(new_positions.iter()) { let mut moving = snapshot .get(old_position) .cloned() .ok_or(PuzzleClearError::InvalidPosition)?; moving.row = new_position.0; moving.col = new_position.1; moving.locked_group_id = Some(group_id.to_string()); next_by_position.insert(*new_position, moving); } for (vacated_position, displaced_position) in old_only.iter().zip(new_only.iter()) { let mut displaced = snapshot .get(displaced_position) .cloned() .ok_or(PuzzleClearError::InvalidPosition)?; if let Some(displaced_group_id) = displaced.locked_group_id.clone() { if displaced_group_id != group_id { displaced_locked_groups.insert(displaced_group_id); } } displaced.row = vacated_position.0; displaced.col = vacated_position.1; next_by_position.insert(*vacated_position, displaced); } for cell in &mut board.cells { let position = (cell.row, cell.col); *cell = next_by_position .get(&position) .cloned() .ok_or(PuzzleClearError::InvalidPosition)?; } for displaced_group_id in displaced_locked_groups { clear_locked_group(board, displaced_group_id.as_str()); } Ok(()) } fn swap_positions( board: &mut PuzzleClearBoard, a_row: u32, a_col: u32, b_row: u32, b_col: u32, ) -> Result<(), PuzzleClearError> { let a_index = cell_index(board, a_row, a_col).ok_or(PuzzleClearError::InvalidPosition)?; let b_index = cell_index(board, b_row, b_col).ok_or(PuzzleClearError::InvalidPosition)?; board.cells.swap(a_index, b_index); board.cells[a_index].row = a_row; board.cells[a_index].col = a_col; board.cells[b_index].row = b_row; board.cells[b_index].col = b_col; Ok(()) } fn take_card( board: &mut PuzzleClearBoard, row: u32, col: u32, ) -> Result, PuzzleClearError> { let cell = cell_mut(board, row, col).ok_or(PuzzleClearError::InvalidPosition)?; cell.locked_group_id = None; Ok(cell.card.take()) } fn set_card( board: &mut PuzzleClearBoard, row: u32, col: u32, card: Option, ) -> Result<(), PuzzleClearError> { let cell = cell_mut(board, row, col).ok_or(PuzzleClearError::InvalidPosition)?; cell.card = card; cell.locked_group_id = None; Ok(()) } fn refill_unlocked_column_segment( board: &mut PuzzleClearBoard, deck: &mut PuzzleClearDeck, col: u32, start_row: u32, end_row: u32, ) -> Result<(), PuzzleClearError> { let mut existing = VecDeque::new(); for row in (start_row..end_row).rev() { if let Some(card) = take_card(board, row, col)? { existing.push_back(card); } } for row in (start_row..end_row).rev() { let next_card = if let Some(card) = existing.pop_front() { Some(card) } else { pop_matching_refill_card(&mut deck.ready_columns, col as usize, board) }; set_card(board, row, col, next_card)?; } Ok(()) } fn pop_matching_refill_card( ready_columns: &mut [Vec], preferred_col: usize, board: &PuzzleClearBoard, ) -> Option { fn matching_refill_index( column: &[PuzzleClearCard], board: &PuzzleClearBoard, ) -> Option { column .iter() .position(|candidate| can_match_remaining_field_card(candidate, board)) } if preferred_col >= ready_columns.len() { return None; } if let Some(index) = matching_refill_index(&ready_columns[preferred_col], board) { return Some(ready_columns[preferred_col].remove(index)); } for index in 0..ready_columns.len() { if index == preferred_col { continue; } if let Some(matching_index) = matching_refill_index(&ready_columns[index], board) { return Some(ready_columns[index].remove(matching_index)); } } if let Some(card) = ready_columns[preferred_col].pop() { return Some(card); } for index in 0..ready_columns.len() { if index == preferred_col { continue; } if let Some(card) = ready_columns[index].pop() { return Some(card); } } None } fn can_match_remaining_field_card(candidate: &PuzzleClearCard, board: &PuzzleClearBoard) -> bool { board.cells.iter().any(|cell| { cell.card.as_ref().is_some_and(|card| { card.group_id == candidate.group_id && manhattan_part_distance(card, candidate) == 1 }) }) } fn is_locked_cell(board: &PuzzleClearBoard, row: u32, col: u32) -> bool { cell(board, row, col) .and_then(|cell| cell.locked_group_id.as_ref()) .is_some() } fn card_at(board: &PuzzleClearBoard, row: u32, col: u32) -> Option<&PuzzleClearCard> { cell(board, row, col)?.card.as_ref() } fn cell(board: &PuzzleClearBoard, row: u32, col: u32) -> Option<&PuzzleClearCell> { cell_index(board, row, col).and_then(|index| board.cells.get(index)) } fn cell_mut(board: &mut PuzzleClearBoard, row: u32, col: u32) -> Option<&mut PuzzleClearCell> { cell_index(board, row, col).and_then(|index| board.cells.get_mut(index)) } fn cell_index(board: &PuzzleClearBoard, row: u32, col: u32) -> Option { (row < board.rows && col < board.cols).then_some((row * board.cols + col) as usize) } fn are_neighbors(a_row: u32, a_col: u32, b_row: u32, b_col: u32) -> bool { a_row.abs_diff(b_row) + a_col.abs_diff(b_col) == 1 } fn manhattan_part_distance(left: &PuzzleClearCard, right: &PuzzleClearCard) -> u32 { left.part_x.abs_diff(right.part_x) + left.part_y.abs_diff(right.part_y) } fn max_puzzle_clear_level_index() -> u32 { puzzle_clear_level_configs() .into_iter() .map(|config| config.level_index) .max() .unwrap_or(1) } struct PuzzleClearPatternGroupSpec { group_id: &'static str, shape: PuzzleClearShapeKind, width: u32, height: u32, atlas_col: u32, atlas_row: u32, } fn puzzle_clear_pattern_group_specs() -> Vec { use PuzzleClearShapeKind::{OneByThree, OneByTwo, TwoByThree, TwoByTwo}; vec![ PuzzleClearPatternGroupSpec { group_id: "D01", shape: TwoByThree, width: 3, height: 2, atlas_col: 0, atlas_row: 0, }, PuzzleClearPatternGroupSpec { group_id: "D02", shape: TwoByThree, width: 2, height: 3, atlas_col: 3, atlas_row: 0, }, PuzzleClearPatternGroupSpec { group_id: "D03", shape: TwoByThree, width: 3, height: 2, atlas_col: 5, atlas_row: 0, }, PuzzleClearPatternGroupSpec { group_id: "C01", shape: TwoByTwo, width: 2, height: 2, atlas_col: 8, atlas_row: 0, }, PuzzleClearPatternGroupSpec { group_id: "C02", shape: TwoByTwo, width: 2, height: 2, atlas_col: 0, atlas_row: 2, }, PuzzleClearPatternGroupSpec { group_id: "B02", shape: OneByThree, width: 1, height: 3, atlas_col: 2, atlas_row: 2, }, PuzzleClearPatternGroupSpec { group_id: "C03", shape: TwoByTwo, width: 2, height: 2, atlas_col: 5, atlas_row: 2, }, PuzzleClearPatternGroupSpec { group_id: "C04", shape: TwoByTwo, width: 2, height: 2, atlas_col: 7, atlas_row: 2, }, PuzzleClearPatternGroupSpec { group_id: "B04", shape: OneByThree, width: 1, height: 3, atlas_col: 9, atlas_row: 2, }, PuzzleClearPatternGroupSpec { group_id: "A02", shape: OneByTwo, width: 1, height: 2, atlas_col: 3, atlas_row: 3, }, PuzzleClearPatternGroupSpec { group_id: "A04", shape: OneByTwo, width: 1, height: 2, atlas_col: 4, atlas_row: 3, }, PuzzleClearPatternGroupSpec { group_id: "A01", shape: OneByTwo, width: 2, height: 1, atlas_col: 0, atlas_row: 4, }, PuzzleClearPatternGroupSpec { group_id: "B01", shape: OneByThree, width: 3, height: 1, atlas_col: 5, atlas_row: 4, }, PuzzleClearPatternGroupSpec { group_id: "A06", shape: OneByTwo, width: 1, height: 2, atlas_col: 8, atlas_row: 4, }, PuzzleClearPatternGroupSpec { group_id: "B03", shape: OneByThree, width: 3, height: 1, atlas_col: 0, atlas_row: 5, }, PuzzleClearPatternGroupSpec { group_id: "B05", shape: OneByThree, width: 3, height: 1, atlas_col: 3, atlas_row: 5, }, PuzzleClearPatternGroupSpec { group_id: "A03", shape: OneByTwo, width: 2, height: 1, atlas_col: 6, atlas_row: 5, }, PuzzleClearPatternGroupSpec { group_id: "A08", shape: OneByTwo, width: 1, height: 2, atlas_col: 9, atlas_row: 5, }, PuzzleClearPatternGroupSpec { group_id: "A05", shape: OneByTwo, width: 2, height: 1, atlas_col: 0, atlas_row: 6, }, PuzzleClearPatternGroupSpec { group_id: "A07", shape: OneByTwo, width: 2, height: 1, atlas_col: 2, atlas_row: 6, }, PuzzleClearPatternGroupSpec { group_id: "A09", shape: OneByTwo, width: 2, height: 1, atlas_col: 4, atlas_row: 6, }, PuzzleClearPatternGroupSpec { group_id: "A10", shape: OneByTwo, width: 1, height: 2, atlas_col: 6, atlas_row: 6, }, PuzzleClearPatternGroupSpec { group_id: "A11", shape: OneByTwo, width: 2, height: 1, atlas_col: 7, atlas_row: 6, }, PuzzleClearPatternGroupSpec { group_id: "A12", shape: OneByTwo, width: 1, height: 2, atlas_col: 0, atlas_row: 7, }, PuzzleClearPatternGroupSpec { group_id: "A13", shape: OneByTwo, width: 2, height: 1, atlas_col: 1, atlas_row: 7, }, PuzzleClearPatternGroupSpec { group_id: "A14", shape: OneByTwo, width: 1, height: 2, atlas_col: 3, atlas_row: 7, }, PuzzleClearPatternGroupSpec { group_id: "A15", shape: OneByTwo, width: 2, height: 1, atlas_col: 4, atlas_row: 7, }, PuzzleClearPatternGroupSpec { group_id: "A16", shape: OneByTwo, width: 1, height: 2, atlas_col: 7, atlas_row: 7, }, PuzzleClearPatternGroupSpec { group_id: "A17", shape: OneByTwo, width: 2, height: 1, atlas_col: 8, atlas_row: 7, }, PuzzleClearPatternGroupSpec { group_id: "A19", shape: OneByTwo, width: 2, height: 1, atlas_col: 1, atlas_row: 8, }, PuzzleClearPatternGroupSpec { group_id: "A18", shape: OneByTwo, width: 1, height: 2, atlas_col: 4, atlas_row: 8, }, PuzzleClearPatternGroupSpec { group_id: "A20", shape: OneByTwo, width: 1, height: 2, atlas_col: 5, atlas_row: 8, }, PuzzleClearPatternGroupSpec { group_id: "A22", shape: OneByTwo, width: 1, height: 2, atlas_col: 6, atlas_row: 8, }, PuzzleClearPatternGroupSpec { group_id: "A21", shape: OneByTwo, width: 2, height: 1, atlas_col: 8, atlas_row: 8, }, PuzzleClearPatternGroupSpec { group_id: "A23", shape: OneByTwo, width: 2, height: 1, atlas_col: 0, atlas_row: 9, }, ] } fn shuffle(items: &mut [T], rng: &mut DeterministicRng) { for index in (1..items.len()).rev() { let swap_index = rng.range_usize(0, index); items.swap(index, swap_index); } } struct DeterministicRng { state: u64, } impl DeterministicRng { fn new(seed: &str, salt: &str) -> Self { let mut state = 0xcbf2_9ce4_8422_2325u64; for byte in seed.bytes().chain(salt.bytes()) { state ^= u64::from(byte); state = state.wrapping_mul(0x1000_0000_01b3); } Self { state } } fn next_u32(&mut self) -> u32 { self.state = self .state .wrapping_mul(6_364_136_223_846_793_005) .wrapping_add(1); (self.state >> 32) as u32 } fn range_usize(&mut self, min: usize, max: usize) -> usize { if max <= min { return min; } min + self.next_u32() as usize % (max - min + 1) } } #[cfg(test)] mod tests { use super::*; #[test] fn fixed_level_config_uses_single_six_by_six_level() { let configs = puzzle_clear_level_configs(); assert_eq!(configs.len(), 1); assert_eq!(configs[0].board_size, 6); assert_eq!(configs[0].target_clears, 35); assert_eq!( configs[0].unlocked_shapes, vec![ PuzzleClearShapeKind::OneByTwo, PuzzleClearShapeKind::OneByThree, PuzzleClearShapeKind::TwoByTwo, PuzzleClearShapeKind::TwoByThree, ] ); assert!(configs.iter().all(|config| config.duration_seconds == 600)); } #[test] fn atlas_plan_contains_exact_shape_quotas() { let groups = plan_puzzle_clear_pattern_groups(256).expect("atlas should plan"); let mut counts = HashMap::new(); for group in groups { *counts.entry(group.shape.as_str()).or_insert(0u32) += 1; assert_eq!(group.atlas_width, group.width * 256); assert_eq!(group.atlas_height, group.height * 256); assert!(group.atlas_x + group.atlas_width <= 2560); assert!(group.atlas_y + group.atlas_height <= 2560); } assert_eq!(counts.get("1x2"), Some(&23)); assert_eq!(counts.get("1x3"), Some(&5)); assert_eq!(counts.get("2x2"), Some(&4)); assert_eq!(counts.get("2x3"), Some(&3)); } #[test] fn atlas_plan_includes_vertical_groups_for_rotatable_shapes() { let groups = plan_puzzle_clear_pattern_groups(256).expect("atlas should plan"); let vertical_groups = groups .iter() .filter(|group| { matches!( group.shape, PuzzleClearShapeKind::OneByTwo | PuzzleClearShapeKind::OneByThree | PuzzleClearShapeKind::TwoByThree ) && group.height > group.width }) .count(); let cards = build_cards_from_groups(&groups, "/generated-puzzle-clear"); assert!(vertical_groups > 0); assert!( cards .iter() .any(|card| card.orientation == PuzzleClearOrientation::Vertical) ); } #[test] fn board_creation_guarantees_a_playable_move() { let groups = plan_puzzle_clear_pattern_groups(64).expect("atlas should plan"); let cards = build_cards_from_groups(&groups, "/generated-puzzle-clear"); let board = create_puzzle_clear_board(&puzzle_clear_level_configs()[0], "seed-a", cards) .expect("board should create"); assert_eq!(board.rows, 6); assert_eq!(board.cols, 6); assert!(find_eliminations(&board).is_empty()); assert!(has_playable_move(&board)); } #[test] fn one_by_two_neighbors_are_not_half_locked() { let board = board_from_cards( 3, vec![ Some(card("pair", 0, 0)), Some(card("noise-a", 0, 0)), Some(card("noise-b", 0, 0)), Some(card("noise-c", 0, 0)), Some(card("pair", 1, 0)), Some(card("noise-d", 0, 0)), Some(card("noise-e", 0, 0)), Some(card("noise-f", 0, 0)), Some(card("noise-g", 0, 0)), ], ); let run = start_puzzle_clear_run( "run-one-by-two-lock".to_string(), "user-1".to_string(), "profile-1".to_string(), board, PuzzleClearDeck { ready_columns: vec![vec![], vec![], vec![]], }, 100, ) .expect("run should start"); let next = apply_puzzle_clear_swap( &run, PuzzleClearMove { from_row: 1, from_col: 1, to_row: 1, to_col: 0, }, 200, ) .expect("swap should resolve"); assert!( next.board .cells .iter() .all(|cell| cell.locked_group_id.is_none()) ); assert_eq!(next.clears_done, 0); } #[test] fn completing_two_piece_group_eliminates_and_refills() { let board = board_from_cards( 3, vec![ Some(card("other", 0, 0)), Some(card("noise", 0, 0)), Some(card("other", 1, 0)), Some(card("noise-a", 0, 0)), Some(card("play", 0, 0)), Some(card("noise-b", 0, 0)), Some(card("noise-c", 0, 0)), Some(card("play", 1, 0)), Some(card("noise-d", 0, 0)), ], ); let deck = PuzzleClearDeck { ready_columns: vec![ vec![], vec![card("fill-0", 0, 0)], vec![card("fill-1", 0, 0)], ], }; let run = start_puzzle_clear_run( "run-1".to_string(), "user-1".to_string(), "profile-1".to_string(), board, deck, 100, ) .expect("run should start"); let next = apply_puzzle_clear_swap( &run, PuzzleClearMove { from_row: 2, from_col: 1, to_row: 1, to_col: 2, }, 200, ) .expect("swap should resolve"); assert_eq!(next.clears_done, 1); assert!(next.board.cells.iter().all(|cell| cell.card.is_some())); assert!(has_playable_move(&next.board)); } #[test] fn reaching_target_clears_without_empty_board_keeps_playing() { let board = board_from_cards( 3, vec![ Some(card("other", 0, 0)), Some(card("noise", 0, 0)), Some(card("other", 1, 0)), Some(card("noise-a", 0, 0)), Some(card("play", 0, 0)), Some(card("noise-b", 0, 0)), Some(card("noise-c", 0, 0)), Some(card("play", 1, 0)), Some(card("keep", 0, 0)), ], ); let mut run = start_puzzle_clear_run( "run-target".to_string(), "user-1".to_string(), "profile-1".to_string(), board, PuzzleClearDeck { ready_columns: vec![vec![], vec![], vec![]], }, 100, ) .expect("run should start"); run.clears_done = 4; let next = apply_puzzle_clear_swap( &run, PuzzleClearMove { from_row: 2, from_col: 1, to_row: 1, to_col: 2, }, 200, ) .expect("swap should resolve"); assert_eq!(next.clears_done, 5); assert_eq!(next.status, PuzzleClearRunStatus::Playing); assert!(next.board.cells.iter().any(|cell| cell.card.is_some())); } #[test] fn refill_keeps_locked_partial_group_in_place() { let mut board = board_from_cards( 3, vec![ Some(card_shape("tri", PuzzleClearShapeKind::OneByThree, 0, 0)), Some(card_shape("tri", PuzzleClearShapeKind::OneByThree, 1, 0)), Some(card("a", 0, 0)), None, Some(card("b", 0, 0)), Some(card("c", 0, 0)), Some(card("d", 0, 0)), Some(card("e", 0, 0)), Some(card("f", 0, 0)), ], ); lock_cells(&mut board, "tri", &[(0, 0), (0, 1)]); let mut deck = PuzzleClearDeck { ready_columns: vec![vec![card("x", 0, 0)], vec![card("y", 0, 0)], vec![]], }; apply_gravity_and_refill(&mut board, &mut deck).expect("refill should keep partial group"); assert_eq!( card_at(&board, 0, 0).map(|card| card.group_id.as_str()), Some("tri") ); assert_eq!( card_at(&board, 0, 1).map(|card| card.group_id.as_str()), Some("tri") ); assert_eq!( cell(&board, 0, 0).and_then(|cell| cell.locked_group_id.as_deref()), Some("tri") ); assert_eq!( cell(&board, 0, 1).and_then(|cell| cell.locked_group_id.as_deref()), Some("tri") ); } #[test] fn refill_compacts_existing_cards_down_before_drawing_new_cards() { let mut board = board_from_cards( 3, vec![ Some(card("top", 0, 0)), Some(card("a", 0, 0)), Some(card("b", 0, 0)), None, Some(card("c", 0, 0)), Some(card("d", 0, 0)), None, Some(card("e", 0, 0)), Some(card("f", 0, 0)), ], ); let mut deck = PuzzleClearDeck { ready_columns: vec![ vec![card("fill-top", 0, 0), card("fill-middle", 0, 0)], vec![], vec![], ], }; apply_gravity_and_refill(&mut board, &mut deck) .expect("gravity should compact before refill"); assert_eq!( card_at(&board, 2, 0).map(|card| card.group_id.as_str()), Some("top") ); assert_eq!( card_at(&board, 0, 0).map(|card| card.group_id.as_str()), Some("fill-top") ); assert_eq!( card_at(&board, 1, 0).map(|card| card.group_id.as_str()), Some("fill-middle") ); } #[test] fn dragging_locked_partial_group_moves_as_unit() { let mut board = board_from_cards( 3, vec![ Some(card_shape("tri", PuzzleClearShapeKind::OneByThree, 0, 0)), Some(card_shape("tri", PuzzleClearShapeKind::OneByThree, 1, 0)), Some(card("a", 0, 0)), Some(card("x", 0, 0)), Some(card("y", 0, 0)), Some(card("play", 0, 0)), Some(card("c", 0, 0)), Some(card("d", 0, 0)), Some(card("play", 1, 0)), ], ); lock_cells(&mut board, "tri", &[(0, 0), (0, 1)]); let run = start_puzzle_clear_run( "run-1".to_string(), "user-1".to_string(), "profile-1".to_string(), board, PuzzleClearDeck { ready_columns: vec![vec![], vec![], vec![]], }, 100, ) .expect("run should start"); let next = apply_puzzle_clear_swap( &run, PuzzleClearMove { from_row: 0, from_col: 0, to_row: 1, to_col: 0, }, 200, ) .expect("group drag should resolve"); assert_eq!( card_at(&next.board, 1, 0).map(|card| card.group_id.as_str()), Some("tri") ); assert_eq!( card_at(&next.board, 1, 1).map(|card| card.group_id.as_str()), Some("tri") ); assert_eq!( cell(&next.board, 1, 0).and_then(|cell| cell.locked_group_id.as_deref()), Some("tri") ); assert_eq!( cell(&next.board, 1, 1).and_then(|cell| cell.locked_group_id.as_deref()), Some("tri") ); assert_eq!( card_at(&next.board, 0, 0).map(|card| card.group_id.as_str()), Some("x") ); assert_eq!( card_at(&next.board, 0, 1).map(|card| card.group_id.as_str()), Some("y") ); } #[test] fn refill_promotes_new_card_matching_remaining_field_card() { let mut board = board_from_cards( 3, vec![ Some(card("a", 0, 0)), None, Some(card("b", 0, 0)), Some(card("c", 0, 0)), Some(card("d", 0, 0)), Some(card("e", 0, 0)), Some(card_shape("tri", PuzzleClearShapeKind::OneByThree, 0, 0)), Some(card("f", 0, 0)), Some(card("g", 0, 0)), ], ); let mut deck = PuzzleClearDeck { ready_columns: vec![ vec![], vec![ card_shape("tri", PuzzleClearShapeKind::OneByThree, 1, 0), card("noise", 0, 0), ], vec![], ], }; apply_gravity_and_refill(&mut board, &mut deck) .expect("refill should choose a matching new card"); assert_eq!( card_at(&board, 0, 1).map(|card| card.group_id.as_str()), Some("tri") ); } #[test] fn refill_can_borrow_from_other_ready_columns_when_preferred_column_empty() { let mut board = board_from_cards( 3, vec![ Some(card("a", 0, 0)), None, Some(card("b", 0, 0)), Some(card("c", 0, 0)), Some(card("d", 0, 0)), Some(card("e", 0, 0)), Some(card("f", 0, 0)), Some(card("g", 0, 0)), Some(card("h", 0, 0)), ], ); let mut deck = PuzzleClearDeck { ready_columns: vec![ vec![], vec![], vec![card("borrow", 0, 0), card("noise", 0, 0)], ], }; apply_gravity_and_refill(&mut board, &mut deck) .expect("refill should borrow from another ready column"); assert!(board.cells.iter().all(|cell| cell.card.is_some())); } #[test] fn player_move_can_drop_card_into_empty_target_cell() { let board = board_from_cards( 3, vec![ Some(card("source", 0, 0)), Some(card("noise-a", 0, 0)), Some(card("noise-b", 0, 0)), Some(card("noise-c", 0, 0)), Some(card_shape("pair", PuzzleClearShapeKind::OneByThree, 0, 0)), Some(card_shape("pair", PuzzleClearShapeKind::OneByThree, 1, 0)), Some(card("play", 0, 0)), Some(card("noise-e", 0, 0)), Some(card("play", 1, 0)), ], ); let run = start_puzzle_clear_run( "run-empty-target".to_string(), "user-1".to_string(), "profile-1".to_string(), board, PuzzleClearDeck { ready_columns: vec![vec![], vec![], vec![]], }, 100, ) .expect("run should start"); let mut run = run.clone(); if let Some(cell) = cell_mut(&mut run.board, 0, 1) { cell.card = None; } run.deck.ready_columns[0].push(card("refill-source", 0, 0)); let next = apply_puzzle_clear_swap( &run, PuzzleClearMove { from_row: 0, from_col: 0, to_row: 0, to_col: 1, }, 200, ) .expect("empty target should be allowed"); assert_eq!( card_at(&next.board, 0, 0).map(|card| card.group_id.as_str()), Some("refill-source") ); assert_eq!( card_at(&next.board, 0, 1).map(|card| card.group_id.as_str()), Some("source") ); } #[test] fn non_two_piece_partial_group_can_be_locked_and_downgraded_by_player_swap() { let board = board_from_cards( 3, vec![ Some(card_shape("tri", PuzzleClearShapeKind::OneByThree, 0, 0)), Some(card_shape("tri", PuzzleClearShapeKind::OneByThree, 1, 0)), Some(card("x", 0, 0)), Some(card("play", 0, 0)), Some(card("z", 0, 0)), Some(card("w", 0, 0)), Some(card("m", 0, 0)), Some(card("n", 0, 0)), Some(card("play", 1, 0)), ], ); let run = start_puzzle_clear_run( "run-1".to_string(), "user-1".to_string(), "profile-1".to_string(), board, PuzzleClearDeck { ready_columns: vec![vec![], vec![], vec![]], }, 100, ) .expect("run should start"); let locked = apply_puzzle_clear_swap( &run, PuzzleClearMove { from_row: 2, from_col: 2, to_row: 1, to_col: 2, }, 200, ) .expect("non-clear swap should mark partials"); assert_eq!( locked.board.cells[0].locked_group_id.as_deref(), Some("tri") ); assert_eq!( locked.board.cells[1].locked_group_id.as_deref(), Some("tri") ); let downgraded = apply_puzzle_clear_swap( &locked, PuzzleClearMove { from_row: 1, from_col: 0, to_row: 0, to_col: 0, }, 250, ) .expect("player swap into group should downgrade lock"); assert!( downgraded .board .cells .iter() .filter(|cell| cell .card .as_ref() .is_some_and(|card| card.group_id == "tri")) .all(|cell| cell.locked_group_id.is_none()) ); } #[test] fn timeout_fails_only_current_level_and_retry_restarts_it() { let board = board_from_cards( 3, vec![ Some(card("a", 0, 0)), Some(card("b", 0, 0)), Some(card("b", 0, 0)), Some(card("c", 0, 0)), Some(card("a", 1, 0)), Some(card("e", 0, 0)), Some(card("f", 0, 0)), Some(card("g", 0, 0)), Some(card("h", 0, 0)), ], ); let run = start_puzzle_clear_run( "run-1".to_string(), "user-1".to_string(), "profile-1".to_string(), board.clone(), PuzzleClearDeck { ready_columns: vec![vec![], vec![], vec![]], }, 0, ) .expect("run should start"); let failed = fail_puzzle_clear_level_on_timeout(&run, 601_000).expect("timeout applies"); assert_eq!(failed.status, PuzzleClearRunStatus::LevelFailed); assert_eq!(failed.level_index, 1); let retried = retry_puzzle_clear_level( &failed, board, PuzzleClearDeck { ready_columns: vec![vec![], vec![], vec![]], }, 700_000, ) .expect("retry should restart"); assert_eq!(retried.status, PuzzleClearRunStatus::Playing); assert_eq!(retried.level_index, 1); assert_eq!(retried.clears_done, 0); } fn board_from_cards(size: u32, cards: Vec>) -> PuzzleClearBoard { let mut cells = Vec::new(); for row in 0..size { for col in 0..size { let index = (row * size + col) as usize; cells.push(PuzzleClearCell { row, col, card: cards.get(index).cloned().flatten(), locked_group_id: None, }); } } PuzzleClearBoard { rows: size, cols: size, cells, } } fn lock_cells(board: &mut PuzzleClearBoard, group_id: &str, positions: &[(u32, u32)]) { for (row, col) in positions { if let Some(cell) = cell_mut(board, *row, *col) { cell.locked_group_id = Some(group_id.to_string()); } } } fn card(group_id: &str, part_x: u32, part_y: u32) -> PuzzleClearCard { card_shape(group_id, PuzzleClearShapeKind::OneByTwo, part_x, part_y) } fn card_shape( group_id: &str, shape: PuzzleClearShapeKind, part_x: u32, part_y: u32, ) -> PuzzleClearCard { PuzzleClearCard { card_id: format!("{group_id}-{part_x}-{part_y}"), group_id: group_id.to_string(), shape, orientation: PuzzleClearOrientation::Horizontal, part_x, part_y, image_src: format!("/{group_id}-{part_x}-{part_y}.png"), image_object_key: format!("{group_id}-{part_x}-{part_y}.png"), asset_object_id: format!("{group_id}-{part_x}-{part_y}-object"), source_atlas_cell: format!("{group_id}:{part_x}:{part_y}"), } } }