1838 lines
56 KiB
Rust
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}"),
|
|
}
|
|
}
|
|
}
|