Skip to main content

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    /// ![](https://raw.githubusercontent.com/tadams42/sokoban-elements/main/crates/sokoban-elements/doc/img/playable_area2.png)
1067    ///
1068    /// becomes
1069    ///
1070    /// ![](https://raw.githubusercontent.com/tadams42/sokoban-elements/main/crates/sokoban-elements/doc/img/normalized_board.png)
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}