Skip to main content

sokoban_elements/
static_board_analysis.rs

1use std::collections::{HashMap, VecDeque};
2
3use crate::{
4    BoardArea, BoardSyntaxError, BoardSyntaxErrorKind, BoxID, Boxbox, Direction, Goal, GoalID,
5    Point2D, Pusher, PusherID, Size2D, SokobanPlus, Tessellation, TileKind, TileShape,
6};
7
8/// Static analysis of game boards (outer walls, corners, play area...)
9///
10/// Static board features are categories of board positions that don't change semantics
11/// even after pushers and boxes had been moved around board. For example:
12///
13/// - positions of outer walls
14/// - position of "corners"
15/// - active playable area
16/// - pusher, box and goal IDs in starting position
17/// - ...
18///
19/// This kind of analysis only makes sense when applied to finalized board designs, ie. a
20/// [`GameBoard`](crate::GameBoard) that is no longer being edited.
21///
22/// # Todo
23///
24/// - construct and test more pathological cases for Trioban / Hexoban / Octoban (play area,
25///   significant walls, corners, ...)
26/// - wall surrounded by walls in play area?
27/// - in Sokoban this wall should not be marked as playable, neither should floors in same position
28pub struct StaticBoardAnalysis {
29    tessellation: Tessellation,
30    size:         Size2D,
31
32    walls_positions:   BoardArea,
33    pushers_positions: BoardArea,
34    boxes_positions:   BoardArea,
35    goals_positions:   BoardArea,
36
37    boxes:   Box<[Boxbox]>,
38    goals:   Box<[Goal]>,
39    pushers: Box<[Pusher]>,
40}
41
42impl StaticBoardAnalysis {
43    /// Builds a new [`StaticBoardAnalysis`] from a board scan.
44    ///
45    /// That way it is decoupled from [`GameBoard`](crate::GameBoard), with no loss of
46    /// functionality.
47    pub fn new<I>(
48        board: I, size: Size2D, tessellation: Tessellation, sokoban_plus: Option<&SokobanPlus>,
49    ) -> Result<Self, BoardSyntaxError>
50    where
51        I: Iterator<Item = TileKind>,
52    {
53        let expected_len = size.size();
54
55        let mut walls_pos = BoardArea::new(size);
56        let mut pushers_pos = BoardArea::new(size);
57        let mut boxes_pos = BoardArea::new(size);
58        let mut goals_pos = BoardArea::new(size);
59
60        let mut boxes: Vec<Boxbox> = Vec::default();
61        let mut pushers: Vec<Pusher> = Vec::default();
62        let mut goals: Vec<Goal> = Vec::default();
63
64        for (idx, tile) in board.enumerate() {
65            if idx >= expected_len {
66                return Err(BoardSyntaxError::new(
67                    BoardSyntaxErrorKind::InputSizeNotMatchedByDeclaredSize {
68                        input:           idx.checked_add(1).unwrap_or(idx),
69                        declared_width:  size.width,
70                        declared_height: size.height,
71                    },
72                ));
73            }
74
75            let pos = Point2D::new_from_idx(idx, &size);
76
77            if tile.is_wall() {
78                walls_pos.add_point(pos);
79            }
80
81            if tile.has_pusher() {
82                let p = Self::build_pusher(pushers.len(), pos.x, pos.y)?;
83                pushers_pos.add_point(pos);
84                pushers.push(p);
85            }
86
87            if tile.has_box() {
88                let b = Self::build_box(boxes.len(), pos.x, pos.y, sokoban_plus)?;
89                boxes_pos.add_point(pos);
90                boxes.push(b);
91            }
92
93            if tile.has_goal() {
94                let g = Self::build_goal(goals.len(), pos.x, pos.y, sokoban_plus)?;
95                goals_pos.add_point(pos);
96                goals.push(g);
97            }
98        }
99
100        Ok(Self {
101            tessellation,
102            walls_positions: walls_pos,
103            pushers_positions: pushers_pos,
104            boxes_positions: boxes_pos,
105            goals_positions: goals_pos,
106            size,
107            boxes: boxes.into_boxed_slice(),
108            goals: goals.into_boxed_slice(),
109            pushers: pushers.into_boxed_slice(),
110        })
111    }
112
113    /// Flood fill with generic bordering predicate.
114    ///
115    /// `None` if `root` is off-board.
116    ///
117    /// # Todo
118    ///
119    /// It should probably return top-left position of found area, as an optimization
120    pub fn flood_fill<F>(
121        root: Point2D, is_obstacle: F, size: Size2D, tessellation: Tessellation,
122    ) -> Option<BoardArea>
123    where
124        F: Fn(Point2D) -> bool,
125    {
126        let mut retv = BoardArea::new(size);
127        let mut visited = BoardArea::new(size);
128        let mut to_inspect: VecDeque<Point2D> = VecDeque::new();
129        to_inspect.push_back(root);
130
131        while !to_inspect.is_empty() {
132            let current_position = to_inspect.pop_front()?;
133            retv.add_point(current_position);
134
135            for (_, neighbor) in current_position.neighbors(tessellation, size)? {
136                if !visited.contains(neighbor) {
137                    if !is_obstacle(neighbor) {
138                        to_inspect.push_back(neighbor);
139                    }
140                    visited.add_point(neighbor);
141                }
142            }
143        }
144
145        Some(retv)
146    }
147
148    /// Dimensions of the board this analysis was built from.
149    pub fn size(&self) -> Size2D { self.size }
150
151    /// [`Tessellation`] of the board this analysis was built from.
152    pub fn tessellation(&self) -> Tessellation { self.tessellation }
153
154    /// Positions of walls on the board this analysis was built from.
155    pub fn walls_positions(&self) -> &BoardArea { &self.walls_positions }
156
157    /// Positions of boxes on the board this analysis was built from.
158    pub fn boxes_positions(&self) -> &BoardArea { &self.boxes_positions }
159
160    /// Positions of goals on the board this analysis was built from.
161    pub fn goals_positions(&self) -> &BoardArea { &self.goals_positions }
162
163    /// Positions of pushers on the board this analysis was built from.
164    pub fn pushers_positions(&self) -> &BoardArea { &self.pushers_positions }
165
166    /// See also: [`GameBoard::boxes()`](crate::GameBoard::boxes())
167    pub fn boxes(&self) -> &[Boxbox] { &self.boxes }
168
169    /// See also: [`GameBoard::goals()`](crate::GameBoard::goals())
170    pub fn goals(&self) -> &[Goal] { &self.goals }
171
172    /// See also: [`GameBoard::pushers()`](crate::GameBoard::pushers())
173    pub fn pushers(&self) -> &[Pusher] { &self.pushers }
174
175    /// All board positions that are not part of playable area and are not significant walls.
176    pub fn outer_area(&self) -> BoardArea {
177        let play_area = self.playable_area();
178        let significant_walls = self.significant_walls();
179
180        let is_candidate =
181            |pos: Point2D| -> bool { !significant_walls.contains(pos) && !play_area.contains(pos) };
182
183        let mut candidates: Vec<Point2D> = Vec::with_capacity(
184            self.size
185                .width
186                .saturating_mul(2)
187                .saturating_add(self.size.height.saturating_mul(2)),
188        );
189        for x in 0..self.size.width {
190            let pos = Point2D::new(x, 0);
191            if is_candidate(pos) {
192                candidates.push(pos);
193            }
194            let pos = Point2D::new(x, self.size.height - 1);
195            if is_candidate(pos) {
196                candidates.push(pos);
197            }
198        }
199        for y in 0..self.size.height {
200            let pos = Point2D::new(0, y);
201            if is_candidate(pos) {
202                candidates.push(pos);
203            }
204            let pos = Point2D::new(self.size.width - 1, y);
205            if is_candidate(pos) {
206                candidates.push(pos);
207            }
208        }
209
210        let is_border = |pos: Point2D| -> bool { significant_walls.contains(pos) };
211
212        let mut retv = BoardArea::new(self.size);
213
214        for candidate in candidates {
215            if !retv.contains(candidate)
216                && let Some(neighbor_outers) =
217                    Self::flood_fill(candidate, is_border, self.size, self.tessellation)
218            {
219                neighbor_outers.into_iter().for_each(|pos| {
220                    retv.add_point(pos);
221                });
222            }
223        }
224
225        retv
226    }
227
228    /// All board positions which are part of "active" board area.
229    ///
230    /// Active board area contains any position that is reachable by any of pushers as if there were
231    /// no boxes and no other pushers on board.
232    ///
233    /// Any tile not included in this area may be replaced by wall. Resulting
234    /// [`GameBoard`](crate::GameBoard) would no longer look exactly the same but will still have
235    /// exact same solution.
236    ///
237    /// In following image, greyed out squares are the ones that will not be in set returned by this
238    /// method:
239    ///
240    /// ![](https://raw.githubusercontent.com/tadams42/sokoban-elements/main/crates/sokoban-elements/doc/img/playable_area1.png)
241    /// ![](https://raw.githubusercontent.com/tadams42/sokoban-elements/main/crates/sokoban-elements/doc/img/hexoban_play_area.png)
242    /// ![](https://raw.githubusercontent.com/tadams42/sokoban-elements/main/crates/sokoban-elements/doc/img/trioban_play_area.png)
243    /// ![](https://raw.githubusercontent.com/tadams42/sokoban-elements/main/crates/sokoban-elements/doc/img/octoban_play_area.png)
244    pub fn playable_area(&self) -> BoardArea {
245        let is_obstacle = |pos: Point2D| -> bool { self.walls_positions.contains(pos) };
246
247        let mut retv = BoardArea::new(self.size);
248
249        let pushers = self.pushers_positions.clone();
250
251        for pos in pushers {
252            if let Some(rp) = Self::flood_fill(pos, is_obstacle, self.size, self.tessellation) {
253                for pos in rp {
254                    retv.add_point(pos);
255                }
256            }
257        }
258
259        retv
260    }
261
262    /// Is the [`playable_area()`](Self::playable_area()) confined by walls alone?
263    ///
264    /// `true` when no playable position lies on the board's outer edge. This is what "closed board"
265    /// means, and it is the only thing it can mean: [`flood_fill()`](Self::flood_fill()) stops at
266    /// walls and at the board edge, so the play area is *always* confined by walls together with
267    /// the edge. A board is closed exactly when the edge is not doing any of that work.
268    ///
269    /// A board with no [`Pusher`] has no play area at all and is vacuously confined, as is the
270    /// empty board.
271    ///
272    /// See also: [`GameBoard::enclose()`](crate::GameBoard::enclose())
273    pub fn is_playable_area_confined(&self) -> bool {
274        let Some((top_left, bottom_right)) = self.playable_area().bounds() else {
275            return true;
276        };
277
278        top_left.x > 0
279            && top_left.y > 0
280            && bottom_right.x.saturating_add(1) < self.size.width
281            && bottom_right.y.saturating_add(1) < self.size.height
282    }
283
284    /// Breadth first search for the shortest path from `from` to `to`, with generic bordering
285    /// predicate.
286    ///
287    /// The path-shaped counterpart of [`flood_fill()`](Self::flood_fill()), and deliberately the
288    /// same shape: both take the obstacle predicate, the board size and the [`Tessellation`] as
289    /// arguments rather than reading them off `self`, so a caller is free to define "obstacle"
290    /// however its rules require.
291    ///
292    /// Returns [`Some`] empty path when `from == to`, and [`None`] when either endpoint is
293    /// off-board or `to` cannot be reached.
294    pub fn flood_path<F>(
295        from: Point2D, to: Point2D, is_obstacle: F, size: Size2D, tessellation: Tessellation,
296    ) -> Option<Vec<Direction>>
297    where
298        F: Fn(Point2D) -> bool,
299    {
300        if !from.is_on_board(size) || !to.is_on_board(size) {
301            return None;
302        }
303
304        if from == to {
305            return Some(Vec::new());
306        }
307
308        // Unlike `flood_fill`, `from` is marked visited before the search rather than not at all.
309        // A predecessor map whose root can be overwritten by a later visit describes a cycle, and
310        // the unwind below would never reach `from`.
311        let mut visited = BoardArea::new(size);
312        visited.add_point(from);
313
314        let mut came_from: HashMap<Point2D, (Direction, Point2D)> = HashMap::new();
315        let mut to_inspect: VecDeque<Point2D> = VecDeque::new();
316        to_inspect.push_back(from);
317
318        while let Some(current_position) = to_inspect.pop_front() {
319            for (direction, neighbor) in current_position.neighbors(tessellation, size)? {
320                if visited.contains(neighbor) || is_obstacle(neighbor) {
321                    continue;
322                }
323
324                visited.add_point(neighbor);
325                came_from.insert(neighbor, (direction, current_position));
326
327                if neighbor == to {
328                    let mut path = Vec::new();
329                    let mut position = to;
330
331                    while position != from {
332                        let (direction, previous) = came_from.get(&position)?;
333                        path.push(*direction);
334                        position = *previous;
335                    }
336
337                    path.reverse();
338                    return Some(path);
339                }
340
341                to_inspect.push_back(neighbor);
342            }
343        }
344
345        None
346    }
347
348    /// Corners in playable area.
349    ///
350    /// ![](https://raw.githubusercontent.com/tadams42/sokoban-elements/main/crates/sokoban-elements/doc/img/corners_in_playable_area.png)
351    /// ![](https://raw.githubusercontent.com/tadams42/sokoban-elements/main/crates/sokoban-elements/doc/img/hexoban_corners.png)
352    /// ![](https://raw.githubusercontent.com/tadams42/sokoban-elements/main/crates/sokoban-elements/doc/img/trioban_corners.png)
353    /// ![](https://raw.githubusercontent.com/tadams42/sokoban-elements/main/crates/sokoban-elements/doc/img/octoban_corners.png)
354    pub fn corners(&self) -> BoardArea {
355        let mut retv = BoardArea::new(self.size);
356
357        for pos in self.playable_area() {
358            if !self.walls_positions.contains(pos) && self.is_corner(pos) {
359                retv.add_point(pos);
360            }
361        }
362
363        retv
364    }
365
366    /// Helper for [`GameBoard`](crate::GameBoard) scanners. Builds a [Boxbox] for the `maybe_id`-th
367    /// box of a scan, tagged from `sokoban_plus`.
368    pub fn build_box(
369        maybe_id: usize, x: usize, y: usize, sokoban_plus: Option<&SokobanPlus>,
370    ) -> Result<Boxbox, BoardSyntaxError> {
371        let box_id = BoxID::try_from(maybe_id).map_err(|e| {
372            BoardSyntaxError::new_with_source(BoardSyntaxErrorKind::TooManyBoxes, Some(e))
373        })?;
374
375        Ok(Boxbox {
376            id: box_id,
377            plus_id: sokoban_plus.and_then(|sp| sp.get_box_plus_id(box_id)),
378            x,
379            y,
380        })
381    }
382
383    /// Helper for [`GameBoard`](crate::GameBoard) scanners. Builds a [Goal] for the `maybe_id`-th
384    /// goal of a scan, tagged from `sokoban_plus`.
385    pub fn build_goal(
386        maybe_id: usize, x: usize, y: usize, sokoban_plus: Option<&SokobanPlus>,
387    ) -> Result<Goal, BoardSyntaxError> {
388        let goal_id = GoalID::try_from(maybe_id).map_err(|e| {
389            BoardSyntaxError::new_with_source(BoardSyntaxErrorKind::TooManyGoals, Some(e))
390        })?;
391
392        Ok(Goal {
393            id: goal_id,
394            plus_id: sokoban_plus.and_then(|sp| sp.get_goal_plus_id(goal_id)),
395            x,
396            y,
397        })
398    }
399
400    /// Helper for [`GameBoard`](crate::GameBoard) scanners. Builds a [Pusher] for the `maybe_id`-th
401    /// pusher of a scan.
402    pub fn build_pusher(maybe_id: usize, x: usize, y: usize) -> Result<Pusher, BoardSyntaxError> {
403        let pusher_id = PusherID::try_from(maybe_id).map_err(|e| {
404            BoardSyntaxError::new_with_source(BoardSyntaxErrorKind::TooManyPushers, Some(e))
405        })?;
406        Ok(Pusher {
407            id: pusher_id,
408            x,
409            y,
410        })
411    }
412
413    /// Is the given position a corner, ie. is it adjacent to walls in such a way that a box placed
414    /// there would be stuck?
415    fn is_corner(&self, at: Point2D) -> bool {
416        let is_wall =
417            |p: Option<Point2D>| -> bool { p.is_some_and(|pp| self.walls_positions.contains(pp)) };
418
419        match self.tessellation {
420            Tessellation::Sokoban => {
421                let l = at.neighbor(self.tessellation, Direction::Left, self.size);
422                let u = at.neighbor(self.tessellation, Direction::Up, self.size);
423                let r = at.neighbor(self.tessellation, Direction::Right, self.size);
424                let d = at.neighbor(self.tessellation, Direction::Down, self.size);
425
426                (is_wall(l) && is_wall(u))
427                    || (is_wall(u) && is_wall(r))
428                    || (is_wall(r) && is_wall(d))
429                    || (is_wall(d) && is_wall(l))
430            }
431
432            Tessellation::Hexoban => {
433                let l = at.neighbor(self.tessellation, Direction::Left, self.size);
434                let r = at.neighbor(self.tessellation, Direction::Right, self.size);
435                let nw = at.neighbor(self.tessellation, Direction::NorthWest, self.size);
436                let ne = at.neighbor(self.tessellation, Direction::NorthEast, self.size);
437                let sw = at.neighbor(self.tessellation, Direction::SouthWest, self.size);
438                let se = at.neighbor(self.tessellation, Direction::SouthEast, self.size);
439
440                (is_wall(l) && is_wall(nw) && is_wall(ne))
441                    || (is_wall(nw) && is_wall(ne) && is_wall(r))
442                    || (is_wall(ne) && is_wall(r) && is_wall(se))
443                    || (is_wall(r) && is_wall(se) && is_wall(sw))
444                    || (is_wall(se) && is_wall(sw) && is_wall(l))
445                    || (is_wall(sw) && is_wall(l) && is_wall(nw))
446            }
447
448            Tessellation::Trioban => {
449                at.neighbors(self.tessellation, self.size)
450                    .map_or(0, |iter_neighbors| {
451                        iter_neighbors.filter(|(_d, p)| is_wall(Some(*p))).count()
452                    })
453                    >= 4
454            }
455
456            Tessellation::Octoban => {
457                if self.tessellation.tile_shape_from_pos(at.x, at.y) == TileShape::Octagon {
458                    let l = at.neighbor(self.tessellation, Direction::Left, self.size);
459                    let u = at.neighbor(self.tessellation, Direction::Up, self.size);
460                    let r = at.neighbor(self.tessellation, Direction::Right, self.size);
461                    let d = at.neighbor(self.tessellation, Direction::Down, self.size);
462                    let nw = at.neighbor(self.tessellation, Direction::NorthWest, self.size);
463                    let ne = at.neighbor(self.tessellation, Direction::NorthEast, self.size);
464                    let sw = at.neighbor(self.tessellation, Direction::SouthWest, self.size);
465                    let se = at.neighbor(self.tessellation, Direction::SouthEast, self.size);
466
467                    (is_wall(l) && is_wall(nw) && is_wall(u))
468                        || (is_wall(u) && is_wall(ne) && is_wall(r))
469                        || (is_wall(r) && is_wall(se) && is_wall(d))
470                        || (is_wall(d) && is_wall(sw) && is_wall(l))
471                } else {
472                    // if at.tile_shape() == TileShape::Square {
473                    let l = at.neighbor(self.tessellation, Direction::Left, self.size);
474                    let u = at.neighbor(self.tessellation, Direction::Up, self.size);
475                    let r = at.neighbor(self.tessellation, Direction::Right, self.size);
476                    let d = at.neighbor(self.tessellation, Direction::Down, self.size);
477
478                    (is_wall(l) && is_wall(u))
479                        || (is_wall(u) && is_wall(r))
480                        || (is_wall(r) && is_wall(d))
481                        || (is_wall(d) && is_wall(l))
482                }
483            }
484        }
485    }
486
487    /// All board positions that are touching active [`playable_area()`](Self::playable_area()).
488    ///
489    /// In following image, tiles that are not greyed out are positions returned by this method.
490    /// Notice how it also correctly detects a missing wall tile (circled in red).
491    ///
492    /// ![](https://raw.githubusercontent.com/tadams42/sokoban-elements/main/crates/sokoban-elements/doc/img/significant_walls.png)
493    /// ![](https://raw.githubusercontent.com/tadams42/sokoban-elements/main/crates/sokoban-elements/doc/img/hexoban_significant_walls.png)
494    /// ![](https://raw.githubusercontent.com/tadams42/sokoban-elements/main/crates/sokoban-elements/doc/img/trioban_significant_walls.png)
495    pub fn significant_walls(&self) -> BoardArea {
496        let mut playable_area = self.playable_area();
497
498        let mut non_area = BoardArea::new(self.size);
499        for y in 0..self.size.height {
500            for x in 0..self.size.width {
501                let pos = Point2D::new(x, y);
502                if !playable_area.contains(pos) {
503                    non_area.add_point(pos);
504                }
505            }
506        }
507
508        // Add any position that directly touches area
509        let mut retv = BoardArea::new(self.size);
510        let mut remainder = BoardArea::new(self.size);
511        for pos in non_area {
512            if pos
513                .neighbors(self.tessellation, self.size)
514                .is_some_and(|mut iter_neigh| iter_neigh.any(|(_d, p)| playable_area.contains(p)))
515            {
516                retv.add_point(pos);
517            } else {
518                remainder.add_point(pos);
519            }
520        }
521
522        for pos in &retv {
523            playable_area.add_point(pos);
524        }
525
526        if self.tessellation == Tessellation::Sokoban {
527            for pos in self.additional_sig_walls_squares(&playable_area, &remainder) {
528                retv.add_point(pos);
529            }
530        }
531        if self.tessellation == Tessellation::Trioban {
532            for pos in self.additional_sig_walls_triangles(&playable_area, &remainder) {
533                retv.add_point(pos);
534            }
535        }
536
537        retv
538    }
539
540    /// Additional significant walls for Trioban tessellation.
541    fn additional_sig_walls_triangles(
542        &self, found_so_far: &BoardArea, to_check: &BoardArea,
543    ) -> BoardArea {
544        let mut retv = BoardArea::new(self.size);
545
546        for pos in to_check {
547            if pos
548                .neighbors(self.tessellation, self.size)
549                .map_or(0, |nps| nps.filter(|(_d, p)| found_so_far.contains(*p)).count())
550                > 1
551            {
552                retv.add_point(pos);
553            }
554        }
555
556        retv
557    }
558
559    /// Additional significant walls for Sokoban tessellation.
560    fn additional_sig_walls_squares(
561        &self, found_so_far: &BoardArea, to_check: &BoardArea,
562    ) -> BoardArea {
563        let mut retv = BoardArea::new(self.size);
564
565        let neighbor = |p: Option<Point2D>, d: Direction| -> Option<Point2D> {
566            if let Some(p1) = p {
567                p1.neighbor(self.tessellation, d, self.size)
568            } else {
569                None
570            }
571        };
572
573        let is_in_area =
574            |p: Option<Point2D>| -> bool { p.is_some_and(|pp| found_so_far.contains(pp)) };
575
576        let is_wall =
577            |p: Option<Point2D>| -> bool { p.is_some_and(|pp| self.walls_positions.contains(pp)) };
578
579        // If `pos` is corner wall position and it has two neighbor walls that are already in play
580        // area and if between those 2 walls there is no  wall, then this corner should also be
581        // marked as belonging to significant walls
582
583        for pos in to_check {
584            let l = pos.neighbor(self.tessellation, Direction::Left, self.size);
585            let u = pos.neighbor(self.tessellation, Direction::Up, self.size);
586            let r = pos.neighbor(self.tessellation, Direction::Right, self.size);
587            let d = pos.neighbor(self.tessellation, Direction::Down, self.size);
588
589            let ne = neighbor(u, Direction::Right);
590            let se = neighbor(d, Direction::Right);
591            let sw = neighbor(d, Direction::Left);
592            let nw = neighbor(u, Direction::Left);
593
594            if (is_wall(u) && is_wall(r) && is_in_area(u) && is_in_area(r) && !is_wall(ne))
595                || (is_wall(r) && is_wall(d) && is_in_area(r) && is_in_area(d) && !is_wall(se))
596                || (is_wall(d) && is_wall(l) && is_in_area(d) && is_in_area(l) && !is_wall(sw))
597                || (is_wall(l) && is_wall(u) && is_in_area(l) && is_in_area(u) && !is_wall(nw))
598            {
599                retv.add_point(pos);
600            }
601        }
602
603        retv
604    }
605}
606
607#[cfg(test)]
608mod tests {
609    use std::collections::HashSet;
610
611    use indoc::indoc;
612
613    use super::*;
614    use crate::{GameBoard, Tessellation};
615
616    #[test]
617    fn it_calculates_playable_areas() -> Result<(), BoardSyntaxError> {
618        // Test data generated after visually inspecting it works. Probably should be
619        // split into multiple tests that each presents one pathological case
620        // from the following
621        //
622        // - corner wall in non playable area
623        // - missing corner wall is detected
624        // - walls fully surrounded by other walls should not be included
625
626        let src = indoc! {"#####   ###########
627                           #@  ######       ##
628                           #$  #   # . * $   #
629                           #. *#$  ##        #
630                           #####  $###  ######
631                            ##  $ $  #      ##
632                           ### # ##   # ######
633                           #   # ##  ##### ..#
634                           # $  $          ..#
635                           ##### ### #@    .+#
636                           #  ##     #########
637                           #   ####  ##  ### #
638                           ########  #   ### #
639                           #    ###  ####### #
640                           # ##              #
641                           ###################"};
642
643        let board = GameBoard::parsed(src, Tessellation::Sokoban)?;
644
645        let expected_playable: HashSet<usize> = HashSet::from_iter([
646            20, 21, 22, 39, 40, 41, 43, 44, 45, 58, 59, 60, 62, 63, 64, 81, 82, 83, 98, 99, 100,
647            101, 102, 103, 104, 117, 119, 122, 123, 124, 134, 135, 136, 138, 141, 142, 148, 149,
648            150, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168,
649            169, 176, 180, 182, 183, 184, 185, 186, 187, 188, 195, 196, 197, 198, 199, 217, 218,
650            226, 236, 237, 245, 248, 249, 250, 251, 255, 256, 264, 267, 270, 271, 272, 273, 274,
651            275, 276, 277, 278, 279, 280, 281, 282, 283,
652        ]);
653
654        assert_eq!(
655            HashSet::from_iter(
656                StaticBoardAnalysis::new(
657                    board.iter().map(|(_, t)| t),
658                    board.size(),
659                    board.tessellation(),
660                    None
661                )?
662                .playable_area()
663                .iter()
664                .map(|p| p.idx(board.size()))
665            ),
666            expected_playable
667        );
668
669        let expected_walls: HashSet<usize> = HashSet::from_iter([
670            0, 1, 2, 3, 4, 19, 23, 24, 25, 26, 27, 38, 42, 46, 57, 61, 65, 76, 77, 78, 79, 80, 84,
671            85, 86, 97, 105, 106, 114, 115, 116, 118, 120, 121, 125, 128, 129, 130, 131, 132, 133,
672            137, 139, 140, 143, 144, 145, 146, 147, 151, 152, 170, 171, 172, 173, 174, 175, 177,
673            178, 179, 181, 189, 194, 200, 201, 202, 203, 204, 205, 206, 207, 208, 213, 214, 215,
674            216, 219, 225, 227, 228, 229, 230, 231, 232, 233, 235, 238, 244, 246, 247, 252, 253,
675            254, 257, 258, 259, 260, 261, 262, 263, 265, 266, 268, 269, 284, 285, 286, 287, 288,
676            289, 290, 291, 292, 293, 294, 295, 296, 297, 298, 299, 300, 301, 302, 303,
677        ]);
678        assert_eq!(
679            HashSet::from_iter(
680                StaticBoardAnalysis::new(
681                    board.iter().map(|(_, t)| t),
682                    board.size(),
683                    board.tessellation(),
684                    None
685                )?
686                .significant_walls()
687                .iter()
688                .map(|p| p.idx(board.size()))
689            ),
690            expected_walls
691        );
692
693        Ok(())
694    }
695
696    #[test]
697    fn it_calculates_hexoban_outer_cells() -> Result<(), BoardSyntaxError> {
698        let src = indoc! {"-----------#-#-#---
699                           ----------#-----#--
700                           ---------#-$-.---#-
701                           ----#-#-#---*---#--
702                           ---#-#---$-.-$-#---
703                           --#-@-.-*-.-.---#--
704                           ---#---$---$-*-$-#-
705                           ----#-#-#-#---.---#
706                           -----------#-----#-
707                           ------------#-#-#--"};
708
709        let board = GameBoard::parsed(src, Tessellation::Hexoban)?;
710
711        let expected: HashSet<usize> = HashSet::from_iter([
712            0, 1, 2, 3, 4, 8, 9, 10, 11, 12, 17, 18, 19, 20, 21, 27, 28, 35, 36, 44, 53, 54, 63,
713            72, 73, 74, 75, 76, 81, 82, 83, 84, 85, 89,
714        ]);
715        assert_eq!(
716            HashSet::from_iter(
717                StaticBoardAnalysis::new(
718                    board.iter().map(|(_, t)| t),
719                    board.size(),
720                    board.tessellation(),
721                    None
722                )?
723                .outer_area()
724                .iter()
725                .map(|p| p.idx(board.size()))
726            ),
727            expected
728        );
729
730        Ok(())
731    }
732}