sokoban_elements/game_board.rs
1mod edges;
2mod errors;
3mod hexoban_board;
4mod index;
5mod iterator;
6mod octoban_board;
7mod parser;
8mod sokoban_board;
9mod transform;
10mod trioban_board;
11
12use std::num::NonZeroU16;
13
14pub use edges::{HorizontalEdge, VerticalEdge};
15pub use errors::{BoardSyntaxError, BoardSyntaxErrorKind};
16use hexoban_board::HexobanBoardParser;
17pub use iterator::GameBoardIter;
18use octoban_board::OctobanBoardParser;
19pub(crate) use parser::is_board_line;
20use parser::{
21 BoardParser, BoardPrinter, BoardRect, BoardResizer, ParsedBoard, grown, normalize_dimensions,
22};
23use sokoban_board::SokobanBoardParser;
24pub(crate) use transform::BoardTransform;
25pub use transform::{MirrorAxis, Rotation};
26use trioban_board::TriobanBoardParser;
27
28use crate::{
29 Boxbox, Goal, Point2D, Pusher, Size2D, SokobanPlus, StaticBoardAnalysis, Tessellation, TileKind,
30};
31
32/// Parsed game board for some [`Tessellation`](crate::Tessellation).
33///
34/// Encapsulates game board as 2D matrix of [`TileKind`](crate::TileKind).
35///
36/// - coordinate system origin `(0, 0)` is at the top left of board
37/// - `X` axis points from left to right
38/// - `Y` axis points from top to down
39///
40/// `GameBoard` is end result of parsing and editing game puzzles. As such it is not optimized data
41/// structure playing the game, implementing game engines or game solvers. `GameBoard` is meant to
42/// be used only as input for these uses and will have to be converted into more suitable data
43/// structure.
44///
45/// `GameBoard` doesn't place any restrictions on it's width, height or on number of pushers, boxes
46/// and goals. When implementing game engines or game solvers, you'll want to validate parsed
47/// `GameBoard` against concrete engine or solver requirements.
48///
49/// [`BoxID`](crate::BoxID), [`GoalID`](crate::GoalID), [`PusherID`](crate::PusherID) and
50/// [`PlusID`](crate::PlusID) ** do restrict** numeric IDs that they can represent. Having
51/// `GameBoard` with more pushers, boxes or goals than these can represent is possible, but you will
52/// not be able to represent ids of it's pieces. Again and by design, there is no mechanism in
53/// `GameBoard` to prevent this situation.
54///
55/// `GameBoard` supports `Index` and `IndexMut` for `(x, y)`, `Point2D` and linear `usize`.
56///
57/// `GameBoard` supports `iter()` yielding row-major `((x, y), TileKind)` pairs.
58///
59/// Depending on [`Tessellation`], a board position has related [`TileShape`](crate::TileShape) that
60/// can be calculated via
61/// [`Tessellation::tile_shape_from_pos(x, y)`](Tessellation::tile_shape_from_pos) or via
62/// [`Point2D::tile_shape(tessellation)`](Point2D::tile_shape)
63///
64/// # Examples
65///
66/// ```rust
67/// use sokoban_elements::*;
68///
69/// # fn main() -> Result<(), BoardSyntaxError> {
70/// let board_text = [
71/// " #####",
72/// " # #",
73/// " #$ #",
74/// " ### $##",
75/// " # $ $ #",
76/// "### # ## # ######",
77/// "# # ## ##### ..#",
78/// "# $ $ ..#",
79/// "##### ### #@## ..#",
80/// " # #########",
81/// " #######",
82/// ]
83/// .join("\n");
84///
85/// let mut board = GameBoard::parsed(&board_text, Tessellation::Sokoban)?;
86///
87/// assert_eq!(board.width(), 19);
88/// assert_eq!(board.height(), 11);
89///
90/// // tiles are read by (x, y)...
91/// assert_eq!(board[(4, 0)], TileKind::Wall);
92///
93/// // ... or by linear board position: y * width + x
94/// let position: usize = 4;
95/// assert_eq!(board[position], TileKind::Wall);
96///
97/// // short rows are padded with floor tiles
98/// assert_eq!(board[(18, 0)], TileKind::Floor);
99///
100/// // tiles are writable
101/// board[(4, 0)] = TileKind::Floor;
102/// assert_eq!(board[(4, 0)], TileKind::Floor);
103///
104/// // iteration is row major over ((x, y), tile) pairs
105/// assert_eq!(board.iter().count(), board.width() * board.height());
106/// assert_eq!(board.iter().next(), Some(((0, 0), TileKind::Floor)));
107///
108/// # Ok(())
109/// # }
110/// ```
111///
112/// # Board size
113///
114/// Board data is always width-normalized: all rows have exactly [`width()`](GameBoard::width())
115/// tiles. Rows shorter than board width are padded with
116/// [`TileKind::Floor`](crate::TileKind::Floor). Parsing, resizing, rotation and mirroring ensure
117/// that this always holds. `Index<(usize, usize)>` and `Index<Point2D>` would've panicked for
118/// perfectly valid board coordinates, if the rows were ragged. `Index<usize>` wouldn't even panic -
119/// in case of ragged rows it would silently return wrong board tile.
120///
121/// A board with no columns and or no rows is empty: [`width()`](GameBoard::width()) is zero if and
122/// only if [`height()`](GameBoard::height()) is zero. Constructing or editing a board in a way that
123/// would leave it with rows of zero length silently produces the empty board instead. There is no
124/// error for this.
125///
126/// These rules is what makes indexing board position well defined.
127///
128/// Resizing edits one edge at a time, so callers control operation order. Additions to a non-empty
129/// board apply `count` exactly, and methods return how many lines were applied.
130///
131/// **Removals saturate.** `remove_rows(Top, 42)` on a 11-row board removes 11, it is not an error.
132/// In that case we'd removed all the rows from the board, so there are no columns left either.
133///
134/// The result of chaining board edge edits depends on order of calls. Any individual step may
135/// collapse the board to size zero (because removals saturate).
136///
137/// Once edge editing empties a board, the one-edge-at-a-time methods cannot grow it back: there are
138/// no rows to add columns to, and rows added to a board with no columns are of zero length, which
139/// is the empty board again. Use [`grow()`](GameBoard::grow()) for that.
140///
141/// `Top` and `Left` resizing shifts existing content. To compensate what would otherwise be a break
142/// in tile geometry, `GameBoard` adds or removes one padding line in perpendicular axis. This means
143/// that `Top` and `Left` resizing may have side effect of changing **both** board dimensions. This
144/// depends on concrete `Tessellation`:
145///
146/// - `add_rows(Top, 1)` on a `Trioban` or `Octoban` board also adds a left column;
147/// `add_columns(Left,1)` also adds a top row.
148/// - `Hexoban` needs it on odd `Top` counts when the retained board spans both row parities. Even
149/// counts need nothing
150///
151/// `Bottom` and `Right` resizing never adds perpendicular padding.
152///
153/// Each resizing count is one batched shift. Adding two `Top` rows to `Trioban` adds no padding,
154/// while two separate one-row calls each add a column.
155///
156/// If you need to tighten the resulting board after this padding, call
157/// [`trim()`](GameBoard::trim()) explicitly.
158///
159/// # Transformations
160///
161/// [`rotate()`](GameBoard::rotate()) applies one tessellation-specific step; see
162/// [`Tessellation::rotation_step_degrees()`]. A transform may add one row or column because its
163/// result must be re-anchored with a rigid motion of the tiling; integer translation periods are
164/// exposed by [`Tessellation::translation_period()`]. A full turn likewise takes four steps for
165/// Sokoban and Octoban, and six for Hexoban and Trioban. Rotation keeps its complete bounding box;
166/// call [`trim()`](GameBoard::trim()) separately when a tight board is wanted.
167///
168/// Mirroring twice restores the original content, but may retain padding added by re-anchoring.
169///
170/// # Normalization and enclosing
171///
172/// [`grow()`](GameBoard::grow()) and [`enclose()`](GameBoard::enclose()) are the two resizing
173/// methods that are not per-edge, because neither takes an edge to work on: `grow()` is told a
174/// size, and `enclose()` derives the edges it must grow from where the play area reaches.
175///
176/// [`enclose()`](GameBoard::enclose()) walls the play area in, so it is the operation that makes a
177/// board "closed".
178///
179/// [`normalize()`](GameBoard::normalize()) does **not** produce a closed board. A play area that
180/// reaches the board edge is confined by the edge, not by a wall, and no wall is invented for it.
181///
182/// # Edits invalidate emergent types and properties
183///
184/// Editing, resizing, mirroring and rotation invalidates everything keyed to position:
185///
186/// - [`BoxID`](crate::BoxID), [`GoalID`](crate::GoalID), and [`PusherID`](crate::PusherID) are
187/// reassigned by scanning the edited board in row-major order
188/// - [`SokobanPlus`](crate::SokobanPlus) is keyed to those IDs and is silently invalidated.
189/// - [`SavedGame`](crate::SavedGame) moves are directional and no longer describe the transformed
190/// board. In particular, Trioban direction changes depend on each triangle's orientation and
191/// cannot be repaired by substituting direction characters.
192/// - Zobrist hashes of boards (game engine feature) are position-keyed and do not survive editing.
193///
194/// Invalidation is natural consequences of how all of above types are defined: they need a stable
195/// board configuration to make any sense. Important thing to have in mind when editing the board:
196/// finish the design before assigning ie. `Sokoban+` to it.
197///
198/// # Serde
199///
200/// With the `serde` feature, `GameBoard` is a two field map of the printed board and the
201/// [`Tessellation`] needed to parse it back:
202///
203/// ```json
204/// { "tessellation": "Sokoban", "board": "#####\n#@$.#\n#####" }
205/// ```
206///
207/// Deserialization goes through [`parsed()`](GameBoard::parsed()), so a document can never produce
208/// a board that violates the width-normalization invariant. Unknown fields are rejected. The board
209/// text is accepted in any form [`parsed()`](GameBoard::parsed()) accepts, run-length encoding
210/// included, but is always written out uncompressed with visible floors.
211///
212/// ```rust
213/// use sokoban_elements::*;
214///
215/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
216/// # #[cfg(feature = "serde")] {
217/// #[rustfmt::skip]
218/// let board_text = [
219/// "#####",
220/// "#@$.#",
221/// "#####",
222/// ]
223/// .join("\n");
224///
225/// let board = GameBoard::parsed(&board_text, Tessellation::Sokoban)?;
226///
227/// let json = serde_json::to_string(&board)?;
228/// assert_eq!(serde_json::from_str::<GameBoard>(&json)?, board);
229/// # }
230/// # Ok(())
231/// # }
232/// ```
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct GameBoard {
235 data: ParsedBoard,
236 tessellation: Tessellation,
237}
238
239impl GameBoard {
240 /// Constructs blank board of given size, all of it's tiles being
241 /// [`TileKind::Floor`](crate::TileKind::Floor).
242 ///
243 /// A board with no columns is the empty board: if either `width` or `height` is zero, both
244 /// [`width()`](GameBoard::width()) and [`height()`](GameBoard::height()) of constructed board
245 /// are zero.
246 ///
247 /// # Examples
248 ///
249 /// ```rust
250 /// use sokoban_elements::*;
251 ///
252 /// let board = GameBoard::new(0, 5, Tessellation::Sokoban);
253 ///
254 /// assert_eq!(board.width(), 0);
255 /// assert_eq!(board.height(), 0);
256 /// ```
257 pub fn new(width: usize, height: usize, tessellation: Tessellation) -> Self {
258 if width == 0 || height == 0 {
259 return Self {
260 data: ParsedBoard::new(),
261 tessellation,
262 };
263 }
264
265 let row = vec![TileKind::Floor; width];
266 Self {
267 data: std::iter::repeat_n(row, height).collect(),
268 tessellation,
269 }
270 }
271
272 pub fn tessellation(&self) -> Tessellation { self.tessellation }
273
274 /// Parses board string in context of given [`Tessellation`].
275 ///
276 /// # Examples
277 ///
278 /// Constructing `GameBoard` from text:
279 ///
280 /// ```rust
281 /// use sokoban_elements::*;
282 ///
283 /// # fn main() -> Result<(), BoardSyntaxError> {
284 /// let board_text = [
285 /// " #####",
286 /// " # #",
287 /// " #$ #",
288 /// " ### $##",
289 /// " # $ $ #",
290 /// "### # ## # ######",
291 /// "# # ## ##### ..#",
292 /// "# $ $ ..#",
293 /// "##### ### #@## ..#",
294 /// " # #########",
295 /// " #######",
296 /// ]
297 /// .join("\n");
298 ///
299 /// let board = GameBoard::parsed(&board_text, Tessellation::Sokoban)?;
300 /// # Ok(())
301 /// # }
302 /// ```
303 ///
304 /// # Grammar
305 ///
306 /// Game board strings use following characters:
307 ///
308 /// | tile | char |
309 /// | -------------- | ------------- |
310 /// | Wall | '#' |
311 /// | Pusher | 'p', '@', 'm' |
312 /// | Pusher on goal | 'P', '+', 'M' |
313 /// | Box | 'b', '$' |
314 /// | Goal | '.' , 'o' |
315 /// | Floor | ' ', '-', '_' |
316 ///
317 /// Game board strings mostly follow
318 /// [SokobanYASC](https://sourceforge.net/projects/sokobanyasc/) specification:
319 ///
320 /// ```sok_file_format
321 /// ::::::::::::::::::::::::::: Board ::::::::::::::::::::::::::
322 /// :: ::
323 /// :: Remarks: ::
324 /// :: ::
325 /// :: The first and the last non-empty square in each row ::
326 /// :: must be a wall or a box on a goal. An empty interior ::
327 /// :: row is written with at least one "-" or "_". ::
328 /// :: ::
329 /// :: Boards may be run-length encoded (RLE), e.g., ::
330 /// :: "###----p.#" may be encoded as "3#4-p.#", and ::
331 /// :: "#-#-#-##-#-#-#" may be encoded as "2(3(#-)#)". ::
332 /// :: A row cannot be split over multiple lines. ::
333 /// :: ::
334 /// :: Rows may be combined on a single line by using "|" ::
335 /// :: as a row separator, e.g., "--3#|3#-#|#pb.#|5#". ::
336 /// :: A "|" at the end of a line is optional and may be ::
337 /// :: omitted. ::
338 /// ```
339 ///
340 /// - [RLE grammar](https://raw.githubusercontent.com/tadams42/sokoban-elements/main/crates/sokoban-elements/doc/img/rle_grammar.svg)
341 pub fn parsed(
342 printed_board: &str, tessellation: Tessellation,
343 ) -> Result<Self, BoardSyntaxError> {
344 let mut data = match tessellation {
345 Tessellation::Sokoban => SokobanBoardParser::parse(printed_board),
346 Tessellation::Hexoban => HexobanBoardParser::parse(printed_board),
347 Tessellation::Trioban => TriobanBoardParser::parse(printed_board),
348 Tessellation::Octoban => OctobanBoardParser::parse(printed_board),
349 }?;
350 normalize_dimensions(&mut data);
351
352 Ok(Self { data, tessellation })
353 }
354
355 /// Constructs board from row major slice of [`TileKind`](crate::TileKind).
356 ///
357 /// A board with no columns is the empty board: if either `width` or `height` is zero, `data`
358 /// must be empty and constructed board is the empty board.
359 ///
360 /// # Errors
361 ///
362 /// [`InputSizeNotMatchedByDeclaredSize`](crate::BoardSyntaxErrorKind::InputSizeNotMatchedByDeclaredSize)
363 /// when length of `data` is not exactly `width * height`. Sizes too large to be represented
364 /// are reported the same way.
365 pub fn parsed_from_tiles(
366 data: &[TileKind], width: usize, height: usize, tessellation: Tessellation,
367 ) -> Result<Self, BoardSyntaxError> {
368 if width.checked_mul(height) != Some(data.len()) {
369 return Err(BoardSyntaxError::new(
370 BoardSyntaxErrorKind::InputSizeNotMatchedByDeclaredSize {
371 input: data.len(),
372 declared_width: width,
373 declared_height: height,
374 },
375 ));
376 }
377
378 // Size check above guarantees `data` is empty when either dimension is zero.
379 let body: ParsedBoard = if width == 0 || height == 0 {
380 ParsedBoard::new()
381 } else {
382 data.chunks_exact(width).map(<[TileKind]>::to_vec).collect()
383 };
384
385 Ok(Self {
386 data: body,
387 tessellation,
388 })
389 }
390
391 pub fn printed(&self, visible_floor: bool) -> String {
392 match self.tessellation {
393 Tessellation::Sokoban => SokobanBoardParser::print(&self.data, visible_floor),
394 Tessellation::Trioban => TriobanBoardParser::print(&self.data, visible_floor),
395 Tessellation::Hexoban => HexobanBoardParser::print(&self.data, visible_floor),
396 Tessellation::Octoban => OctobanBoardParser::print(&self.data, visible_floor),
397 }
398 }
399
400 /// Number of board columns.
401 ///
402 /// Returns `0` for empty board. Board width and [`height()`](GameBoard::height()) are either
403 /// both zero or both greater than zero.
404 pub fn width(&self) -> usize {
405 if self.data.is_empty() {
406 0
407 } else {
408 self.data[0].len()
409 }
410 }
411
412 /// Number of board rows.
413 ///
414 /// Returns `0` for empty board. Board height and [`width()`](GameBoard::width()) are either
415 /// both zero or both greater than zero.
416 pub fn height(&self) -> usize { self.data.len() }
417
418 pub fn size(&self) -> Size2D {
419 Size2D {
420 width: self.width(),
421 height: self.height(),
422 }
423 }
424
425 pub fn pushers_count(&self) -> usize {
426 self.data
427 .iter()
428 .flatten()
429 .filter(|&&x| x.has_pusher())
430 .count()
431 }
432
433 pub fn boxes_count(&self) -> usize {
434 self.data.iter().flatten().filter(|&&x| x.has_box()).count()
435 }
436
437 pub fn goals_count(&self) -> usize {
438 self.data
439 .iter()
440 .flatten()
441 .filter(|&&x| x.has_goal())
442 .count()
443 }
444
445 pub fn is_blank(&self) -> bool {
446 self.data
447 .iter()
448 .all(|row| row.iter().all(TileKind::is_empty_floor))
449 }
450
451 /// All [`Pusher`] pieces on this board, in row major order.
452 ///
453 /// [`PusherID`](crate::PusherID) of each piece is assigned by scanning board top to bottom and
454 /// each row left to right, starting from `0`. See [`PusherID`](crate::PusherID) docs.
455 ///
456 /// Tile with pusher standing on goal
457 /// ([`TileKind::PusherOnGoal`](crate::TileKind::PusherOnGoal)) yields both a [`Pusher`] here
458 /// and a [`Goal`] in [`goals()`](GameBoard::goals()).
459 ///
460 /// # Errors
461 ///
462 /// [`TooManyPushers`](crate::BoardSyntaxErrorKind::TooManyPushers) when board has more pushers
463 /// than [`PusherID`](crate::PusherID) can represent.
464 ///
465 /// # Examples
466 ///
467 /// ```rust
468 /// use sokoban_elements::*;
469 ///
470 /// # fn main() -> Result<(), BoardSyntaxError> {
471 /// #[rustfmt::skip]
472 /// let board_text = [
473 /// "#####",
474 /// "#@$.#",
475 /// "#####",
476 /// ]
477 /// .join("\n");
478 ///
479 /// let board = GameBoard::parsed(&board_text, Tessellation::Sokoban)?;
480 ///
481 /// let pushers = board.pushers()?;
482 ///
483 /// assert_eq!(pushers.len(), board.pushers_count());
484 /// assert_eq!(pushers[0].id, PusherID::from(0_u8));
485 /// assert_eq!((pushers[0].x, pushers[0].y), (1, 1));
486 /// # Ok(())
487 /// # }
488 /// ```
489 pub fn pushers(&self) -> Result<Vec<Pusher>, BoardSyntaxError> {
490 self.iter()
491 .filter(|(_, tile)| tile.has_pusher())
492 .enumerate()
493 .map(|(idx, ((x, y), _))| {
494 let p = StaticBoardAnalysis::build_pusher(idx, x, y)?;
495 Ok(p)
496 })
497 .collect()
498 }
499
500 /// All [`Boxbox`] pieces on this board, in row major order.
501 ///
502 /// [`BoxID`](crate::BoxID) of each piece is assigned by scanning board top to bottom and each
503 /// row left to right, starting from `0`. See [`BoxID`](crate::BoxID) docs.
504 ///
505 /// Tile with box standing on goal ([`TileKind::BoxOnGoal`](crate::TileKind::BoxOnGoal)) yields
506 /// both a `Boxbox` here and a [`Goal`] in [`goals()`](GameBoard::goals()).
507 ///
508 /// `Sokoban+ ID` of each piece is looked up in `sokoban_plus` by assigned
509 /// [`BoxID`](crate::BoxID). Without `sokoban_plus`, no box gets `Sokoban+ ID`. See
510 /// [`SokobanPlus`](crate::SokobanPlus).
511 ///
512 /// # Errors
513 ///
514 /// [`TooManyBoxes`](crate::BoardSyntaxErrorKind::TooManyBoxes) when board has more boxes than
515 /// [`BoxID`](crate::BoxID) can represent.
516 ///
517 /// # Examples
518 ///
519 /// ```rust
520 /// use sokoban_elements::*;
521 ///
522 /// # fn main() -> Result<(), BoardSyntaxError> {
523 /// #[rustfmt::skip]
524 /// let board_text = [
525 /// "#####",
526 /// "#@$*#",
527 /// "#####",
528 /// ]
529 /// .join("\n");
530 ///
531 /// let board = GameBoard::parsed(&board_text, Tessellation::Sokoban)?;
532 ///
533 /// let boxes = board.boxes(None)?;
534 ///
535 /// assert_eq!(boxes.len(), board.boxes_count());
536 /// assert_eq!(boxes[0].id, BoxID::from(0_u8));
537 /// assert_eq!((boxes[0].x, boxes[0].y), (2, 1));
538 /// assert_eq!(boxes[0].plus_id, None);
539 ///
540 /// // box standing on goal is both a box and a goal
541 /// assert_eq!((boxes[1].x, boxes[1].y), (3, 1));
542 /// assert_eq!(board.goals(None)?.len(), 1);
543 /// # Ok(())
544 /// # }
545 /// ```
546 ///
547 /// Reading `Sokoban+ IDs`:
548 ///
549 /// ```rust
550 /// use sokoban_elements::*;
551 ///
552 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
553 /// #[rustfmt::skip]
554 /// let board_text = [
555 /// "#####",
556 /// "#@$*#",
557 /// "#####",
558 /// ]
559 /// .join("\n");
560 ///
561 /// let board = GameBoard::parsed(&board_text, Tessellation::Sokoban)?;
562 /// let sokoban_plus = SokobanPlus::parsed("0 42", "42 0")?;
563 ///
564 /// let boxes = board.boxes(Some(&sokoban_plus))?;
565 ///
566 /// assert_eq!(boxes[0].plus_id, None);
567 /// assert_eq!(boxes[1].plus_id, Some(PlusID::new(42)?));
568 /// # Ok(())
569 /// # }
570 /// ```
571 pub fn boxes(
572 &self, sokoban_plus: Option<&SokobanPlus>,
573 ) -> Result<Vec<Boxbox>, BoardSyntaxError> {
574 self.iter()
575 .filter(|(_, tile)| tile.has_box())
576 .enumerate()
577 .map(|(idx, ((x, y), _))| {
578 let b = StaticBoardAnalysis::build_box(idx, x, y, sokoban_plus)?;
579 Ok(b)
580 })
581 .collect()
582 }
583
584 /// All [`Goal`] pieces on this board, in row major order.
585 ///
586 /// [`GoalID`](crate::GoalID) of each piece is assigned by scanning board top to bottom and each
587 /// row left to right, starting from `0`. See [`GoalID`](crate::GoalID) docs.
588 ///
589 /// Tiles with box or pusher standing on goal
590 /// ([`TileKind::BoxOnGoal`](crate::TileKind::BoxOnGoal),
591 /// [`TileKind::PusherOnGoal`](crate::TileKind::PusherOnGoal)) yield a [`Goal`] here in addition
592 /// to the piece standing on it.
593 ///
594 /// `Sokoban+ ID` of each piece is looked up in `sokoban_plus` by assigned
595 /// [`GoalID`](crate::GoalID). Without `sokoban_plus`, no goal gets `Sokoban+ ID`. See
596 /// [`SokobanPlus`](crate::SokobanPlus).
597 ///
598 /// # Errors
599 ///
600 /// [`TooManyGoals`](crate::BoardSyntaxErrorKind::TooManyGoals) when board has more goals than
601 /// [`GoalID`](crate::GoalID) can represent.
602 ///
603 /// # Examples
604 ///
605 /// ```rust
606 /// use sokoban_elements::*;
607 ///
608 /// # fn main() -> Result<(), BoardSyntaxError> {
609 /// #[rustfmt::skip]
610 /// let board_text = [
611 /// "#####",
612 /// "#+$.#",
613 /// "#####",
614 /// ]
615 /// .join("\n");
616 ///
617 /// let board = GameBoard::parsed(&board_text, Tessellation::Sokoban)?;
618 ///
619 /// let goals = board.goals(None)?;
620 ///
621 /// assert_eq!(goals.len(), board.goals_count());
622 ///
623 /// // pusher standing on goal is both a pusher and a goal
624 /// assert_eq!(goals[0].id, GoalID::from(0_u8));
625 /// assert_eq!((goals[0].x, goals[0].y), (1, 1));
626 /// assert_eq!((goals[1].x, goals[1].y), (3, 1));
627 /// # Ok(())
628 /// # }
629 /// ```
630 pub fn goals(&self, sokoban_plus: Option<&SokobanPlus>) -> Result<Vec<Goal>, BoardSyntaxError> {
631 self.iter()
632 .filter(|(_, tile)| tile.has_goal())
633 .enumerate()
634 .map(|(idx, ((x, y), _))| {
635 let g = StaticBoardAnalysis::build_goal(idx, x, y, sokoban_plus)?;
636 Ok(g)
637 })
638 .collect()
639 }
640
641 /// Row major iterator over `((x, y), tile)` pairs of this board.
642 ///
643 /// # Examples
644 ///
645 /// ```rust
646 /// use sokoban_elements::*;
647 ///
648 /// let board = GameBoard::new(3, 2, Tessellation::Sokoban);
649 ///
650 /// let positions: Vec<(usize, usize)> = board.iter().map(|(position, _)| position).collect();
651 ///
652 /// assert_eq!(positions, vec![(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)]);
653 /// ```
654 pub fn iter(&self) -> GameBoardIter<'_> { GameBoardIter::new(self) }
655
656 /// Restores board data invariant after editing.
657 ///
658 /// Editing primitives are free to produce rows of zero length on their way to a final result;
659 /// a board with no columns is the empty board. Every method editing `self.data` must call
660 /// this. See `GameBoard` docs.
661 fn normalize_dimensions(&mut self) { normalize_dimensions(&mut self.data); }
662
663 fn crop(&mut self, source: BoardRect) {
664 match self.tessellation {
665 Tessellation::Sokoban => SokobanBoardParser::crop(&mut self.data, source),
666 Tessellation::Trioban => TriobanBoardParser::crop(&mut self.data, source),
667 Tessellation::Hexoban => HexobanBoardParser::crop(&mut self.data, source),
668 Tessellation::Octoban => OctobanBoardParser::crop(&mut self.data, source),
669 }
670 self.normalize_dimensions();
671 }
672
673 /// Mirrors the board across `axis` as a rigid motion of its tessellation.
674 ///
675 /// [`MirrorAxis::Horizontal`] exchanges the top and bottom edges;
676 /// [`MirrorAxis::Vertical`] exchanges the left and right edges. Re-anchoring may add one row or
677 /// column to keep tile geometry intact.
678 ///
679 /// # Examples
680 ///
681 /// ```rust
682 /// use sokoban_elements::*;
683 ///
684 /// # fn main() -> Result<(), BoardSyntaxError> {
685 /// let mut board = GameBoard::parsed("@$.", Tessellation::Sokoban)?;
686 /// board.mirror(MirrorAxis::Vertical);
687 /// assert_eq!(board.printed(false), ".$@");
688 /// # Ok(())
689 /// # }
690 /// ```
691 pub fn mirror(&mut self, axis: MirrorAxis) {
692 let transform = match axis {
693 MirrorAxis::Horizontal => BoardTransform::MirrorHorizontal,
694 MirrorAxis::Vertical => BoardTransform::MirrorVertical,
695 };
696 self.data = transform::transformed(&self.data, self.tessellation, transform);
697 self.normalize_dimensions();
698 }
699
700 /// Rotates the board by one natural step of its tessellation.
701 ///
702 /// The step is 90 degrees for Sokoban and Octoban, and 60 degrees for Hexoban and Trioban;
703 /// see [`Tessellation::rotation_step_degrees()`]. Rotation keeps the complete rotated bounding
704 /// box and does not trim floor-only edges.
705 ///
706 /// # Examples
707 ///
708 /// ```rust
709 /// use sokoban_elements::*;
710 ///
711 /// # fn main() -> Result<(), BoardSyntaxError> {
712 /// #[rustfmt::skip]
713 /// let board_text = [
714 /// "@",
715 /// "$",
716 /// ".",
717 /// ]
718 /// .join("\n");
719 /// let mut board = GameBoard::parsed(&board_text, Tessellation::Sokoban)?;
720 /// board.rotate(Rotation::Clockwise);
721 /// assert_eq!(board.printed(false), ".$@");
722 /// # Ok(())
723 /// # }
724 /// ```
725 pub fn rotate(&mut self, rotation: Rotation) {
726 let transform = match rotation {
727 Rotation::Clockwise => BoardTransform::RotateClockwise,
728 Rotation::CounterClockwise => BoardTransform::RotateCounterClockwise,
729 };
730 self.data = transform::transformed(&self.data, self.tessellation, transform);
731 self.normalize_dimensions();
732 }
733
734 /// Grows the board to at least `width` columns and `height` rows, filling with
735 /// [`TileKind::Floor`](crate::TileKind::Floor).
736 ///
737 /// Each axis is grown independently to the larger of its current and its wanted size, so a
738 /// board is never shrunk: an axis already at or past its wanted size is left alone, and a call
739 /// asking for no growth on either axis is a no-op.
740 ///
741 /// Growth is added to the Bottom and Right edges only. Existing tiles therefore keep their
742 /// `(x, y)`, and no perpendicular padding line is ever added, whatever the
743 /// [`Tessellation`].
744 ///
745 /// This is also the way back from the empty board, which the one-edge-at-a-time methods cannot
746 /// grow.
747 ///
748 /// # Examples
749 ///
750 /// ```rust
751 /// use std::num::NonZeroU16;
752 ///
753 /// use sokoban_elements::*;
754 ///
755 /// # fn main() -> Result<(), std::num::TryFromIntError> {
756 /// let mut board = GameBoard::new(3, 2, Tessellation::Sokoban);
757 ///
758 /// board.grow(NonZeroU16::try_from(5)?, NonZeroU16::try_from(1)?);
759 ///
760 /// // width grew, height was already past the wanted one and stayed
761 /// assert_eq!(board.width(), 5);
762 /// assert_eq!(board.height(), 2);
763 ///
764 /// // editing can empty a board, and growing is the way back
765 /// board.remove_columns(VerticalEdge::Left, 42);
766 /// assert_eq!(board.size(), Size2D::new(0, 0));
767 ///
768 /// board.grow(NonZeroU16::try_from(4)?, NonZeroU16::try_from(3)?);
769 /// assert_eq!(board.size(), Size2D::new(4, 3));
770 /// # Ok(())
771 /// # }
772 /// ```
773 pub fn grow(&mut self, width: NonZeroU16, height: NonZeroU16) {
774 let target_width = self.width().max(usize::from(width.get()));
775 let target_height = self.height().max(usize::from(height.get()));
776
777 grown(&mut self.data, target_width, target_height);
778 self.normalize_dimensions();
779 }
780
781 /// Adds `count` rows of [`TileKind::Floor`](crate::TileKind::Floor) to given board edge.
782 ///
783 /// Returns `count` when the board is non-empty. A Top addition may also add one left padding
784 /// column to preserve tile geometry.
785 ///
786 /// Does nothing to the empty board: a board with no columns can only grow rows of zero length,
787 /// and a board with no columns is the empty board. Grow the empty board with
788 /// [`grow()`](GameBoard::grow()) instead.
789 ///
790 /// # Examples
791 ///
792 /// ```rust
793 /// use sokoban_elements::*;
794 ///
795 /// let mut board = GameBoard::new(3, 2, Tessellation::Sokoban);
796 ///
797 /// board.add_rows(HorizontalEdge::Top, 2);
798 ///
799 /// assert_eq!(board.width(), 3);
800 /// assert_eq!(board.height(), 4);
801 /// ```
802 pub fn add_rows(&mut self, edge: HorizontalEdge, count: usize) -> usize {
803 let applied = match self.tessellation {
804 Tessellation::Sokoban => SokobanBoardParser::add_rows(&mut self.data, edge, count),
805 Tessellation::Trioban => TriobanBoardParser::add_rows(&mut self.data, edge, count),
806 Tessellation::Hexoban => HexobanBoardParser::add_rows(&mut self.data, edge, count),
807 Tessellation::Octoban => OctobanBoardParser::add_rows(&mut self.data, edge, count),
808 };
809 self.normalize_dimensions();
810 applied
811 }
812
813 /// Removes up to `count` rows from given board edge and returns the number removed.
814 ///
815 /// Removal saturates at the board height. A Top removal that retains rows may also add one left
816 /// padding column to preserve tile geometry.
817 ///
818 /// # Examples
819 ///
820 /// ```rust
821 /// use sokoban_elements::*;
822 ///
823 /// let mut board = GameBoard::new(3, 2, Tessellation::Sokoban);
824 ///
825 /// board.remove_rows(HorizontalEdge::Bottom, 1);
826 /// assert_eq!(board.height(), 1);
827 ///
828 /// // removing more rows than there are empties the board
829 /// board.remove_rows(HorizontalEdge::Bottom, 42);
830 /// assert_eq!(board.width(), 0);
831 /// assert_eq!(board.height(), 0);
832 /// ```
833 pub fn remove_rows(&mut self, edge: HorizontalEdge, count: usize) -> usize {
834 let applied = match self.tessellation {
835 Tessellation::Sokoban => SokobanBoardParser::remove_rows(&mut self.data, edge, count),
836 Tessellation::Trioban => TriobanBoardParser::remove_rows(&mut self.data, edge, count),
837 Tessellation::Hexoban => HexobanBoardParser::remove_rows(&mut self.data, edge, count),
838 Tessellation::Octoban => OctobanBoardParser::remove_rows(&mut self.data, edge, count),
839 };
840 self.normalize_dimensions();
841 applied
842 }
843
844 /// Adds `count` columns of [`TileKind::Floor`](crate::TileKind::Floor) to given board edge.
845 ///
846 /// Returns `count` when the board is non-empty. An odd Left addition on Trioban or Octoban also
847 /// adds one top padding row to preserve tile geometry.
848 ///
849 /// Does nothing to the empty board, which has no rows to add columns to. Grow the empty board
850 /// with [`grow()`](GameBoard::grow()) instead.
851 ///
852 /// # Examples
853 ///
854 /// ```rust
855 /// use sokoban_elements::*;
856 ///
857 /// let mut board = GameBoard::new(3, 2, Tessellation::Sokoban);
858 ///
859 /// board.add_columns(VerticalEdge::Right, 4);
860 ///
861 /// assert_eq!(board.width(), 7);
862 /// assert_eq!(board.height(), 2);
863 /// ```
864 pub fn add_columns(&mut self, edge: VerticalEdge, count: usize) -> usize {
865 let applied = match self.tessellation {
866 Tessellation::Sokoban => SokobanBoardParser::add_columns(&mut self.data, edge, count),
867 Tessellation::Trioban => TriobanBoardParser::add_columns(&mut self.data, edge, count),
868 Tessellation::Hexoban => HexobanBoardParser::add_columns(&mut self.data, edge, count),
869 Tessellation::Octoban => OctobanBoardParser::add_columns(&mut self.data, edge, count),
870 };
871 self.normalize_dimensions();
872 applied
873 }
874
875 /// Removes up to `count` columns from given board edge and returns the number removed.
876 ///
877 /// Removal saturates at the board width. An odd Left removal that retains columns on Trioban or
878 /// Octoban also adds one top padding row to preserve tile geometry. Removing all columns also
879 /// removes the rows, because a board with no columns is the empty board.
880 ///
881 /// # Examples
882 ///
883 /// ```rust
884 /// use sokoban_elements::*;
885 ///
886 /// let mut board = GameBoard::new(3, 2, Tessellation::Sokoban);
887 ///
888 /// board.remove_columns(VerticalEdge::Left, 1);
889 /// assert_eq!(board.width(), 2);
890 ///
891 /// board.remove_columns(VerticalEdge::Left, 2);
892 /// assert_eq!(board.width(), 0);
893 /// assert_eq!(board.height(), 0);
894 /// ```
895 pub fn remove_columns(&mut self, edge: VerticalEdge, count: usize) -> usize {
896 let applied = match self.tessellation {
897 Tessellation::Sokoban => {
898 SokobanBoardParser::remove_columns(&mut self.data, edge, count)
899 }
900 Tessellation::Trioban => {
901 TriobanBoardParser::remove_columns(&mut self.data, edge, count)
902 }
903 Tessellation::Hexoban => {
904 HexobanBoardParser::remove_columns(&mut self.data, edge, count)
905 }
906 Tessellation::Octoban => {
907 OctobanBoardParser::remove_columns(&mut self.data, edge, count)
908 }
909 };
910 self.normalize_dimensions();
911 applied
912 }
913
914 /// Removes "outer" floor-only rows and columns in one geometry-preserving crop.
915 ///
916 /// A tile is retained when it is not
917 /// [empty floor](crate::TileKind::is_empty_floor()) - walls, boxes, goals and pushers all
918 /// count, so a goal-only or pusher-only edge line survives trimming.
919 ///
920 /// The result is the smallest rigidly anchored rectangle. It may retain one padding row or
921 /// column when the tight rectangle would change tile geometry.
922 ///
923 /// Trimming leaves the [empty board](Self::new()) exactly when there is nothing to retain,
924 /// which is what [`is_blank()`](Self::is_blank()) reports.
925 pub fn trim(&mut self) {
926 match self.tessellation {
927 Tessellation::Sokoban => SokobanBoardParser::trim(&mut self.data),
928 Tessellation::Trioban => TriobanBoardParser::trim(&mut self.data),
929 Tessellation::Hexoban => HexobanBoardParser::trim(&mut self.data),
930 Tessellation::Octoban => OctobanBoardParser::trim(&mut self.data),
931 }
932 self.normalize_dimensions();
933 }
934
935 /// Surrounds the play area with walls, growing the board where there is no room for them.
936 ///
937 /// [`StaticBoardAnalysis::flood_fill()`](crate::StaticBoardAnalysis::flood_fill()) stops at
938 /// walls and at the board edge, so the play area is *always* confined by the two together.
939 /// A board is "closed" exactly when the edge is not doing any of that work, which is what
940 /// [`StaticBoardAnalysis::is_playable_area_confined()`](crate::StaticBoardAnalysis::is_playable_area_confined())
941 /// reports and what this method establishes. There is no need to reconstruct anything inward:
942 /// every on-board position next to the play area and not in it is already a wall, because that
943 /// is why the flood stopped there.
944 ///
945 /// One row or column is added to each edge the play area reaches, and then every [empty
946 /// floor](crate::TileKind::is_empty_floor()) tile on the resulting outer edge becomes a
947 /// [`Wall`](crate::TileKind::Wall). Edges are grown Bottom, Right, Top, Left, because only Top
948 /// and Left shift content and may add a perpendicular padding line. Those padding lines are
949 /// floor too, so walling the outer edge afterwards seals them without tracking where they
950 /// landed.
951 ///
952 /// Nothing is overwritten but floor, so no piece, goal or wall is ever lost. Two consequences:
953 ///
954 /// - Dead floor on an edge that was *not* grown becomes wall as well. It lies outside the play
955 /// area, so the puzzle is unchanged, and it only happens on a board that was leaking
956 /// somewhere - an already closed board is left exactly as it is, which makes this method
957 /// idempotent.
958 /// - The result is not tightened. Follow with [`trim()`](Self::trim()) or
959 /// [`normalize()`](Self::normalize()) when a tight board is wanted.
960 ///
961 /// A board with no [`Pusher`] has no play area to enclose and is left untouched, as is the
962 /// empty board.
963 ///
964 /// # Examples
965 ///
966 /// ```rust
967 /// use sokoban_elements::*;
968 ///
969 /// # fn main() -> Result<(), BoardSyntaxError> {
970 /// // The pusher can walk off the top of this board, so the top is not a wall's job yet.
971 /// #[rustfmt::skip]
972 /// let board_text = [
973 /// "#-@-#",
974 /// "#####",
975 /// ]
976 /// .join("\n");
977 ///
978 /// let mut board = GameBoard::parsed(&board_text, Tessellation::Sokoban)?;
979 /// board.enclose()?;
980 ///
981 /// #[rustfmt::skip]
982 /// let enclosed = [
983 /// "#####",
984 /// "#-@-#",
985 /// "#####",
986 /// ]
987 /// .join("\n");
988 ///
989 /// assert_eq!(board.printed(true), enclosed);
990 ///
991 /// // Enclosing an already closed board changes nothing.
992 /// board.enclose()?;
993 /// assert_eq!(board.printed(true), enclosed);
994 /// # Ok(())
995 /// # }
996 /// ```
997 pub fn enclose(&mut self) -> Result<(), BoardSyntaxError> {
998 let analysis = StaticBoardAnalysis::new(
999 self.iter().map(|(_, t)| t),
1000 self.size(),
1001 self.tessellation,
1002 None,
1003 )?;
1004
1005 // No play area means nothing to enclose: a board with no pusher, or the empty board.
1006 let Some((top_left, bottom_right)) = analysis.playable_area().bounds() else {
1007 return Ok(());
1008 };
1009
1010 // The bounding box of the play area touches an edge exactly when some playable position
1011 // lies on it.
1012 let touches_top = top_left.y == 0;
1013 let touches_left = top_left.x == 0;
1014 let touches_bottom = bottom_right.y.saturating_add(1) >= self.height();
1015 let touches_right = bottom_right.x.saturating_add(1) >= self.width();
1016
1017 if !(touches_top || touches_bottom || touches_left || touches_right) {
1018 return Ok(());
1019 }
1020
1021 if touches_bottom {
1022 self.add_rows(HorizontalEdge::Bottom, 1);
1023 }
1024 if touches_right {
1025 self.add_columns(VerticalEdge::Right, 1);
1026 }
1027 if touches_top {
1028 self.add_rows(HorizontalEdge::Top, 1);
1029 }
1030 if touches_left {
1031 self.add_columns(VerticalEdge::Left, 1);
1032 }
1033
1034 let (width, height) = (self.width(), self.height());
1035 for y in 0..height {
1036 for x in 0..width {
1037 let is_outer_edge = x == 0
1038 || y == 0
1039 || x.saturating_add(1) == width
1040 || y.saturating_add(1) == height;
1041 if is_outer_edge && self[(x, y)].is_empty_floor() {
1042 self[(x, y)] = TileKind::Wall;
1043 }
1044 }
1045 }
1046
1047 self.normalize_dimensions();
1048
1049 Ok(())
1050 }
1051
1052 /// All non-playable tiles are replaced by floors and missing walls around play area are
1053 /// reconstructed.
1054 ///
1055 /// The reconstructed board is cropped once using the same smallest rigid anchor as
1056 /// [`trim()`](Self::trim). The crop is the bounding box of everything this method keeps: the
1057 /// play area plus the reconstructed walls confining it.
1058 ///
1059 /// This does **not** make a board closed. A play area that reaches the board edge is confined
1060 /// there by the edge itself rather than by a wall, and those edge tiles are kept as they are -
1061 /// no wall is invented around them. [`enclose()`](Self::enclose()) is the operation that makes
1062 /// room and walls them, and the two compose: `board.enclose()?; board.normalize()?;`.
1063 ///
1064 /// A board with no [`Pusher`] has no play area to normalize around, and is left untouched.
1065 ///
1066 /// 
1067 ///
1068 /// becomes
1069 ///
1070 /// 
1071 ///
1072 /// # Todo
1073 ///
1074 /// Following is the stuff that `SokobanYASC` does, see what we can implement too
1075 ///
1076 /// - fill jagged (i.e., "stair-case" like) wall edges
1077 /// - remove redundant walls and floors,
1078 /// - remove unused boxes and goals
1079 /// - change immovable boxes on non goal squares to walls
1080 /// - change immovable boxes on goal squares to walls
1081 /// - fill unnecessary illegal box squares
1082 pub fn normalize(&mut self) -> Result<(), BoardSyntaxError> {
1083 let analysis = StaticBoardAnalysis::new(
1084 self.iter().map(|(_, t)| t),
1085 self.size(),
1086 self.tessellation,
1087 None,
1088 )?;
1089 let significant_walls = analysis.significant_walls();
1090
1091 // Everything this method keeps: the play area itself plus the walls confining it. The crop
1092 // below has to be anchored on this same set - anchoring it on the significant walls alone
1093 // discards playable tiles that reach the board edge, which have no off-board neighbor that
1094 // could become a significant wall.
1095 let mut retained = significant_walls.clone();
1096 for pos in analysis.playable_area() {
1097 retained.add_point(pos);
1098 }
1099
1100 // No pusher means no play area, and therefore no significant walls either: there is nothing
1101 // to normalize around, so leave the board alone rather than floor every tile of it. Nothing
1102 // is mutated on this path, so `normalize_dimensions()` has nothing to fix up.
1103 let Some((top_left, bottom_right)) = retained.bounds() else {
1104 return Ok(());
1105 };
1106
1107 // Replace all non-playable tiles with floor
1108 let tmp: Vec<Point2D> = self
1109 .iter()
1110 .filter_map(|((x, y), _)| {
1111 let pos = Point2D::new(x, y);
1112 if retained.contains(pos) {
1113 None
1114 } else {
1115 Some(pos)
1116 }
1117 })
1118 .collect();
1119 for pos in tmp {
1120 self[(pos.x, pos.y)] = TileKind::Floor;
1121 }
1122
1123 // reconstruct missing walls
1124 for pos in &significant_walls {
1125 self[(pos.x, pos.y)] = TileKind::Wall;
1126 }
1127
1128 self.crop(BoardRect {
1129 left: top_left.x,
1130 top: top_left.y,
1131 width: bottom_right.x.saturating_sub(top_left.x).saturating_add(1),
1132 height: bottom_right.y.saturating_sub(top_left.y).saturating_add(1),
1133 });
1134
1135 Ok(())
1136 }
1137}
1138
1139#[cfg(feature = "serde")]
1140mod serde_impl {
1141 use serde::de::Error as _;
1142 use serde::{Deserialize, Deserializer, Serialize, Serializer};
1143
1144 use super::GameBoard;
1145 use crate::Tessellation;
1146
1147 /// Wire format of [`GameBoard`]: the printed board plus the [`Tessellation`] needed to parse it
1148 /// back.
1149 #[derive(Serialize, Deserialize)]
1150 #[serde(rename = "GameBoard", deny_unknown_fields)]
1151 struct GameBoardRepr {
1152 tessellation: Tessellation,
1153 board: String,
1154 }
1155
1156 impl Serialize for GameBoard {
1157 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1158 // `printed(true)` is mandatory, not cosmetic: with `false`, the default printer trims
1159 // trailing `TileKind::Floor` from every row, so board width is lost. It also
1160 // guarantees no printed row is blank. Hexoban ignores the flag and always prints
1161 // visible floors.
1162 GameBoardRepr {
1163 tessellation: self.tessellation,
1164 board: self.printed(true),
1165 }
1166 .serialize(serializer)
1167 }
1168 }
1169
1170 impl<'de> Deserialize<'de> for GameBoard {
1171 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1172 let repr = GameBoardRepr::deserialize(deserializer)?;
1173
1174 // Parsing (rather than assembling `data` directly) is what keeps the
1175 // width-normalization invariant intact.
1176 GameBoard::parsed(&repr.board, repr.tessellation).map_err(D::Error::custom)
1177 }
1178 }
1179}
1180
1181#[cfg(test)]
1182mod tests {
1183 use std::error::Error;
1184
1185 use indoc::indoc;
1186
1187 use super::*;
1188 use crate::{BoxID, GoalID, PlusID, PusherID};
1189
1190 fn board_text() -> String {
1191 [
1192 " #####",
1193 " # #",
1194 " #$ #",
1195 " ### $##",
1196 " # $ $ #",
1197 "### # ## # ######",
1198 "# # ## ##### ..#",
1199 "# $ $ ..#",
1200 "##### ### #@## ..#",
1201 " # #########",
1202 " #######",
1203 ]
1204 .join("\n")
1205 }
1206
1207 fn board() -> Result<GameBoard, BoardSyntaxError> {
1208 GameBoard::parsed(&board_text(), Tessellation::Sokoban)
1209 }
1210
1211 /// Board data invariant: all rows have exactly `width()` tiles and a board with no columns is
1212 /// the empty board.
1213 fn assert_invariant(board: &GameBoard) {
1214 assert!(
1215 board.data.iter().all(|row| row.len() == board.width()),
1216 "rows are not width-normalized: {:?}",
1217 board.data
1218 );
1219 assert_eq!(
1220 board.width() == 0,
1221 board.height() == 0,
1222 "board is {}x{}, but a board with no columns must have no rows",
1223 board.width(),
1224 board.height()
1225 );
1226 assert_eq!(board.iter().count(), board.width() * board.height());
1227 }
1228
1229 fn assert_empty(board: &GameBoard) {
1230 assert_invariant(board);
1231 assert_eq!(board.width(), 0);
1232 assert_eq!(board.height(), 0);
1233 assert!(board.data.is_empty());
1234 assert!(board.iter().next().is_none());
1235 }
1236
1237 fn content_signature(board: &GameBoard) -> Vec<(usize, usize, TileKind)> {
1238 let non_floor: Vec<_> = board
1239 .iter()
1240 .filter(|(_, tile)| *tile != TileKind::Floor)
1241 .collect();
1242 let min_x = non_floor.iter().map(|((x, _), _)| *x).min().unwrap_or(0);
1243 let min_y = non_floor.iter().map(|((_, y), _)| *y).min().unwrap_or(0);
1244 non_floor
1245 .into_iter()
1246 .map(|((x, y), tile)| (x - min_x, y - min_y, tile))
1247 .collect()
1248 }
1249
1250 fn patterned_board(tessellation: Tessellation) -> Result<GameBoard, BoardSyntaxError> {
1251 let markers = [
1252 TileKind::Wall,
1253 TileKind::Pusher,
1254 TileKind::Box,
1255 TileKind::Goal,
1256 TileKind::BoxOnGoal,
1257 TileKind::PusherOnGoal,
1258 ];
1259 let tiles: Vec<_> = (0..12)
1260 .map(|index| markers[index % markers.len()])
1261 .collect();
1262 GameBoard::parsed_from_tiles(&tiles, 4, 3, tessellation)
1263 }
1264
1265 #[test]
1266 fn it_reports_width_and_height_of_non_square_board() -> Result<(), BoardSyntaxError> {
1267 let board = board()?;
1268
1269 assert_eq!(board.width(), 19);
1270 assert_eq!(board.height(), 11);
1271
1272 Ok(())
1273 }
1274
1275 #[test]
1276 fn when_board_is_constructed_with_zero_dimension_it_is_empty() {
1277 for tessellation in Tessellation::ALL {
1278 assert_empty(&GameBoard::new(0, 5, tessellation));
1279 assert_empty(&GameBoard::new(5, 0, tessellation));
1280 assert_empty(&GameBoard::new(0, 0, tessellation));
1281 }
1282 }
1283
1284 #[test]
1285 fn when_board_is_empty_printing_round_trips() -> Result<(), BoardSyntaxError> {
1286 for tessellation in Tessellation::ALL {
1287 let board = GameBoard::new(0, 0, tessellation);
1288
1289 assert_eq!(board.printed(true), "");
1290 assert_eq!(board.printed(false), "");
1291 assert_eq!(GameBoard::parsed(&board.printed(true), tessellation)?, board);
1292 }
1293
1294 Ok(())
1295 }
1296
1297 #[test]
1298 fn when_board_is_empty_editing_preserves_the_invariant() {
1299 for tessellation in Tessellation::ALL {
1300 let mut board = GameBoard::new(0, 0, tessellation);
1301
1302 board.trim();
1303 assert_empty(&board);
1304
1305 board.mirror(MirrorAxis::Horizontal);
1306 assert_empty(&board);
1307
1308 board.mirror(MirrorAxis::Vertical);
1309 assert_empty(&board);
1310
1311 board.remove_rows(HorizontalEdge::Top, 1);
1312 board.remove_columns(VerticalEdge::Left, 1);
1313 assert_empty(&board);
1314
1315 board.add_rows(HorizontalEdge::Top, 1);
1316 board.add_columns(VerticalEdge::Left, 1);
1317 assert_empty(&board);
1318 }
1319 }
1320
1321 #[test]
1322 fn every_transform_preserves_piece_counts() -> Result<(), BoardSyntaxError> {
1323 for tessellation in Tessellation::ALL {
1324 let original = patterned_board(tessellation)?;
1325 let expected = (
1326 original.boxes_count(),
1327 original.goals_count(),
1328 original.pushers_count(),
1329 );
1330
1331 for transform in [
1332 BoardTransform::MirrorHorizontal,
1333 BoardTransform::MirrorVertical,
1334 BoardTransform::RotateClockwise,
1335 BoardTransform::RotateCounterClockwise,
1336 ] {
1337 let mut board = original.clone();
1338 match transform {
1339 BoardTransform::MirrorHorizontal => board.mirror(MirrorAxis::Horizontal),
1340 BoardTransform::MirrorVertical => board.mirror(MirrorAxis::Vertical),
1341 BoardTransform::RotateClockwise => board.rotate(Rotation::Clockwise),
1342 BoardTransform::RotateCounterClockwise => {
1343 board.rotate(Rotation::CounterClockwise);
1344 }
1345 }
1346 assert_eq!(
1347 (board.boxes_count(), board.goals_count(), board.pushers_count()),
1348 expected,
1349 "{tessellation:?} {transform:?}"
1350 );
1351 }
1352 }
1353
1354 Ok(())
1355 }
1356
1357 mod test_parsing {
1358 use super::*;
1359
1360 #[test]
1361 fn it_pads_short_rows_to_board_width() -> Result<(), BoardSyntaxError> {
1362 let board =
1363 GameBoard::parsed(&["###", "#@#####", "###"].join("\n"), Tessellation::Sokoban)?;
1364
1365 assert_eq!(board.width(), 7);
1366 assert_eq!(board.height(), 3);
1367 assert_eq!(board.iter().count(), 21);
1368
1369 // row 0 is only 3 characters long in board text
1370 assert_eq!(board[(6, 0)], TileKind::Floor);
1371
1372 Ok(())
1373 }
1374
1375 /// Width-normalization pads short rows with floor tiles. Printing without visible floor
1376 /// trims them back, so it round trips to original board text. Printing with visible
1377 /// floor renders them, so all printed lines have equal length.
1378 #[test]
1379 fn when_rows_are_padded_only_visible_floor_printing_shows_it()
1380 -> Result<(), BoardSyntaxError> {
1381 let board = board()?;
1382
1383 assert_eq!(board.printed(false), board_text());
1384
1385 let printed = board.printed(true);
1386 assert!(
1387 printed
1388 .split('\n')
1389 .all(|line| line.chars().count() == board.width())
1390 );
1391 assert_eq!(
1392 GameBoard::parsed(&printed, Tessellation::Sokoban)?,
1393 board,
1394 "printing with visible floor must round trip"
1395 );
1396
1397 Ok(())
1398 }
1399
1400 #[test]
1401 fn when_board_text_has_no_columns_it_parses_to_empty_board() -> Result<(), BoardSyntaxError>
1402 {
1403 // None of these are blank, but all of them RLE-decode to whitespace only.
1404 for text in ["|", "|||", "3|", "2(|)", "3 ", "| ", " | "] {
1405 for tessellation in Tessellation::ALL {
1406 let board = GameBoard::parsed(text, tessellation)?;
1407 assert_empty(&board);
1408 }
1409 }
1410
1411 // Row separators still separate rows when there are tiles to separate.
1412 let board = GameBoard::parsed("-|-", Tessellation::Sokoban)?;
1413 assert_eq!(board.width(), 1);
1414 assert_eq!(board.height(), 2);
1415 assert_invariant(&board);
1416
1417 Ok(())
1418 }
1419
1420 #[test]
1421 fn when_tiles_slice_is_empty_it_constructs_empty_board() -> Result<(), BoardSyntaxError> {
1422 for tessellation in Tessellation::ALL {
1423 assert_empty(&GameBoard::parsed_from_tiles(&[], 0, 5, tessellation)?);
1424 assert_empty(&GameBoard::parsed_from_tiles(&[], 5, 0, tessellation)?);
1425 assert_empty(&GameBoard::parsed_from_tiles(&[], 0, 0, tessellation)?);
1426 }
1427
1428 Ok(())
1429 }
1430
1431 #[test]
1432 fn when_declared_size_does_not_match_tiles_slice_it_errors() {
1433 // Size is checked before any zero dimension is normalized away.
1434 let err = GameBoard::parsed_from_tiles(&[TileKind::Wall], 0, 5, Tessellation::Sokoban)
1435 .err()
1436 .unwrap();
1437 assert!(matches!(
1438 err.kind(),
1439 BoardSyntaxErrorKind::InputSizeNotMatchedByDeclaredSize { .. }
1440 ));
1441
1442 // Declared size too large to be represented is reported the same way, not panicked on.
1443 let err = GameBoard::parsed_from_tiles(
1444 &[TileKind::Wall],
1445 usize::MAX,
1446 2,
1447 Tessellation::Sokoban,
1448 )
1449 .err()
1450 .unwrap();
1451 assert!(matches!(
1452 err.kind(),
1453 BoardSyntaxErrorKind::InputSizeNotMatchedByDeclaredSize { .. }
1454 ));
1455 }
1456 }
1457
1458 mod test_pieces_and_sokoban_plus {
1459 use super::*;
1460
1461 #[test]
1462 fn it_collects_pieces_matching_the_counters() -> Result<(), BoardSyntaxError> {
1463 let board = board()?;
1464
1465 assert_eq!(board.pushers()?.len(), board.pushers_count());
1466 assert_eq!(board.boxes(None)?.len(), board.boxes_count());
1467 assert_eq!(board.goals(None)?.len(), board.goals_count());
1468
1469 Ok(())
1470 }
1471
1472 #[test]
1473 fn it_assigns_piece_ids_in_row_major_order() -> Result<(), Box<dyn Error>> {
1474 let board = board()?;
1475
1476 for (idx, pusher) in board.pushers()?.iter().enumerate() {
1477 assert_eq!(pusher.id, PusherID::try_from(idx)?);
1478 assert!(board[(pusher.x, pusher.y)].has_pusher());
1479 }
1480
1481 let boxes = board.boxes(None)?;
1482 for (idx, boxbox) in boxes.iter().enumerate() {
1483 assert_eq!(boxbox.id, BoxID::try_from(idx)?);
1484 assert!(board[(boxbox.x, boxbox.y)].has_box());
1485 assert_eq!(boxbox.plus_id, None);
1486 }
1487
1488 // row major means positions are non decreasing in linear board position
1489 let positions: Vec<usize> = boxes.iter().map(|b| b.y * board.width() + b.x).collect();
1490 assert!(positions.windows(2).all(|w| w[0] < w[1]));
1491
1492 for (idx, goal) in board.goals(None)?.iter().enumerate() {
1493 assert_eq!(goal.id, GoalID::try_from(idx)?);
1494 assert!(board[(goal.x, goal.y)].has_goal());
1495 assert_eq!(goal.plus_id, None);
1496 }
1497
1498 Ok(())
1499 }
1500
1501 #[test]
1502 fn when_piece_is_on_goal_it_is_both_a_piece_and_a_goal() -> Result<(), BoardSyntaxError> {
1503 // '*' is box on goal, '+' is pusher on goal
1504 let board = GameBoard::parsed("#######|#-$.+-*#|#######", Tessellation::Sokoban)?;
1505
1506 let boxes = board.boxes(None)?;
1507 let goals = board.goals(None)?;
1508 let pushers = board.pushers()?;
1509
1510 assert_eq!(
1511 boxes
1512 .iter()
1513 .map(|piece| (piece.x, piece.y))
1514 .collect::<Vec<_>>(),
1515 vec![(2, 1), (6, 1)]
1516 );
1517 assert_eq!(
1518 goals
1519 .iter()
1520 .map(|piece| (piece.x, piece.y))
1521 .collect::<Vec<_>>(),
1522 vec![(3, 1), (4, 1), (6, 1)]
1523 );
1524 assert_eq!(
1525 pushers
1526 .iter()
1527 .map(|piece| (piece.x, piece.y))
1528 .collect::<Vec<_>>(),
1529 vec![(4, 1)]
1530 );
1531
1532 Ok(())
1533 }
1534
1535 #[test]
1536 fn when_board_is_empty_it_has_no_pieces() -> Result<(), BoardSyntaxError> {
1537 for tessellation in Tessellation::ALL {
1538 let board = GameBoard::new(0, 0, tessellation);
1539
1540 assert!(board.pushers()?.is_empty());
1541 assert!(board.boxes(None)?.is_empty());
1542 assert!(board.goals(None)?.is_empty());
1543 }
1544
1545 Ok(())
1546 }
1547
1548 #[test]
1549 fn it_reads_plus_ids_from_sokoban_plus() -> Result<(), Box<dyn std::error::Error>> {
1550 let board = board()?;
1551 assert_eq!(board.boxes_count(), 6);
1552 assert_eq!(board.goals_count(), 6);
1553
1554 // '0' means "this piece has no Sokoban+ ID", and pieces past the end of the sequence
1555 // get none either
1556 let sokoban_plus = SokobanPlus::parsed("1 0 2", "2 0 1")?;
1557
1558 let boxes = board.boxes(Some(&sokoban_plus))?;
1559 let plus_id = |id: u16| PlusID::new(id).ok();
1560
1561 assert_eq!(
1562 boxes.iter().map(|piece| piece.plus_id).collect::<Vec<_>>(),
1563 vec![plus_id(1), None, plus_id(2), None, None, None]
1564 );
1565
1566 let goals = board.goals(Some(&sokoban_plus))?;
1567 assert_eq!(
1568 goals.iter().map(|piece| piece.plus_id).collect::<Vec<_>>(),
1569 vec![plus_id(2), None, plus_id(1), None, None, None]
1570 );
1571
1572 Ok(())
1573 }
1574
1575 #[test]
1576 fn when_board_has_more_pushers_than_pusher_id_can_represent_it_errors()
1577 -> Result<(), BoardSyntaxError> {
1578 let pushers_count = usize::from(PusherID::MAX) + 1;
1579
1580 let just_enough = GameBoard::parsed(&"@".repeat(pushers_count), Tessellation::Sokoban)?;
1581 assert_eq!(just_enough.pushers()?.len(), pushers_count);
1582
1583 let one_too_many =
1584 GameBoard::parsed(&"@".repeat(pushers_count + 1), Tessellation::Sokoban)?;
1585 let result = one_too_many.pushers();
1586 assert!(result.is_err(), "one pusher over PusherID::MAX must be an error");
1587 if let Err(err) = result {
1588 assert!(matches!(err.kind(), BoardSyntaxErrorKind::TooManyPushers));
1589 assert!(err.source().is_some(), "cause of TooManyPushers must be preserved");
1590 assert!(
1591 err.to_string().contains(&pushers_count.to_string()),
1592 "error message must state the supported maximum count: {err}"
1593 );
1594 }
1595
1596 Ok(())
1597 }
1598 }
1599
1600 mod test_rotation {
1601 use super::*;
1602
1603 #[test]
1604 fn mirror_and_rotation_round_trips_restore_content() -> Result<(), BoardSyntaxError> {
1605 for tessellation in Tessellation::ALL {
1606 let original = patterned_board(tessellation)?;
1607 let expected = content_signature(&original);
1608
1609 for axis in [MirrorAxis::Horizontal, MirrorAxis::Vertical] {
1610 let mut board = original.clone();
1611 board.mirror(axis);
1612 board.mirror(axis);
1613 assert_eq!(content_signature(&board), expected, "{tessellation:?} {axis:?}");
1614 }
1615
1616 let mut board = original.clone();
1617 board.rotate(Rotation::Clockwise);
1618 board.rotate(Rotation::CounterClockwise);
1619 assert_eq!(
1620 content_signature(&board),
1621 expected,
1622 "{tessellation:?} inverse rotations"
1623 );
1624
1625 let mut board = original;
1626 let steps = 360 / usize::from(tessellation.rotation_step_degrees());
1627 for _ in 0..steps {
1628 board.rotate(Rotation::Clockwise);
1629 }
1630 assert_eq!(content_signature(&board), expected, "{tessellation:?} full turn");
1631 }
1632
1633 Ok(())
1634 }
1635 }
1636
1637 mod test_resizing {
1638 use super::*;
1639
1640 #[test]
1641 fn when_board_is_resized_rows_stay_width_normalized() -> Result<(), BoardSyntaxError> {
1642 let mut board = board()?;
1643
1644 board.add_columns(VerticalEdge::Left, 1);
1645 board.remove_columns(VerticalEdge::Right, 1);
1646 board.add_rows(HorizontalEdge::Top, 1);
1647 board.remove_rows(HorizontalEdge::Bottom, 1);
1648 assert_invariant(&board);
1649
1650 board.trim();
1651 assert_invariant(&board);
1652
1653 board.mirror(MirrorAxis::Vertical);
1654 board.mirror(MirrorAxis::Horizontal);
1655 assert_invariant(&board);
1656
1657 Ok(())
1658 }
1659
1660 #[test]
1661 fn when_all_columns_are_removed_board_becomes_empty() -> Result<(), BoardSyntaxError> {
1662 // Asking to remove more columns than there are removes all of them, and a board with no
1663 // columns is the empty board, so it's rows go too.
1664 let mut removed_right = board()?;
1665 removed_right.remove_columns(VerticalEdge::Right, 20);
1666 assert_empty(&removed_right);
1667
1668 let mut removed_left = board()?;
1669 removed_left.remove_columns(VerticalEdge::Left, 20);
1670 assert_empty(&removed_left);
1671
1672 Ok(())
1673 }
1674
1675 #[test]
1676 fn when_all_rows_are_removed_board_becomes_empty() -> Result<(), BoardSyntaxError> {
1677 let mut removed_bottom = board()?;
1678 removed_bottom.remove_rows(HorizontalEdge::Bottom, 11);
1679 assert_empty(&removed_bottom);
1680
1681 // asking for more rows than there are is not an error either
1682 let mut removed_top = board()?;
1683 removed_top.remove_rows(HorizontalEdge::Top, 42);
1684 assert_empty(&removed_top);
1685
1686 Ok(())
1687 }
1688
1689 /// Each editing call leaves a valid board behind, so a board emptied by one call cannot be
1690 /// grown by the next: there are no rows to add columns to, and rows added to a board with
1691 /// no columns are of zero length, which is the empty board again.
1692 ///
1693 /// This is the trade for editing one edge at a time. [`GameBoard::grow()`] is the way back,
1694 /// see `when_board_is_emptied_by_editing_grow_brings_it_back`.
1695 #[test]
1696 fn when_board_is_emptied_by_editing_it_cannot_be_grown_back() -> Result<(), BoardSyntaxError>
1697 {
1698 let mut board = board()?;
1699
1700 board.remove_columns(VerticalEdge::Right, 20);
1701 assert_empty(&board);
1702
1703 board.add_columns(VerticalEdge::Left, 2);
1704 assert_empty(&board);
1705
1706 board.add_rows(HorizontalEdge::Top, 3);
1707 assert_empty(&board);
1708
1709 Ok(())
1710 }
1711
1712 #[test]
1713 fn when_board_is_emptied_by_editing_grow_brings_it_back() -> Result<(), BoardSyntaxError> {
1714 let mut board = board()?;
1715
1716 board.remove_columns(VerticalEdge::Right, 20);
1717 assert_empty(&board);
1718
1719 board.grow(NonZeroU16::new(4).unwrap(), NonZeroU16::new(3).unwrap());
1720
1721 assert_eq!(board.size(), Size2D::new(4, 3));
1722 assert_invariant(&board);
1723 assert!(board.iter().all(|(_, tile)| tile == TileKind::Floor));
1724
1725 Ok(())
1726 }
1727
1728 #[test]
1729 fn grow_never_shrinks_a_board() {
1730 let mut board = GameBoard::new(5, 4, Tessellation::Sokoban);
1731
1732 // both axes are already past the wanted size, so nothing happens
1733 board.grow(NonZeroU16::new(2).unwrap(), NonZeroU16::new(1).unwrap());
1734 assert_eq!(board.size(), Size2D::new(5, 4));
1735
1736 // each axis grows on its own; the one that is big enough is left alone
1737 board.grow(NonZeroU16::new(9).unwrap(), NonZeroU16::new(1).unwrap());
1738 assert_eq!(board.size(), Size2D::new(9, 4));
1739
1740 board.grow(NonZeroU16::new(1).unwrap(), NonZeroU16::new(7).unwrap());
1741 assert_eq!(board.size(), Size2D::new(9, 7));
1742
1743 assert_invariant(&board);
1744 }
1745
1746 /// Growth goes to the Bottom and Right edges, which are dimension-local in every
1747 /// tessellation. Reimplementing it through Top or Left would pass every other test
1748 /// here while silently adding a perpendicular padding line and moving every tile.
1749 #[test]
1750 fn grow_adds_no_padding_and_moves_no_tile_in_any_tessellation() {
1751 for tessellation in Tessellation::ALL {
1752 let mut board = GameBoard::new(3, 3, tessellation);
1753 board[(0, 0)] = TileKind::Wall;
1754 board[(2, 1)] = TileKind::Box;
1755 board[(1, 2)] = TileKind::Pusher;
1756
1757 let before: Vec<((usize, usize), TileKind)> = board.iter().collect();
1758
1759 // odd growth on both axes: exactly what a Top or Left edit would have to compensate
1760 board.grow(NonZeroU16::new(6).unwrap(), NonZeroU16::new(4).unwrap());
1761
1762 assert_eq!(
1763 board.size(),
1764 Size2D::new(6, 4),
1765 "{tessellation:?} did not grow to the wanted size"
1766 );
1767 for (position, tile) in before {
1768 assert_eq!(
1769 board[position], tile,
1770 "{tessellation:?} moved the tile at {position:?}"
1771 );
1772 }
1773 assert_invariant(&board);
1774 }
1775 }
1776
1777 #[test]
1778 fn when_one_column_is_left_board_can_still_grow() -> Result<(), BoardSyntaxError> {
1779 // Stopping one column short of empty keeps the rows, so the board can still be grown.
1780 let mut board = board()?;
1781
1782 board.remove_columns(VerticalEdge::Right, 18);
1783 assert_eq!(board.width(), 1);
1784 assert_eq!(board.height(), 11);
1785
1786 board.add_columns(VerticalEdge::Left, 1);
1787 board.add_rows(HorizontalEdge::Top, 2);
1788
1789 assert_eq!(board.width(), 2);
1790 assert_eq!(board.height(), 13);
1791 assert_invariant(&board);
1792
1793 Ok(())
1794 }
1795
1796 #[test]
1797 fn resizer_implementors_identify_their_tessellation() {
1798 assert_eq!(SokobanBoardParser::TESSELLATION, Tessellation::Sokoban);
1799 assert_eq!(HexobanBoardParser::TESSELLATION, Tessellation::Hexoban);
1800 assert_eq!(TriobanBoardParser::TESSELLATION, Tessellation::Trioban);
1801 assert_eq!(OctobanBoardParser::TESSELLATION, Tessellation::Octoban);
1802 }
1803
1804 #[test]
1805 fn when_board_is_all_floor_trim_empties_it() -> Result<(), BoardSyntaxError> {
1806 let mut board = GameBoard::parsed("---", Tessellation::Sokoban)?;
1807 assert!(board.is_blank());
1808
1809 board.trim();
1810 assert_empty(&board);
1811
1812 Ok(())
1813 }
1814
1815 #[test]
1816 fn trim_empties_exactly_the_boards_that_are_blank() -> Result<(), BoardSyntaxError> {
1817 // The goal-only and pusher-only cases are the ones that used to be emptied despite not
1818 // being blank.
1819 for source in ["---", "-\n-\n-", "@", ".", "+", "@--#", ".--#", "-#-"] {
1820 let mut board = GameBoard::parsed(source, Tessellation::Sokoban)?;
1821 let was_blank = board.is_blank();
1822
1823 board.trim();
1824 assert_eq!(board.width() == 0, was_blank, "{source}");
1825 }
1826
1827 Ok(())
1828 }
1829 }
1830
1831 mod test_normalization {
1832 use super::*;
1833
1834 #[test]
1835 fn it_can_normalize_board() -> Result<(), BoardSyntaxError> {
1836 let src = indoc! {" ########
1837 ## *. #*#
1838 ##### @# #*#
1839 # $.$######
1840 # # $ .#
1841 ###### $ .#
1842 #######
1843 # #"};
1844
1845 let mut board = GameBoard::parsed(src, Tessellation::Sokoban)?;
1846 board.normalize()?;
1847
1848 let result = indoc! {" ######
1849 ## *. #
1850 #### @# #
1851 # $.$#####
1852 # # $ .#
1853 ##### $ .#
1854 #######"};
1855
1856 assert_eq!(board.printed(false), result);
1857
1858 Ok(())
1859 }
1860
1861 #[test]
1862 fn it_can_normalize_sokoban_board() -> Result<(), BoardSyntaxError> {
1863 let src = indoc! {"##### ###########
1864 #@ ###### ##
1865 #$ # # . * $ #
1866 #. *#$ ## #
1867 ##### $### ######
1868 ## $ $ # ##
1869 ### # ## # ######
1870 # # ## ##### ..#
1871 # $ $ ..#
1872 ##### ### #@ .+#
1873 # ## #########
1874 # #### ## ### #
1875 ######## # ### #
1876 # ### ####### #
1877 # ## #
1878 ###################"};
1879
1880 let mut board = GameBoard::parsed(src, Tessellation::Sokoban)?;
1881 board.normalize()?;
1882
1883 let result = indoc! {"#####
1884 #@ #####
1885 #$ # #
1886 #. *#$ #
1887 ##### $###
1888 # $ $ ##
1889 ### # ## # #####
1890 # # ## ##### ..#
1891 # $ $ ..#
1892 ##### ### #@ .+#
1893 # #########
1894 #### # # #
1895 ###### # # # #
1896 # ### ####### #
1897 # ## #
1898 ###################"};
1899
1900 assert_eq!(board.printed(false), result);
1901
1902 Ok(())
1903 }
1904
1905 #[test]
1906 fn normalize_keeps_playable_tiles_on_the_board_edge() -> Result<(), BoardSyntaxError> {
1907 // A play area reaching the board edge is confined by the edge itself, so those tiles
1908 // have no off-board neighbor that could become a significant wall. They
1909 // must survive anyway.
1910 for source in ["-@-", "@#", "#@", "@--#", "#--@"] {
1911 let mut board = GameBoard::parsed(source, Tessellation::Sokoban)?;
1912 board.normalize()?;
1913 assert_eq!(board.printed(true), source, "{source}");
1914 }
1915
1916 Ok(())
1917 }
1918
1919 #[test]
1920 fn when_board_has_no_pusher_normalize_leaves_it_alone() -> Result<(), BoardSyntaxError> {
1921 let source = indoc! {"#####
1922 #-$.#
1923 #####"};
1924
1925 let mut board = GameBoard::parsed(source, Tessellation::Sokoban)?;
1926 board.normalize()?;
1927 assert_eq!(board.printed(true), source);
1928
1929 // A pusher-less board with nothing but pieces is left alone too, not annihilated.
1930 let mut board = GameBoard::parsed("-$.-", Tessellation::Sokoban)?;
1931 board.normalize()?;
1932 assert_eq!(board.printed(true), "-$.-");
1933
1934 for tessellation in Tessellation::ALL {
1935 let mut board = GameBoard::new(0, 0, tessellation);
1936 board.normalize()?;
1937 assert_empty(&board);
1938 }
1939
1940 Ok(())
1941 }
1942
1943 #[test]
1944 fn normalize_preserves_tile_shapes_of_edge_play_areas() -> Result<(), BoardSyntaxError> {
1945 for tessellation in [Tessellation::Trioban, Tessellation::Octoban] {
1946 let mut board = GameBoard::parsed("-@-$-.-", tessellation)?;
1947 let before: Vec<_> = board
1948 .iter()
1949 .filter(|(_, tile)| !tile.is_empty_floor())
1950 .map(|((x, y), tile)| (tile, tessellation.tile_shape_from_pos(x, y)))
1951 .collect();
1952
1953 board.normalize()?;
1954
1955 let after: Vec<_> = board
1956 .iter()
1957 .filter(|(_, tile)| !tile.is_empty_floor())
1958 .map(|((x, y), tile)| (tile, tessellation.tile_shape_from_pos(x, y)))
1959 .collect();
1960 assert_eq!(after, before, "{tessellation:?}");
1961 }
1962
1963 Ok(())
1964 }
1965
1966 #[test]
1967 fn normalization_uses_one_rigid_crop_on_trioban_and_octoban() -> Result<(), BoardSyntaxError>
1968 {
1969 let source = indoc! {"---------
1970 --#####--
1971 --#@$.#--
1972 --#####--
1973 ---------"};
1974
1975 for (tessellation, expected_size, first_wall) in [
1976 (Tessellation::Trioban, Size2D::new(7, 3), (1_usize, 0_usize)),
1977 (Tessellation::Octoban, Size2D::new(6, 3), (2, 0)),
1978 ] {
1979 let mut board = GameBoard::parsed(source, tessellation)?;
1980 board.normalize()?;
1981
1982 assert_eq!(
1983 board.size(),
1984 expected_size,
1985 "{tessellation:?}\n{}",
1986 board.printed(true)
1987 );
1988 assert_eq!(
1989 board[first_wall],
1990 TileKind::Wall,
1991 "{tessellation:?}\n{}",
1992 board.printed(true)
1993 );
1994 }
1995
1996 Ok(())
1997 }
1998 }
1999
2000 mod test_enclosing {
2001 use super::*;
2002
2003 fn analysis_of(board: &GameBoard) -> Result<StaticBoardAnalysis, BoardSyntaxError> {
2004 StaticBoardAnalysis::new(
2005 board.iter().map(|(_, t)| t),
2006 board.size(),
2007 board.tessellation(),
2008 None,
2009 )
2010 }
2011
2012 #[test]
2013 fn enclose_confines_the_playable_area() -> Result<(), BoardSyntaxError> {
2014 // No walls at all: the play area is the whole board and every edge leaks.
2015 let mut board = GameBoard::parsed("-@-", Tessellation::Sokoban)?;
2016 board.enclose()?;
2017
2018 let expected = indoc! {"#####
2019 #-@-#
2020 #####"};
2021 assert_eq!(board.printed(true), expected);
2022 assert!(analysis_of(&board)?.is_playable_area_confined());
2023
2024 // A single tile grows into the centre of a 3x3.
2025 let mut board = GameBoard::parsed("@", Tessellation::Sokoban)?;
2026 board.enclose()?;
2027 assert_eq!(
2028 board.printed(true),
2029 indoc! {"###
2030 #@#
2031 ###"}
2032 );
2033
2034 Ok(())
2035 }
2036
2037 #[test]
2038 fn enclose_is_a_no_op_on_a_closed_board() -> Result<(), BoardSyntaxError> {
2039 let source = indoc! {"#####
2040 #@$.#
2041 #####"};
2042
2043 let mut board = GameBoard::parsed(source, Tessellation::Sokoban)?;
2044 assert!(analysis_of(&board)?.is_playable_area_confined());
2045
2046 board.enclose()?;
2047 assert_eq!(board.printed(true), source);
2048
2049 Ok(())
2050 }
2051
2052 #[test]
2053 fn enclose_is_idempotent() -> Result<(), BoardSyntaxError> {
2054 for tessellation in Tessellation::ALL {
2055 let mut once = GameBoard::new(3, 3, tessellation);
2056 once[(1, 1)] = TileKind::Pusher;
2057 once[(2, 1)] = TileKind::Box;
2058 once.enclose()?;
2059
2060 let mut twice = once.clone();
2061 twice.enclose()?;
2062
2063 assert_eq!(twice, once, "{tessellation:?}");
2064 }
2065
2066 Ok(())
2067 }
2068
2069 #[test]
2070 fn when_board_has_no_pusher_enclose_leaves_it_alone() -> Result<(), BoardSyntaxError> {
2071 let mut board = GameBoard::parsed("-$.-", Tessellation::Sokoban)?;
2072 board.enclose()?;
2073 assert_eq!(board.printed(true), "-$.-");
2074
2075 for tessellation in Tessellation::ALL {
2076 let mut board = GameBoard::new(0, 0, tessellation);
2077 board.enclose()?;
2078 assert_empty(&board);
2079 }
2080
2081 Ok(())
2082 }
2083
2084 #[test]
2085 fn enclose_confines_every_tessellation_without_losing_content()
2086 -> Result<(), BoardSyntaxError> {
2087 for tessellation in Tessellation::ALL {
2088 // All floor except the pieces, so the play area covers the board and leaks
2089 // everywhere.
2090 let mut board = GameBoard::new(3, 3, tessellation);
2091 board[(1, 1)] = TileKind::Pusher;
2092 board[(2, 1)] = TileKind::Box;
2093 board[(1, 2)] = TileKind::Goal;
2094
2095 assert!(!analysis_of(&board)?.is_playable_area_confined(), "{tessellation:?}");
2096
2097 board.enclose()?;
2098
2099 assert!(analysis_of(&board)?.is_playable_area_confined(), "{tessellation:?}");
2100 assert_eq!(board.pushers_count(), 1, "{tessellation:?}");
2101 assert_eq!(board.boxes_count(), 1, "{tessellation:?}");
2102 assert_eq!(board.goals_count(), 1, "{tessellation:?}");
2103 assert_invariant(&board);
2104 }
2105
2106 Ok(())
2107 }
2108
2109 #[test]
2110 fn enclose_is_deterministic_when_two_edges_leak() -> Result<(), BoardSyntaxError> {
2111 // Walled on Bottom and Right, open on Top and Left - the two edges whose growth may add
2112 // a perpendicular padding line. The sizes below pin the Bottom, Right, Top,
2113 // Left order.
2114 let source = indoc! {"@-#
2115 --#
2116 ###"};
2117
2118 for (tessellation, expected) in [
2119 (Tessellation::Sokoban, Size2D::new(4, 4)),
2120 (Tessellation::Trioban, Size2D::new(5, 5)),
2121 (Tessellation::Octoban, Size2D::new(5, 5)),
2122 ] {
2123 let mut board = GameBoard::parsed(source, tessellation)?;
2124 board.enclose()?;
2125
2126 assert_eq!(board.size(), expected, "{tessellation:?}");
2127 assert!(analysis_of(&board)?.is_playable_area_confined(), "{tessellation:?}");
2128 }
2129
2130 Ok(())
2131 }
2132
2133 #[test]
2134 fn enclose_then_normalize_yields_a_closed_normalized_board() -> Result<(), BoardSyntaxError>
2135 {
2136 let mut board = GameBoard::parsed("-@-$-.-", Tessellation::Sokoban)?;
2137
2138 board.enclose()?;
2139 board.normalize()?;
2140
2141 assert!(analysis_of(&board)?.is_playable_area_confined());
2142 assert_eq!(
2143 board.printed(true),
2144 indoc! {"#########
2145 #-@-$-.-#
2146 #########"}
2147 );
2148
2149 Ok(())
2150 }
2151 }
2152
2153 #[cfg(feature = "serde")]
2154 mod test_serde {
2155
2156 use super::*;
2157
2158 fn round_tripped(board: &GameBoard) -> Result<GameBoard, Box<dyn Error>> {
2159 let json = serde_json::to_string(board)?;
2160
2161 Ok(serde_json::from_str::<GameBoard>(&json)?)
2162 }
2163
2164 fn assert_round_trips(board: &GameBoard) -> Result<(), Box<dyn Error>> {
2165 let parsed = round_tripped(board)?;
2166
2167 assert_eq!(parsed, *board);
2168 assert_eq!(parsed.width(), board.width());
2169 assert_eq!(parsed.height(), board.height());
2170 assert_invariant(&parsed);
2171
2172 Ok(())
2173 }
2174
2175 #[test]
2176 fn it_round_trips_through_json() -> Result<(), Box<dyn Error>> {
2177 for tessellation in [
2178 Tessellation::Sokoban,
2179 Tessellation::Trioban,
2180 Tessellation::Octoban,
2181 ] {
2182 let board = GameBoard::parsed(&board_text(), tessellation)?;
2183 assert_round_trips(&board)?;
2184 }
2185
2186 Ok(())
2187 }
2188
2189 #[test]
2190 fn it_round_trips_hexoban_boards() -> Result<(), Box<dyn Error>> {
2191 let scheme1_type1 = indoc! {"---#-#-#-#----------
2192 --#-------#---------
2193 -#-@-----#----------
2194 --#-$---$-#-#-#-#-#-
2195 -#---.---.---------#
2196 --#---------------#-
2197 -#-#-#-#-#-#-#-#-#--"};
2198
2199 let scheme1_type2 = indoc! {"---#-#-#-#-----------
2200 --#-------#----------
2201 -#-@-----#-----------
2202 --#-$---$-#-#-#-#-#--
2203 -#---.---.---------#-
2204 --#-----------------#
2205 -#-#-#-#-#-#-#-#-#-#-"};
2206
2207 for src in [scheme1_type1, scheme1_type2] {
2208 let board = GameBoard::parsed(src, Tessellation::Hexoban)?;
2209 assert_round_trips(&board)?;
2210 }
2211
2212 Ok(())
2213 }
2214
2215 #[test]
2216 fn it_round_trips_resized_hexoban_board() -> Result<(), Box<dyn Error>> {
2217 let markers = [
2218 TileKind::Wall,
2219 TileKind::Pusher,
2220 TileKind::Box,
2221 TileKind::Goal,
2222 TileKind::BoxOnGoal,
2223 TileKind::PusherOnGoal,
2224 ];
2225 let mut board = GameBoard::parsed_from_tiles(&markers, 3, 2, Tessellation::Hexoban)?;
2226 board.add_rows(HorizontalEdge::Top, 1);
2227 board.remove_rows(HorizontalEdge::Top, 1);
2228
2229 assert_round_trips(&board)?;
2230
2231 Ok(())
2232 }
2233
2234 #[test]
2235 fn it_round_trips_resized_trioban_and_octoban_boards() -> Result<(), Box<dyn Error>> {
2236 let markers = [
2237 TileKind::Wall,
2238 TileKind::Pusher,
2239 TileKind::Box,
2240 TileKind::Goal,
2241 TileKind::BoxOnGoal,
2242 TileKind::PusherOnGoal,
2243 ];
2244
2245 for tessellation in [Tessellation::Trioban, Tessellation::Octoban] {
2246 let mut board = GameBoard::parsed_from_tiles(&markers, 3, 2, tessellation)?;
2247 board.add_rows(HorizontalEdge::Top, 1);
2248 board.add_columns(VerticalEdge::Left, 1);
2249
2250 assert_round_trips(&board)?;
2251 }
2252
2253 Ok(())
2254 }
2255
2256 /// Hexoban stores boards in a dense internal form whose external text depends on the
2257 /// parity of the first non floor tile, so which rows carry content is load bearing.
2258 #[test]
2259 fn when_hexoban_content_sits_in_rows_of_one_parity_only_it_round_trips()
2260 -> Result<(), Box<dyn Error>> {
2261 for row in [0, 1] {
2262 let mut board = GameBoard::new(5, 4, Tessellation::Hexoban);
2263 board[(0, row)] = TileKind::Wall;
2264 board[(2, row)] = TileKind::Wall;
2265 board[(4, row)] = TileKind::Box;
2266
2267 assert_round_trips(&board)?;
2268 }
2269
2270 Ok(())
2271 }
2272
2273 #[test]
2274 fn when_board_is_all_floor_it_round_trips() -> Result<(), Box<dyn Error>> {
2275 for tessellation in Tessellation::ALL {
2276 assert_round_trips(&GameBoard::new(6, 3, tessellation))?;
2277 }
2278
2279 Ok(())
2280 }
2281
2282 #[test]
2283 fn when_board_is_single_row_it_round_trips() -> Result<(), Box<dyn Error>> {
2284 for tessellation in Tessellation::ALL {
2285 let mut board = GameBoard::new(5, 1, tessellation);
2286 board[(0, 0)] = TileKind::Wall;
2287 board[(2, 0)] = TileKind::Pusher;
2288
2289 assert_round_trips(&board)?;
2290 }
2291
2292 Ok(())
2293 }
2294
2295 #[test]
2296 fn when_board_is_empty_it_round_trips() -> Result<(), Box<dyn Error>> {
2297 for tessellation in Tessellation::ALL {
2298 assert_round_trips(&GameBoard::new(0, 5, tessellation))?;
2299 assert_round_trips(&GameBoard::new(4, 0, tessellation))?;
2300 }
2301
2302 Ok(())
2303 }
2304
2305 /// Boards are printed with visible floors precisely so that trailing floor columns are not
2306 /// trimmed away.
2307 #[test]
2308 fn it_preserves_trailing_floor_columns() -> Result<(), Box<dyn Error>> {
2309 let board = GameBoard::parsed("#@$.#\n#----", Tessellation::Sokoban)?;
2310 assert_eq!(board.width(), 5);
2311
2312 assert_round_trips(&board)?;
2313
2314 Ok(())
2315 }
2316
2317 #[test]
2318 fn it_deserializes_fields_in_any_order() -> Result<(), Box<dyn Error>> {
2319 let board: GameBoard =
2320 serde_json::from_str(r##"{"board": "#@$.#", "tessellation": "Sokoban"}"##)?;
2321
2322 assert_eq!(board, GameBoard::parsed("#@$.#", Tessellation::Sokoban)?);
2323
2324 Ok(())
2325 }
2326
2327 #[test]
2328 fn it_rejects_unknown_fields() {
2329 let result = serde_json::from_str::<GameBoard>(
2330 r##"{"tessellation": "Sokoban", "board": "#@$.#", "width": 5}"##,
2331 );
2332
2333 assert!(result.is_err());
2334 }
2335
2336 #[test]
2337 fn it_rejects_board_text_that_does_not_parse() {
2338 let result = serde_json::from_str::<GameBoard>(
2339 r##"{"tessellation": "Hexoban", "board": "#-##-#"}"##,
2340 );
2341
2342 assert!(result.is_err());
2343 }
2344 }
2345}