Files
Genarrative/server-rs/crates/module-puzzle-clear/src/application.rs

1838 lines
56 KiB
Rust

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<PuzzleClearLevelConfig> {
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<PuzzleClearShapeQuota> {
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<Vec<PuzzleClearPatternGroup>, 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<PuzzleClearCard> {
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<PuzzleClearCard>,
) -> Result<PuzzleClearBoard, PuzzleClearError> {
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::<Vec<_>>();
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<PuzzleClearRunSnapshot, PuzzleClearError> {
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<PuzzleClearRunSnapshot, PuzzleClearError> {
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<PuzzleClearRunSnapshot, PuzzleClearError> {
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<PuzzleClearRunSnapshot, PuzzleClearError> {
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<PuzzleClearRunSnapshot, PuzzleClearError> {
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<PuzzleClearElimination> {
let mut by_group: HashMap<String, Vec<(u32, u32, PuzzleClearCard)>> = 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::<BTreeSet<_>>();
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<PuzzleClearElimination> {
let mut by_group: HashMap<String, Vec<(u32, u32, PuzzleClearCard)>> = 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<u32, PuzzleClearError> {
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::<Vec<_>>();
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::<BTreeSet<_>>();
let new_set = new_positions.iter().copied().collect::<BTreeSet<_>>();
if old_set == new_set {
return Ok(());
}
let old_only = old_set.difference(&new_set).copied().collect::<Vec<_>>();
let new_only = new_set.difference(&old_set).copied().collect::<Vec<_>>();
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::<HashMap<_, _>>();
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<Option<PuzzleClearCard>, 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<PuzzleClearCard>,
) -> 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<PuzzleClearCard>],
preferred_col: usize,
board: &PuzzleClearBoard,
) -> Option<PuzzleClearCard> {
fn matching_refill_index(
column: &[PuzzleClearCard],
board: &PuzzleClearBoard,
) -> Option<usize> {
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<usize> {
(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<PuzzleClearPatternGroupSpec> {
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<T>(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<Option<PuzzleClearCard>>) -> 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}"),
}
}
}