mod edges;
mod errors;
mod hexoban_board;
mod index;
mod iterator;
mod octoban_board;
mod parser;
mod sokoban_board;
mod transform;
mod trioban_board;
use std::num::NonZeroU16;
pub use edges::{HorizontalEdge, VerticalEdge};
pub use errors::{BoardSyntaxError, BoardSyntaxErrorKind};
use hexoban_board::HexobanBoardParser;
pub use iterator::GameBoardIter;
use octoban_board::OctobanBoardParser;
pub(crate) use parser::is_board_line;
use parser::{
BoardParser, BoardPrinter, BoardRect, BoardResizer, ParsedBoard, grown, normalize_dimensions,
};
use sokoban_board::SokobanBoardParser;
pub(crate) use transform::BoardTransform;
pub use transform::{MirrorAxis, Rotation};
use trioban_board::TriobanBoardParser;
use crate::{
Boxbox, Goal, Point2D, Pusher, Size2D, SokobanPlus, StaticBoardAnalysis, Tessellation, TileKind,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GameBoard {
data: ParsedBoard,
tessellation: Tessellation,
}
impl GameBoard {
pub fn new(width: usize, height: usize, tessellation: Tessellation) -> Self {
if width == 0 || height == 0 {
return Self {
data: ParsedBoard::new(),
tessellation,
};
}
let row = vec![TileKind::Floor; width];
Self {
data: std::iter::repeat_n(row, height).collect(),
tessellation,
}
}
pub fn tessellation(&self) -> Tessellation { self.tessellation }
pub fn parsed(
printed_board: &str, tessellation: Tessellation,
) -> Result<Self, BoardSyntaxError> {
let mut data = match tessellation {
Tessellation::Sokoban => SokobanBoardParser::parse(printed_board),
Tessellation::Hexoban => HexobanBoardParser::parse(printed_board),
Tessellation::Trioban => TriobanBoardParser::parse(printed_board),
Tessellation::Octoban => OctobanBoardParser::parse(printed_board),
}?;
normalize_dimensions(&mut data);
Ok(Self { data, tessellation })
}
pub fn parsed_from_tiles(
data: &[TileKind], width: usize, height: usize, tessellation: Tessellation,
) -> Result<Self, BoardSyntaxError> {
if width.checked_mul(height) != Some(data.len()) {
return Err(BoardSyntaxError::new(
BoardSyntaxErrorKind::InputSizeNotMatchedByDeclaredSize {
input: data.len(),
declared_width: width,
declared_height: height,
},
));
}
let body: ParsedBoard = if width == 0 || height == 0 {
ParsedBoard::new()
} else {
data.chunks_exact(width).map(<[TileKind]>::to_vec).collect()
};
Ok(Self {
data: body,
tessellation,
})
}
pub fn printed(&self, visible_floor: bool) -> String {
match self.tessellation {
Tessellation::Sokoban => SokobanBoardParser::print(&self.data, visible_floor),
Tessellation::Trioban => TriobanBoardParser::print(&self.data, visible_floor),
Tessellation::Hexoban => HexobanBoardParser::print(&self.data, visible_floor),
Tessellation::Octoban => OctobanBoardParser::print(&self.data, visible_floor),
}
}
pub fn width(&self) -> usize {
if self.data.is_empty() {
0
} else {
self.data[0].len()
}
}
pub fn height(&self) -> usize { self.data.len() }
pub fn size(&self) -> Size2D {
Size2D {
width: self.width(),
height: self.height(),
}
}
pub fn pushers_count(&self) -> usize {
self.data
.iter()
.flatten()
.filter(|&&x| x.has_pusher())
.count()
}
pub fn boxes_count(&self) -> usize {
self.data.iter().flatten().filter(|&&x| x.has_box()).count()
}
pub fn goals_count(&self) -> usize {
self.data
.iter()
.flatten()
.filter(|&&x| x.has_goal())
.count()
}
pub fn is_blank(&self) -> bool {
self.data
.iter()
.all(|row| row.iter().all(TileKind::is_empty_floor))
}
pub fn pushers(&self) -> Result<Vec<Pusher>, BoardSyntaxError> {
self.iter()
.filter(|(_, tile)| tile.has_pusher())
.enumerate()
.map(|(idx, ((x, y), _))| {
let p = StaticBoardAnalysis::build_pusher(idx, x, y)?;
Ok(p)
})
.collect()
}
pub fn boxes(
&self, sokoban_plus: Option<&SokobanPlus>,
) -> Result<Vec<Boxbox>, BoardSyntaxError> {
self.iter()
.filter(|(_, tile)| tile.has_box())
.enumerate()
.map(|(idx, ((x, y), _))| {
let b = StaticBoardAnalysis::build_box(idx, x, y, sokoban_plus)?;
Ok(b)
})
.collect()
}
pub fn goals(&self, sokoban_plus: Option<&SokobanPlus>) -> Result<Vec<Goal>, BoardSyntaxError> {
self.iter()
.filter(|(_, tile)| tile.has_goal())
.enumerate()
.map(|(idx, ((x, y), _))| {
let g = StaticBoardAnalysis::build_goal(idx, x, y, sokoban_plus)?;
Ok(g)
})
.collect()
}
pub fn iter(&self) -> GameBoardIter<'_> { GameBoardIter::new(self) }
fn normalize_dimensions(&mut self) { normalize_dimensions(&mut self.data); }
fn crop(&mut self, source: BoardRect) {
match self.tessellation {
Tessellation::Sokoban => SokobanBoardParser::crop(&mut self.data, source),
Tessellation::Trioban => TriobanBoardParser::crop(&mut self.data, source),
Tessellation::Hexoban => HexobanBoardParser::crop(&mut self.data, source),
Tessellation::Octoban => OctobanBoardParser::crop(&mut self.data, source),
}
self.normalize_dimensions();
}
pub fn mirror(&mut self, axis: MirrorAxis) {
let transform = match axis {
MirrorAxis::Horizontal => BoardTransform::MirrorHorizontal,
MirrorAxis::Vertical => BoardTransform::MirrorVertical,
};
self.data = transform::transformed(&self.data, self.tessellation, transform);
self.normalize_dimensions();
}
pub fn rotate(&mut self, rotation: Rotation) {
let transform = match rotation {
Rotation::Clockwise => BoardTransform::RotateClockwise,
Rotation::CounterClockwise => BoardTransform::RotateCounterClockwise,
};
self.data = transform::transformed(&self.data, self.tessellation, transform);
self.normalize_dimensions();
}
pub fn grow(&mut self, width: NonZeroU16, height: NonZeroU16) {
let target_width = self.width().max(usize::from(width.get()));
let target_height = self.height().max(usize::from(height.get()));
grown(&mut self.data, target_width, target_height);
self.normalize_dimensions();
}
pub fn add_rows(&mut self, edge: HorizontalEdge, count: usize) -> usize {
let applied = match self.tessellation {
Tessellation::Sokoban => SokobanBoardParser::add_rows(&mut self.data, edge, count),
Tessellation::Trioban => TriobanBoardParser::add_rows(&mut self.data, edge, count),
Tessellation::Hexoban => HexobanBoardParser::add_rows(&mut self.data, edge, count),
Tessellation::Octoban => OctobanBoardParser::add_rows(&mut self.data, edge, count),
};
self.normalize_dimensions();
applied
}
pub fn remove_rows(&mut self, edge: HorizontalEdge, count: usize) -> usize {
let applied = match self.tessellation {
Tessellation::Sokoban => SokobanBoardParser::remove_rows(&mut self.data, edge, count),
Tessellation::Trioban => TriobanBoardParser::remove_rows(&mut self.data, edge, count),
Tessellation::Hexoban => HexobanBoardParser::remove_rows(&mut self.data, edge, count),
Tessellation::Octoban => OctobanBoardParser::remove_rows(&mut self.data, edge, count),
};
self.normalize_dimensions();
applied
}
pub fn add_columns(&mut self, edge: VerticalEdge, count: usize) -> usize {
let applied = match self.tessellation {
Tessellation::Sokoban => SokobanBoardParser::add_columns(&mut self.data, edge, count),
Tessellation::Trioban => TriobanBoardParser::add_columns(&mut self.data, edge, count),
Tessellation::Hexoban => HexobanBoardParser::add_columns(&mut self.data, edge, count),
Tessellation::Octoban => OctobanBoardParser::add_columns(&mut self.data, edge, count),
};
self.normalize_dimensions();
applied
}
pub fn remove_columns(&mut self, edge: VerticalEdge, count: usize) -> usize {
let applied = match self.tessellation {
Tessellation::Sokoban => {
SokobanBoardParser::remove_columns(&mut self.data, edge, count)
}
Tessellation::Trioban => {
TriobanBoardParser::remove_columns(&mut self.data, edge, count)
}
Tessellation::Hexoban => {
HexobanBoardParser::remove_columns(&mut self.data, edge, count)
}
Tessellation::Octoban => {
OctobanBoardParser::remove_columns(&mut self.data, edge, count)
}
};
self.normalize_dimensions();
applied
}
pub fn trim(&mut self) {
match self.tessellation {
Tessellation::Sokoban => SokobanBoardParser::trim(&mut self.data),
Tessellation::Trioban => TriobanBoardParser::trim(&mut self.data),
Tessellation::Hexoban => HexobanBoardParser::trim(&mut self.data),
Tessellation::Octoban => OctobanBoardParser::trim(&mut self.data),
}
self.normalize_dimensions();
}
pub fn enclose(&mut self) -> Result<(), BoardSyntaxError> {
let analysis = StaticBoardAnalysis::new(
self.iter().map(|(_, t)| t),
self.size(),
self.tessellation,
None,
)?;
let Some((top_left, bottom_right)) = analysis.playable_area().bounds() else {
return Ok(());
};
let touches_top = top_left.y == 0;
let touches_left = top_left.x == 0;
let touches_bottom = bottom_right.y.saturating_add(1) >= self.height();
let touches_right = bottom_right.x.saturating_add(1) >= self.width();
if !(touches_top || touches_bottom || touches_left || touches_right) {
return Ok(());
}
if touches_bottom {
self.add_rows(HorizontalEdge::Bottom, 1);
}
if touches_right {
self.add_columns(VerticalEdge::Right, 1);
}
if touches_top {
self.add_rows(HorizontalEdge::Top, 1);
}
if touches_left {
self.add_columns(VerticalEdge::Left, 1);
}
let (width, height) = (self.width(), self.height());
for y in 0..height {
for x in 0..width {
let is_outer_edge = x == 0
|| y == 0
|| x.saturating_add(1) == width
|| y.saturating_add(1) == height;
if is_outer_edge && self[(x, y)].is_empty_floor() {
self[(x, y)] = TileKind::Wall;
}
}
}
self.normalize_dimensions();
Ok(())
}
pub fn normalize(&mut self) -> Result<(), BoardSyntaxError> {
let analysis = StaticBoardAnalysis::new(
self.iter().map(|(_, t)| t),
self.size(),
self.tessellation,
None,
)?;
let significant_walls = analysis.significant_walls();
let mut retained = significant_walls.clone();
for pos in analysis.playable_area() {
retained.add_point(pos);
}
let Some((top_left, bottom_right)) = retained.bounds() else {
return Ok(());
};
let tmp: Vec<Point2D> = self
.iter()
.filter_map(|((x, y), _)| {
let pos = Point2D::new(x, y);
if retained.contains(pos) {
None
} else {
Some(pos)
}
})
.collect();
for pos in tmp {
self[(pos.x, pos.y)] = TileKind::Floor;
}
for pos in &significant_walls {
self[(pos.x, pos.y)] = TileKind::Wall;
}
self.crop(BoardRect {
left: top_left.x,
top: top_left.y,
width: bottom_right.x.saturating_sub(top_left.x).saturating_add(1),
height: bottom_right.y.saturating_sub(top_left.y).saturating_add(1),
});
Ok(())
}
}
#[cfg(feature = "serde")]
mod serde_impl {
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use super::GameBoard;
use crate::Tessellation;
#[derive(Serialize, Deserialize)]
#[serde(rename = "GameBoard", deny_unknown_fields)]
struct GameBoardRepr {
tessellation: Tessellation,
board: String,
}
impl Serialize for GameBoard {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
GameBoardRepr {
tessellation: self.tessellation,
board: self.printed(true),
}
.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for GameBoard {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let repr = GameBoardRepr::deserialize(deserializer)?;
GameBoard::parsed(&repr.board, repr.tessellation).map_err(D::Error::custom)
}
}
}
#[cfg(test)]
mod tests {
use std::error::Error;
use indoc::indoc;
use super::*;
use crate::{BoxID, GoalID, PlusID, PusherID};
fn board_text() -> String {
[
" #####",
" # #",
" #$ #",
" ### $##",
" # $ $ #",
"### # ## # ######",
"# # ## ##### ..#",
"# $ $ ..#",
"##### ### #@## ..#",
" # #########",
" #######",
]
.join("\n")
}
fn board() -> Result<GameBoard, BoardSyntaxError> {
GameBoard::parsed(&board_text(), Tessellation::Sokoban)
}
fn assert_invariant(board: &GameBoard) {
assert!(
board.data.iter().all(|row| row.len() == board.width()),
"rows are not width-normalized: {:?}",
board.data
);
assert_eq!(
board.width() == 0,
board.height() == 0,
"board is {}x{}, but a board with no columns must have no rows",
board.width(),
board.height()
);
assert_eq!(board.iter().count(), board.width() * board.height());
}
fn assert_empty(board: &GameBoard) {
assert_invariant(board);
assert_eq!(board.width(), 0);
assert_eq!(board.height(), 0);
assert!(board.data.is_empty());
assert!(board.iter().next().is_none());
}
fn content_signature(board: &GameBoard) -> Vec<(usize, usize, TileKind)> {
let non_floor: Vec<_> = board
.iter()
.filter(|(_, tile)| *tile != TileKind::Floor)
.collect();
let min_x = non_floor.iter().map(|((x, _), _)| *x).min().unwrap_or(0);
let min_y = non_floor.iter().map(|((_, y), _)| *y).min().unwrap_or(0);
non_floor
.into_iter()
.map(|((x, y), tile)| (x - min_x, y - min_y, tile))
.collect()
}
fn patterned_board(tessellation: Tessellation) -> Result<GameBoard, BoardSyntaxError> {
let markers = [
TileKind::Wall,
TileKind::Pusher,
TileKind::Box,
TileKind::Goal,
TileKind::BoxOnGoal,
TileKind::PusherOnGoal,
];
let tiles: Vec<_> = (0..12)
.map(|index| markers[index % markers.len()])
.collect();
GameBoard::parsed_from_tiles(&tiles, 4, 3, tessellation)
}
#[test]
fn it_reports_width_and_height_of_non_square_board() -> Result<(), BoardSyntaxError> {
let board = board()?;
assert_eq!(board.width(), 19);
assert_eq!(board.height(), 11);
Ok(())
}
#[test]
fn when_board_is_constructed_with_zero_dimension_it_is_empty() {
for tessellation in Tessellation::ALL {
assert_empty(&GameBoard::new(0, 5, tessellation));
assert_empty(&GameBoard::new(5, 0, tessellation));
assert_empty(&GameBoard::new(0, 0, tessellation));
}
}
#[test]
fn when_board_is_empty_printing_round_trips() -> Result<(), BoardSyntaxError> {
for tessellation in Tessellation::ALL {
let board = GameBoard::new(0, 0, tessellation);
assert_eq!(board.printed(true), "");
assert_eq!(board.printed(false), "");
assert_eq!(GameBoard::parsed(&board.printed(true), tessellation)?, board);
}
Ok(())
}
#[test]
fn when_board_is_empty_editing_preserves_the_invariant() {
for tessellation in Tessellation::ALL {
let mut board = GameBoard::new(0, 0, tessellation);
board.trim();
assert_empty(&board);
board.mirror(MirrorAxis::Horizontal);
assert_empty(&board);
board.mirror(MirrorAxis::Vertical);
assert_empty(&board);
board.remove_rows(HorizontalEdge::Top, 1);
board.remove_columns(VerticalEdge::Left, 1);
assert_empty(&board);
board.add_rows(HorizontalEdge::Top, 1);
board.add_columns(VerticalEdge::Left, 1);
assert_empty(&board);
}
}
#[test]
fn every_transform_preserves_piece_counts() -> Result<(), BoardSyntaxError> {
for tessellation in Tessellation::ALL {
let original = patterned_board(tessellation)?;
let expected = (
original.boxes_count(),
original.goals_count(),
original.pushers_count(),
);
for transform in [
BoardTransform::MirrorHorizontal,
BoardTransform::MirrorVertical,
BoardTransform::RotateClockwise,
BoardTransform::RotateCounterClockwise,
] {
let mut board = original.clone();
match transform {
BoardTransform::MirrorHorizontal => board.mirror(MirrorAxis::Horizontal),
BoardTransform::MirrorVertical => board.mirror(MirrorAxis::Vertical),
BoardTransform::RotateClockwise => board.rotate(Rotation::Clockwise),
BoardTransform::RotateCounterClockwise => {
board.rotate(Rotation::CounterClockwise);
}
}
assert_eq!(
(board.boxes_count(), board.goals_count(), board.pushers_count()),
expected,
"{tessellation:?} {transform:?}"
);
}
}
Ok(())
}
mod test_parsing {
use super::*;
#[test]
fn it_pads_short_rows_to_board_width() -> Result<(), BoardSyntaxError> {
let board =
GameBoard::parsed(&["###", "#@#####", "###"].join("\n"), Tessellation::Sokoban)?;
assert_eq!(board.width(), 7);
assert_eq!(board.height(), 3);
assert_eq!(board.iter().count(), 21);
assert_eq!(board[(6, 0)], TileKind::Floor);
Ok(())
}
#[test]
fn when_rows_are_padded_only_visible_floor_printing_shows_it()
-> Result<(), BoardSyntaxError> {
let board = board()?;
assert_eq!(board.printed(false), board_text());
let printed = board.printed(true);
assert!(
printed
.split('\n')
.all(|line| line.chars().count() == board.width())
);
assert_eq!(
GameBoard::parsed(&printed, Tessellation::Sokoban)?,
board,
"printing with visible floor must round trip"
);
Ok(())
}
#[test]
fn when_board_text_has_no_columns_it_parses_to_empty_board() -> Result<(), BoardSyntaxError>
{
for text in ["|", "|||", "3|", "2(|)", "3 ", "| ", " | "] {
for tessellation in Tessellation::ALL {
let board = GameBoard::parsed(text, tessellation)?;
assert_empty(&board);
}
}
let board = GameBoard::parsed("-|-", Tessellation::Sokoban)?;
assert_eq!(board.width(), 1);
assert_eq!(board.height(), 2);
assert_invariant(&board);
Ok(())
}
#[test]
fn when_tiles_slice_is_empty_it_constructs_empty_board() -> Result<(), BoardSyntaxError> {
for tessellation in Tessellation::ALL {
assert_empty(&GameBoard::parsed_from_tiles(&[], 0, 5, tessellation)?);
assert_empty(&GameBoard::parsed_from_tiles(&[], 5, 0, tessellation)?);
assert_empty(&GameBoard::parsed_from_tiles(&[], 0, 0, tessellation)?);
}
Ok(())
}
#[test]
fn when_declared_size_does_not_match_tiles_slice_it_errors() {
let err = GameBoard::parsed_from_tiles(&[TileKind::Wall], 0, 5, Tessellation::Sokoban)
.err()
.unwrap();
assert!(matches!(
err.kind(),
BoardSyntaxErrorKind::InputSizeNotMatchedByDeclaredSize { .. }
));
let err = GameBoard::parsed_from_tiles(
&[TileKind::Wall],
usize::MAX,
2,
Tessellation::Sokoban,
)
.err()
.unwrap();
assert!(matches!(
err.kind(),
BoardSyntaxErrorKind::InputSizeNotMatchedByDeclaredSize { .. }
));
}
}
mod test_pieces_and_sokoban_plus {
use super::*;
#[test]
fn it_collects_pieces_matching_the_counters() -> Result<(), BoardSyntaxError> {
let board = board()?;
assert_eq!(board.pushers()?.len(), board.pushers_count());
assert_eq!(board.boxes(None)?.len(), board.boxes_count());
assert_eq!(board.goals(None)?.len(), board.goals_count());
Ok(())
}
#[test]
fn it_assigns_piece_ids_in_row_major_order() -> Result<(), Box<dyn Error>> {
let board = board()?;
for (idx, pusher) in board.pushers()?.iter().enumerate() {
assert_eq!(pusher.id, PusherID::try_from(idx)?);
assert!(board[(pusher.x, pusher.y)].has_pusher());
}
let boxes = board.boxes(None)?;
for (idx, boxbox) in boxes.iter().enumerate() {
assert_eq!(boxbox.id, BoxID::try_from(idx)?);
assert!(board[(boxbox.x, boxbox.y)].has_box());
assert_eq!(boxbox.plus_id, None);
}
let positions: Vec<usize> = boxes.iter().map(|b| b.y * board.width() + b.x).collect();
assert!(positions.windows(2).all(|w| w[0] < w[1]));
for (idx, goal) in board.goals(None)?.iter().enumerate() {
assert_eq!(goal.id, GoalID::try_from(idx)?);
assert!(board[(goal.x, goal.y)].has_goal());
assert_eq!(goal.plus_id, None);
}
Ok(())
}
#[test]
fn when_piece_is_on_goal_it_is_both_a_piece_and_a_goal() -> Result<(), BoardSyntaxError> {
let board = GameBoard::parsed("#######|#-$.+-*#|#######", Tessellation::Sokoban)?;
let boxes = board.boxes(None)?;
let goals = board.goals(None)?;
let pushers = board.pushers()?;
assert_eq!(
boxes
.iter()
.map(|piece| (piece.x, piece.y))
.collect::<Vec<_>>(),
vec![(2, 1), (6, 1)]
);
assert_eq!(
goals
.iter()
.map(|piece| (piece.x, piece.y))
.collect::<Vec<_>>(),
vec![(3, 1), (4, 1), (6, 1)]
);
assert_eq!(
pushers
.iter()
.map(|piece| (piece.x, piece.y))
.collect::<Vec<_>>(),
vec![(4, 1)]
);
Ok(())
}
#[test]
fn when_board_is_empty_it_has_no_pieces() -> Result<(), BoardSyntaxError> {
for tessellation in Tessellation::ALL {
let board = GameBoard::new(0, 0, tessellation);
assert!(board.pushers()?.is_empty());
assert!(board.boxes(None)?.is_empty());
assert!(board.goals(None)?.is_empty());
}
Ok(())
}
#[test]
fn it_reads_plus_ids_from_sokoban_plus() -> Result<(), Box<dyn std::error::Error>> {
let board = board()?;
assert_eq!(board.boxes_count(), 6);
assert_eq!(board.goals_count(), 6);
let sokoban_plus = SokobanPlus::parsed("1 0 2", "2 0 1")?;
let boxes = board.boxes(Some(&sokoban_plus))?;
let plus_id = |id: u16| PlusID::new(id).ok();
assert_eq!(
boxes.iter().map(|piece| piece.plus_id).collect::<Vec<_>>(),
vec![plus_id(1), None, plus_id(2), None, None, None]
);
let goals = board.goals(Some(&sokoban_plus))?;
assert_eq!(
goals.iter().map(|piece| piece.plus_id).collect::<Vec<_>>(),
vec![plus_id(2), None, plus_id(1), None, None, None]
);
Ok(())
}
#[test]
fn when_board_has_more_pushers_than_pusher_id_can_represent_it_errors()
-> Result<(), BoardSyntaxError> {
let pushers_count = usize::from(PusherID::MAX) + 1;
let just_enough = GameBoard::parsed(&"@".repeat(pushers_count), Tessellation::Sokoban)?;
assert_eq!(just_enough.pushers()?.len(), pushers_count);
let one_too_many =
GameBoard::parsed(&"@".repeat(pushers_count + 1), Tessellation::Sokoban)?;
let result = one_too_many.pushers();
assert!(result.is_err(), "one pusher over PusherID::MAX must be an error");
if let Err(err) = result {
assert!(matches!(err.kind(), BoardSyntaxErrorKind::TooManyPushers));
assert!(err.source().is_some(), "cause of TooManyPushers must be preserved");
assert!(
err.to_string().contains(&pushers_count.to_string()),
"error message must state the supported maximum count: {err}"
);
}
Ok(())
}
}
mod test_rotation {
use super::*;
#[test]
fn mirror_and_rotation_round_trips_restore_content() -> Result<(), BoardSyntaxError> {
for tessellation in Tessellation::ALL {
let original = patterned_board(tessellation)?;
let expected = content_signature(&original);
for axis in [MirrorAxis::Horizontal, MirrorAxis::Vertical] {
let mut board = original.clone();
board.mirror(axis);
board.mirror(axis);
assert_eq!(content_signature(&board), expected, "{tessellation:?} {axis:?}");
}
let mut board = original.clone();
board.rotate(Rotation::Clockwise);
board.rotate(Rotation::CounterClockwise);
assert_eq!(
content_signature(&board),
expected,
"{tessellation:?} inverse rotations"
);
let mut board = original;
let steps = 360 / usize::from(tessellation.rotation_step_degrees());
for _ in 0..steps {
board.rotate(Rotation::Clockwise);
}
assert_eq!(content_signature(&board), expected, "{tessellation:?} full turn");
}
Ok(())
}
}
mod test_resizing {
use super::*;
#[test]
fn when_board_is_resized_rows_stay_width_normalized() -> Result<(), BoardSyntaxError> {
let mut board = board()?;
board.add_columns(VerticalEdge::Left, 1);
board.remove_columns(VerticalEdge::Right, 1);
board.add_rows(HorizontalEdge::Top, 1);
board.remove_rows(HorizontalEdge::Bottom, 1);
assert_invariant(&board);
board.trim();
assert_invariant(&board);
board.mirror(MirrorAxis::Vertical);
board.mirror(MirrorAxis::Horizontal);
assert_invariant(&board);
Ok(())
}
#[test]
fn when_all_columns_are_removed_board_becomes_empty() -> Result<(), BoardSyntaxError> {
let mut removed_right = board()?;
removed_right.remove_columns(VerticalEdge::Right, 20);
assert_empty(&removed_right);
let mut removed_left = board()?;
removed_left.remove_columns(VerticalEdge::Left, 20);
assert_empty(&removed_left);
Ok(())
}
#[test]
fn when_all_rows_are_removed_board_becomes_empty() -> Result<(), BoardSyntaxError> {
let mut removed_bottom = board()?;
removed_bottom.remove_rows(HorizontalEdge::Bottom, 11);
assert_empty(&removed_bottom);
let mut removed_top = board()?;
removed_top.remove_rows(HorizontalEdge::Top, 42);
assert_empty(&removed_top);
Ok(())
}
#[test]
fn when_board_is_emptied_by_editing_it_cannot_be_grown_back() -> Result<(), BoardSyntaxError>
{
let mut board = board()?;
board.remove_columns(VerticalEdge::Right, 20);
assert_empty(&board);
board.add_columns(VerticalEdge::Left, 2);
assert_empty(&board);
board.add_rows(HorizontalEdge::Top, 3);
assert_empty(&board);
Ok(())
}
#[test]
fn when_board_is_emptied_by_editing_grow_brings_it_back() -> Result<(), BoardSyntaxError> {
let mut board = board()?;
board.remove_columns(VerticalEdge::Right, 20);
assert_empty(&board);
board.grow(NonZeroU16::new(4).unwrap(), NonZeroU16::new(3).unwrap());
assert_eq!(board.size(), Size2D::new(4, 3));
assert_invariant(&board);
assert!(board.iter().all(|(_, tile)| tile == TileKind::Floor));
Ok(())
}
#[test]
fn grow_never_shrinks_a_board() {
let mut board = GameBoard::new(5, 4, Tessellation::Sokoban);
board.grow(NonZeroU16::new(2).unwrap(), NonZeroU16::new(1).unwrap());
assert_eq!(board.size(), Size2D::new(5, 4));
board.grow(NonZeroU16::new(9).unwrap(), NonZeroU16::new(1).unwrap());
assert_eq!(board.size(), Size2D::new(9, 4));
board.grow(NonZeroU16::new(1).unwrap(), NonZeroU16::new(7).unwrap());
assert_eq!(board.size(), Size2D::new(9, 7));
assert_invariant(&board);
}
#[test]
fn grow_adds_no_padding_and_moves_no_tile_in_any_tessellation() {
for tessellation in Tessellation::ALL {
let mut board = GameBoard::new(3, 3, tessellation);
board[(0, 0)] = TileKind::Wall;
board[(2, 1)] = TileKind::Box;
board[(1, 2)] = TileKind::Pusher;
let before: Vec<((usize, usize), TileKind)> = board.iter().collect();
board.grow(NonZeroU16::new(6).unwrap(), NonZeroU16::new(4).unwrap());
assert_eq!(
board.size(),
Size2D::new(6, 4),
"{tessellation:?} did not grow to the wanted size"
);
for (position, tile) in before {
assert_eq!(
board[position], tile,
"{tessellation:?} moved the tile at {position:?}"
);
}
assert_invariant(&board);
}
}
#[test]
fn when_one_column_is_left_board_can_still_grow() -> Result<(), BoardSyntaxError> {
let mut board = board()?;
board.remove_columns(VerticalEdge::Right, 18);
assert_eq!(board.width(), 1);
assert_eq!(board.height(), 11);
board.add_columns(VerticalEdge::Left, 1);
board.add_rows(HorizontalEdge::Top, 2);
assert_eq!(board.width(), 2);
assert_eq!(board.height(), 13);
assert_invariant(&board);
Ok(())
}
#[test]
fn resizer_implementors_identify_their_tessellation() {
assert_eq!(SokobanBoardParser::TESSELLATION, Tessellation::Sokoban);
assert_eq!(HexobanBoardParser::TESSELLATION, Tessellation::Hexoban);
assert_eq!(TriobanBoardParser::TESSELLATION, Tessellation::Trioban);
assert_eq!(OctobanBoardParser::TESSELLATION, Tessellation::Octoban);
}
#[test]
fn when_board_is_all_floor_trim_empties_it() -> Result<(), BoardSyntaxError> {
let mut board = GameBoard::parsed("---", Tessellation::Sokoban)?;
assert!(board.is_blank());
board.trim();
assert_empty(&board);
Ok(())
}
#[test]
fn trim_empties_exactly_the_boards_that_are_blank() -> Result<(), BoardSyntaxError> {
for source in ["---", "-\n-\n-", "@", ".", "+", "@--#", ".--#", "-#-"] {
let mut board = GameBoard::parsed(source, Tessellation::Sokoban)?;
let was_blank = board.is_blank();
board.trim();
assert_eq!(board.width() == 0, was_blank, "{source}");
}
Ok(())
}
}
mod test_normalization {
use super::*;
#[test]
fn it_can_normalize_board() -> Result<(), BoardSyntaxError> {
let src = indoc! {" ########
## *. #*#
##### @# #*#
# $.$######
# # $ .#
###### $ .#
#######
# #"};
let mut board = GameBoard::parsed(src, Tessellation::Sokoban)?;
board.normalize()?;
let result = indoc! {" ######
## *. #
#### @# #
# $.$#####
# # $ .#
##### $ .#
#######"};
assert_eq!(board.printed(false), result);
Ok(())
}
#[test]
fn it_can_normalize_sokoban_board() -> Result<(), BoardSyntaxError> {
let src = indoc! {"##### ###########
#@ ###### ##
#$ # # . * $ #
#. *#$ ## #
##### $### ######
## $ $ # ##
### # ## # ######
# # ## ##### ..#
# $ $ ..#
##### ### #@ .+#
# ## #########
# #### ## ### #
######## # ### #
# ### ####### #
# ## #
###################"};
let mut board = GameBoard::parsed(src, Tessellation::Sokoban)?;
board.normalize()?;
let result = indoc! {"#####
#@ #####
#$ # #
#. *#$ #
##### $###
# $ $ ##
### # ## # #####
# # ## ##### ..#
# $ $ ..#
##### ### #@ .+#
# #########
#### # # #
###### # # # #
# ### ####### #
# ## #
###################"};
assert_eq!(board.printed(false), result);
Ok(())
}
#[test]
fn normalize_keeps_playable_tiles_on_the_board_edge() -> Result<(), BoardSyntaxError> {
for source in ["-@-", "@#", "#@", "@--#", "#--@"] {
let mut board = GameBoard::parsed(source, Tessellation::Sokoban)?;
board.normalize()?;
assert_eq!(board.printed(true), source, "{source}");
}
Ok(())
}
#[test]
fn when_board_has_no_pusher_normalize_leaves_it_alone() -> Result<(), BoardSyntaxError> {
let source = indoc! {"#####
#-$.#
#####"};
let mut board = GameBoard::parsed(source, Tessellation::Sokoban)?;
board.normalize()?;
assert_eq!(board.printed(true), source);
let mut board = GameBoard::parsed("-$.-", Tessellation::Sokoban)?;
board.normalize()?;
assert_eq!(board.printed(true), "-$.-");
for tessellation in Tessellation::ALL {
let mut board = GameBoard::new(0, 0, tessellation);
board.normalize()?;
assert_empty(&board);
}
Ok(())
}
#[test]
fn normalize_preserves_tile_shapes_of_edge_play_areas() -> Result<(), BoardSyntaxError> {
for tessellation in [Tessellation::Trioban, Tessellation::Octoban] {
let mut board = GameBoard::parsed("-@-$-.-", tessellation)?;
let before: Vec<_> = board
.iter()
.filter(|(_, tile)| !tile.is_empty_floor())
.map(|((x, y), tile)| (tile, tessellation.tile_shape_from_pos(x, y)))
.collect();
board.normalize()?;
let after: Vec<_> = board
.iter()
.filter(|(_, tile)| !tile.is_empty_floor())
.map(|((x, y), tile)| (tile, tessellation.tile_shape_from_pos(x, y)))
.collect();
assert_eq!(after, before, "{tessellation:?}");
}
Ok(())
}
#[test]
fn normalization_uses_one_rigid_crop_on_trioban_and_octoban() -> Result<(), BoardSyntaxError>
{
let source = indoc! {"---------
--#####--
--#@$.#--
--#####--
---------"};
for (tessellation, expected_size, first_wall) in [
(Tessellation::Trioban, Size2D::new(7, 3), (1_usize, 0_usize)),
(Tessellation::Octoban, Size2D::new(6, 3), (2, 0)),
] {
let mut board = GameBoard::parsed(source, tessellation)?;
board.normalize()?;
assert_eq!(
board.size(),
expected_size,
"{tessellation:?}\n{}",
board.printed(true)
);
assert_eq!(
board[first_wall],
TileKind::Wall,
"{tessellation:?}\n{}",
board.printed(true)
);
}
Ok(())
}
}
mod test_enclosing {
use super::*;
fn analysis_of(board: &GameBoard) -> Result<StaticBoardAnalysis, BoardSyntaxError> {
StaticBoardAnalysis::new(
board.iter().map(|(_, t)| t),
board.size(),
board.tessellation(),
None,
)
}
#[test]
fn enclose_confines_the_playable_area() -> Result<(), BoardSyntaxError> {
let mut board = GameBoard::parsed("-@-", Tessellation::Sokoban)?;
board.enclose()?;
let expected = indoc! {"#####
#-@-#
#####"};
assert_eq!(board.printed(true), expected);
assert!(analysis_of(&board)?.is_playable_area_confined());
let mut board = GameBoard::parsed("@", Tessellation::Sokoban)?;
board.enclose()?;
assert_eq!(
board.printed(true),
indoc! {"###
#@#
###"}
);
Ok(())
}
#[test]
fn enclose_is_a_no_op_on_a_closed_board() -> Result<(), BoardSyntaxError> {
let source = indoc! {"#####
#@$.#
#####"};
let mut board = GameBoard::parsed(source, Tessellation::Sokoban)?;
assert!(analysis_of(&board)?.is_playable_area_confined());
board.enclose()?;
assert_eq!(board.printed(true), source);
Ok(())
}
#[test]
fn enclose_is_idempotent() -> Result<(), BoardSyntaxError> {
for tessellation in Tessellation::ALL {
let mut once = GameBoard::new(3, 3, tessellation);
once[(1, 1)] = TileKind::Pusher;
once[(2, 1)] = TileKind::Box;
once.enclose()?;
let mut twice = once.clone();
twice.enclose()?;
assert_eq!(twice, once, "{tessellation:?}");
}
Ok(())
}
#[test]
fn when_board_has_no_pusher_enclose_leaves_it_alone() -> Result<(), BoardSyntaxError> {
let mut board = GameBoard::parsed("-$.-", Tessellation::Sokoban)?;
board.enclose()?;
assert_eq!(board.printed(true), "-$.-");
for tessellation in Tessellation::ALL {
let mut board = GameBoard::new(0, 0, tessellation);
board.enclose()?;
assert_empty(&board);
}
Ok(())
}
#[test]
fn enclose_confines_every_tessellation_without_losing_content()
-> Result<(), BoardSyntaxError> {
for tessellation in Tessellation::ALL {
let mut board = GameBoard::new(3, 3, tessellation);
board[(1, 1)] = TileKind::Pusher;
board[(2, 1)] = TileKind::Box;
board[(1, 2)] = TileKind::Goal;
assert!(!analysis_of(&board)?.is_playable_area_confined(), "{tessellation:?}");
board.enclose()?;
assert!(analysis_of(&board)?.is_playable_area_confined(), "{tessellation:?}");
assert_eq!(board.pushers_count(), 1, "{tessellation:?}");
assert_eq!(board.boxes_count(), 1, "{tessellation:?}");
assert_eq!(board.goals_count(), 1, "{tessellation:?}");
assert_invariant(&board);
}
Ok(())
}
#[test]
fn enclose_is_deterministic_when_two_edges_leak() -> Result<(), BoardSyntaxError> {
let source = indoc! {"@-#
--#
###"};
for (tessellation, expected) in [
(Tessellation::Sokoban, Size2D::new(4, 4)),
(Tessellation::Trioban, Size2D::new(5, 5)),
(Tessellation::Octoban, Size2D::new(5, 5)),
] {
let mut board = GameBoard::parsed(source, tessellation)?;
board.enclose()?;
assert_eq!(board.size(), expected, "{tessellation:?}");
assert!(analysis_of(&board)?.is_playable_area_confined(), "{tessellation:?}");
}
Ok(())
}
#[test]
fn enclose_then_normalize_yields_a_closed_normalized_board() -> Result<(), BoardSyntaxError>
{
let mut board = GameBoard::parsed("-@-$-.-", Tessellation::Sokoban)?;
board.enclose()?;
board.normalize()?;
assert!(analysis_of(&board)?.is_playable_area_confined());
assert_eq!(
board.printed(true),
indoc! {"#########
#-@-$-.-#
#########"}
);
Ok(())
}
}
#[cfg(feature = "serde")]
mod test_serde {
use super::*;
fn round_tripped(board: &GameBoard) -> Result<GameBoard, Box<dyn Error>> {
let json = serde_json::to_string(board)?;
Ok(serde_json::from_str::<GameBoard>(&json)?)
}
fn assert_round_trips(board: &GameBoard) -> Result<(), Box<dyn Error>> {
let parsed = round_tripped(board)?;
assert_eq!(parsed, *board);
assert_eq!(parsed.width(), board.width());
assert_eq!(parsed.height(), board.height());
assert_invariant(&parsed);
Ok(())
}
#[test]
fn it_round_trips_through_json() -> Result<(), Box<dyn Error>> {
for tessellation in [
Tessellation::Sokoban,
Tessellation::Trioban,
Tessellation::Octoban,
] {
let board = GameBoard::parsed(&board_text(), tessellation)?;
assert_round_trips(&board)?;
}
Ok(())
}
#[test]
fn it_round_trips_hexoban_boards() -> Result<(), Box<dyn Error>> {
let scheme1_type1 = indoc! {"---#-#-#-#----------
--#-------#---------
-#-@-----#----------
--#-$---$-#-#-#-#-#-
-#---.---.---------#
--#---------------#-
-#-#-#-#-#-#-#-#-#--"};
let scheme1_type2 = indoc! {"---#-#-#-#-----------
--#-------#----------
-#-@-----#-----------
--#-$---$-#-#-#-#-#--
-#---.---.---------#-
--#-----------------#
-#-#-#-#-#-#-#-#-#-#-"};
for src in [scheme1_type1, scheme1_type2] {
let board = GameBoard::parsed(src, Tessellation::Hexoban)?;
assert_round_trips(&board)?;
}
Ok(())
}
#[test]
fn it_round_trips_resized_hexoban_board() -> Result<(), Box<dyn Error>> {
let markers = [
TileKind::Wall,
TileKind::Pusher,
TileKind::Box,
TileKind::Goal,
TileKind::BoxOnGoal,
TileKind::PusherOnGoal,
];
let mut board = GameBoard::parsed_from_tiles(&markers, 3, 2, Tessellation::Hexoban)?;
board.add_rows(HorizontalEdge::Top, 1);
board.remove_rows(HorizontalEdge::Top, 1);
assert_round_trips(&board)?;
Ok(())
}
#[test]
fn it_round_trips_resized_trioban_and_octoban_boards() -> Result<(), Box<dyn Error>> {
let markers = [
TileKind::Wall,
TileKind::Pusher,
TileKind::Box,
TileKind::Goal,
TileKind::BoxOnGoal,
TileKind::PusherOnGoal,
];
for tessellation in [Tessellation::Trioban, Tessellation::Octoban] {
let mut board = GameBoard::parsed_from_tiles(&markers, 3, 2, tessellation)?;
board.add_rows(HorizontalEdge::Top, 1);
board.add_columns(VerticalEdge::Left, 1);
assert_round_trips(&board)?;
}
Ok(())
}
#[test]
fn when_hexoban_content_sits_in_rows_of_one_parity_only_it_round_trips()
-> Result<(), Box<dyn Error>> {
for row in [0, 1] {
let mut board = GameBoard::new(5, 4, Tessellation::Hexoban);
board[(0, row)] = TileKind::Wall;
board[(2, row)] = TileKind::Wall;
board[(4, row)] = TileKind::Box;
assert_round_trips(&board)?;
}
Ok(())
}
#[test]
fn when_board_is_all_floor_it_round_trips() -> Result<(), Box<dyn Error>> {
for tessellation in Tessellation::ALL {
assert_round_trips(&GameBoard::new(6, 3, tessellation))?;
}
Ok(())
}
#[test]
fn when_board_is_single_row_it_round_trips() -> Result<(), Box<dyn Error>> {
for tessellation in Tessellation::ALL {
let mut board = GameBoard::new(5, 1, tessellation);
board[(0, 0)] = TileKind::Wall;
board[(2, 0)] = TileKind::Pusher;
assert_round_trips(&board)?;
}
Ok(())
}
#[test]
fn when_board_is_empty_it_round_trips() -> Result<(), Box<dyn Error>> {
for tessellation in Tessellation::ALL {
assert_round_trips(&GameBoard::new(0, 5, tessellation))?;
assert_round_trips(&GameBoard::new(4, 0, tessellation))?;
}
Ok(())
}
#[test]
fn it_preserves_trailing_floor_columns() -> Result<(), Box<dyn Error>> {
let board = GameBoard::parsed("#@$.#\n#----", Tessellation::Sokoban)?;
assert_eq!(board.width(), 5);
assert_round_trips(&board)?;
Ok(())
}
#[test]
fn it_deserializes_fields_in_any_order() -> Result<(), Box<dyn Error>> {
let board: GameBoard =
serde_json::from_str(r##"{"board": "#@$.#", "tessellation": "Sokoban"}"##)?;
assert_eq!(board, GameBoard::parsed("#@$.#", Tessellation::Sokoban)?);
Ok(())
}
#[test]
fn it_rejects_unknown_fields() {
let result = serde_json::from_str::<GameBoard>(
r##"{"tessellation": "Sokoban", "board": "#@$.#", "width": 5}"##,
);
assert!(result.is_err());
}
#[test]
fn it_rejects_board_text_that_does_not_parse() {
let result = serde_json::from_str::<GameBoard>(
r##"{"tessellation": "Hexoban", "board": "#-##-#"}"##,
);
assert!(result.is_err());
}
}
}