Skip to main content

sokoban_elements/game_board/
transform.rs

1//! Rigid transformations of board coordinates.
2//!
3//! Raw tessellation maps may produce negative coordinates. Re-anchoring starts with the tight
4//! bounding box, then considers adding zero or one column and row. Of the anchors that are rigid
5//! translations, this module chooses the smallest resulting area, with candidate order as the
6//! deterministic tie-breaker.
7//!
8//! Hexoban could sometimes save a padding column by reindexing odd and even rows independently.
9//! The shared engine deliberately uses integer translations only; the simpler rule is sufficient
10//! for every transform and keeps anchoring uniform across tessellations.
11//!
12//! See also: `board-geometry-and-transformations.md` in `doc/dev-notes/`.
13
14use super::parser::{ParsedBoard, assert_width_normalized};
15use crate::{Tessellation, TileKind};
16
17/// Axis reflected across by [`GameBoard::mirror()`](crate::GameBoard::mirror).
18#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
19pub enum MirrorAxis {
20    /// Reflect across a horizontal axis, exchanging the top and bottom edges.
21    Horizontal,
22
23    /// Reflect across a vertical axis, exchanging the left and right edges.
24    Vertical,
25}
26
27/// Direction of one tessellation-specific [`GameBoard::rotate()`](crate::GameBoard::rotate) step.
28#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
29pub enum Rotation {
30    Clockwise,
31    CounterClockwise,
32}
33
34#[derive(Debug, Copy, Clone, PartialEq, Eq)]
35pub(crate) enum BoardTransform {
36    MirrorHorizontal,
37    MirrorVertical,
38    RotateClockwise,
39    RotateCounterClockwise,
40}
41
42fn to_i64(value: usize) -> i64 {
43    // Boards are orders of magnitude smaller than this bound; saturation is unreachable.
44    i64::try_from(value).unwrap_or(i64::MAX)
45}
46
47fn to_usize(value: i64) -> usize {
48    // Anchoring makes every coordinate non-negative and real boards fit comfortably in `usize`.
49    usize::try_from(value).unwrap_or(0)
50}
51
52fn raw_bounds(
53    body: &ParsedBoard, tessellation: Tessellation, transform: BoardTransform,
54) -> (i64, i64, i64, i64) {
55    let mut min_x = i64::MAX;
56    let mut min_y = i64::MAX;
57    let mut max_x = i64::MIN;
58    let mut max_y = i64::MIN;
59
60    for (y, row) in body.iter().enumerate() {
61        for x in 0..row.len() {
62            let (mapped_x, mapped_y) =
63                tessellation.transformed_raw(transform, to_i64(x), to_i64(y));
64            min_x = min_x.min(mapped_x);
65            min_y = min_y.min(mapped_y);
66            max_x = max_x.max(mapped_x);
67            max_y = max_y.max(mapped_y);
68        }
69    }
70
71    (min_x, min_y, max_x, max_y)
72}
73
74fn anchored_size(max_x: i64, max_y: i64, dx: i64, dy: i64) -> (usize, usize) {
75    (to_usize(max_x + dx + 1), to_usize(max_y + dy + 1))
76}
77
78fn best_anchor(
79    tessellation: Tessellation, min_x: i64, min_y: i64, max_x: i64, max_y: i64,
80) -> (i64, i64, usize, usize) {
81    let tight_dx = -min_x;
82    let tight_dy = -min_y;
83    let mut best: Option<(i64, i64, usize, usize, usize)> = None;
84
85    for (extra_x, extra_y) in [(0_i64, 0_i64), (1, 0), (0, 1), (1, 1)] {
86        let dx = tight_dx + extra_x;
87        let dy = tight_dy + extra_y;
88        if !tessellation.is_rigid_translation(dx, dy) {
89            continue;
90        }
91
92        let (width, height) = anchored_size(max_x, max_y, dx, dy);
93        let area = width.saturating_mul(height);
94        if best.is_none_or(|(_, _, _, _, best_area)| area < best_area) {
95            best = Some((dx, dy, width, height, area));
96        }
97    }
98
99    // Every supported translation lattice has period at most two on each axis, so one of the
100    // four candidates above is always rigid.
101    best.map_or((tight_dx, tight_dy, 0, 0), |(dx, dy, width, height, _)| {
102        (dx, dy, width, height)
103    })
104}
105
106pub(super) fn transformed(
107    body: &ParsedBoard, tessellation: Tessellation, transform: BoardTransform,
108) -> ParsedBoard {
109    assert_width_normalized(body);
110    if body.is_empty() {
111        return ParsedBoard::new();
112    }
113
114    let (min_x, min_y, max_x, max_y) = raw_bounds(body, tessellation, transform);
115    let (dx, dy, width, height) = best_anchor(tessellation, min_x, min_y, max_x, max_y);
116    let mut transformed = vec![vec![TileKind::Floor; width]; height];
117
118    for (y, row) in body.iter().enumerate() {
119        for (x, tile) in row.iter().enumerate() {
120            let (mapped_x, mapped_y) =
121                tessellation.transformed_raw(transform, to_i64(x), to_i64(y));
122            transformed[to_usize(mapped_y + dy)][to_usize(mapped_x + dx)] = *tile;
123        }
124    }
125
126    transformed
127}
128
129#[cfg(test)]
130mod tests {
131    use std::collections::{HashMap, HashSet};
132
133    use super::*;
134
135    const TESSELLATIONS: [Tessellation; 4] = [
136        Tessellation::Sokoban,
137        Tessellation::Hexoban,
138        Tessellation::Trioban,
139        Tessellation::Octoban,
140    ];
141    const TRANSFORMS: [BoardTransform; 4] = [
142        BoardTransform::MirrorHorizontal,
143        BoardTransform::MirrorVertical,
144        BoardTransform::RotateClockwise,
145        BoardTransform::RotateCounterClockwise,
146    ];
147    const SIZES: [(usize, usize); 6] = [(4, 4), (4, 5), (5, 4), (5, 5), (7, 3), (1, 5)];
148
149    type Position = (usize, usize);
150    type PositionMap = HashMap<Position, Position>;
151
152    fn mapping(
153        tessellation: Tessellation, transform: BoardTransform, width: usize, height: usize,
154    ) -> (Position, PositionMap) {
155        let body = vec![vec![TileKind::Floor; width]; height];
156        let (min_x, min_y, max_x, max_y) = raw_bounds(&body, tessellation, transform);
157        let (dx, dy, transformed_width, transformed_height) =
158            best_anchor(tessellation, min_x, min_y, max_x, max_y);
159        let positions = (0..height)
160            .flat_map(|y| {
161                (0..width).map(move |x| {
162                    let (mapped_x, mapped_y) =
163                        tessellation.transformed_raw(transform, to_i64(x), to_i64(y));
164                    ((x, y), (to_usize(mapped_x + dx), to_usize(mapped_y + dy)))
165                })
166            })
167            .collect();
168        ((transformed_width, transformed_height), positions)
169    }
170
171    fn neighbors(
172        tessellation: Tessellation, position: (usize, usize), width: usize, height: usize,
173    ) -> HashSet<(usize, usize)> {
174        tessellation
175            .known_directions()
176            .iter()
177            .filter_map(|direction| {
178                tessellation.neighbor(position.0, position.1, *direction, width, height)
179            })
180            .collect()
181    }
182
183    /// The re-anchoring rule of section 5 of
184    /// `doc/dev-notes/board-geometry-and-transformations.md`, which that document calls "the only
185    /// free choice in the whole design" - and which nothing pinned until now.
186    ///
187    /// Expected sizes are computed from the section 4 maps plus the documented rule (smallest area
188    /// over the four candidate offsets, first found wins), cross-checked against an independent
189    /// implementation. Deliberately *not* taken from the probe run recorded in section 11: that
190    /// probe takes the first valid candidate rather than the smallest-area one, so its figures are
191    /// not the engine's.
192    mod test_anchoring {
193        use super::*;
194
195        /// A source size and the size it anchors to under each of `TRANSFORMS`, in that order.
196        type SizeExpectation = (Tessellation, Position, [Position; 4]);
197
198        #[rustfmt::skip]
199        #[test]
200        fn it_anchors_to_the_smallest_area() {
201            // (tessellation, source, [MirrorH, MirrorV, RotateCW, RotateCCW])
202            let expectations: [SizeExpectation; 8] = [
203                (Tessellation::Sokoban, (5, 4), [(5, 4), (5, 4), (4, 5), (4, 5)]),
204                (Tessellation::Sokoban, (4, 3), [(4, 3), (4, 3), (3, 4), (3, 4)]),
205                (Tessellation::Hexoban, (4, 4), [(4, 5), (5, 4), (4, 6), (5, 6)]),
206                (Tessellation::Hexoban, (4, 3), [(4, 3), (5, 3), (4, 5), (4, 6)]),
207                (Tessellation::Trioban, (4, 4), [(4, 4), (5, 4), (7, 4), (7, 4)]),
208                (Tessellation::Trioban, (5, 5), [(6, 5), (5, 5), (10, 5), (10, 5)]),
209                (Tessellation::Octoban, (5, 4), [(6, 4), (5, 4), (4, 6), (4, 5)]),
210                (Tessellation::Octoban, (4, 3), [(4, 3), (5, 3), (3, 4), (3, 5)]),
211            ];
212
213            for (tessellation, (width, height), wanted) in expectations {
214                for (transform, want) in TRANSFORMS.iter().zip(wanted) {
215                    let (size, _) = mapping(tessellation, *transform, width, height);
216                    assert_eq!(
217                        size, want,
218                        "{tessellation:?} {width}x{height} under {transform:?}"
219                    );
220                }
221            }
222        }
223
224        /// When the column-adding and row-adding candidates have *exactly* the same area, the
225        /// column wins - decided only by the candidate order `[(0,0), (1,0), (0,1), (1,1)]` and by
226        /// the comparison being strictly `<`. Relaxing either flips precisely these cases and
227        /// nothing else in the suite notices.
228        #[test]
229        fn it_breaks_an_exact_area_tie_by_adding_a_column() {
230            let ties = [
231                (
232                    Tessellation::Octoban,
233                    (2, 2),
234                    BoardTransform::MirrorHorizontal,
235                    (3, 2),
236                ),
237                (Tessellation::Octoban, (2, 2), BoardTransform::RotateClockwise, (3, 2)),
238                (
239                    Tessellation::Octoban,
240                    (4, 4),
241                    BoardTransform::MirrorHorizontal,
242                    (5, 4),
243                ),
244                (
245                    Tessellation::Trioban,
246                    (3, 3),
247                    BoardTransform::MirrorHorizontal,
248                    (4, 3),
249                ),
250                (
251                    Tessellation::Trioban,
252                    (5, 5),
253                    BoardTransform::MirrorHorizontal,
254                    (6, 5),
255                ),
256                (Tessellation::Trioban, (4, 4), BoardTransform::MirrorVertical, (5, 4)),
257            ];
258
259            for (tessellation, (width, height), transform, wanted) in ties {
260                let (size, _) = mapping(tessellation, transform, width, height);
261                assert_eq!(size, wanted, "{tessellation:?} {width}x{height} under {transform:?}");
262
263                // The row-adding alternative really is available and really does tie, so the
264                // choice above is the tie-break and not just the smaller option.
265                let body = vec![vec![TileKind::Floor; width]; height];
266                let (min_x, min_y, max_x, max_y) = raw_bounds(&body, tessellation, transform);
267                let column = anchored_size(max_x, max_y, -min_x + 1, -min_y);
268                let row = anchored_size(max_x, max_y, -min_x, -min_y + 1);
269
270                assert!(tessellation.is_rigid_translation(-min_x + 1, -min_y));
271                assert!(tessellation.is_rigid_translation(-min_x, -min_y + 1));
272                assert_eq!(column, wanted);
273                assert_eq!(
274                    column.0 * column.1,
275                    row.0 * row.1,
276                    "{tessellation:?} {width}x{height} {transform:?}: {column:?} vs {row:?} \
277                     should tie on area"
278                );
279            }
280        }
281
282        /// A transform may leave destination cells with no source cell mapping onto them. Section 5
283        /// says this "is not a bug" - the anchored bounding box is what the tiling admits, not a
284        /// tight fit - so pin it rather than leave it to be "fixed" later.
285        #[test]
286        fn it_leaves_padding_cells_with_no_preimage() {
287            let expectations = [
288                // A whole column, because Trioban's horizontal mirror shifts every row.
289                (
290                    Tessellation::Trioban,
291                    (4, 3),
292                    BoardTransform::MirrorHorizontal,
293                    vec![(0, 0), (0, 1), (0, 2)],
294                ),
295                // Hexoban's row parities mirror to different columns, so the holes alternate
296                // between the two edges. Listed row-major, as the scan below yields them.
297                (
298                    Tessellation::Hexoban,
299                    (4, 3),
300                    BoardTransform::MirrorVertical,
301                    vec![(0, 0), (4, 1), (0, 2)],
302                ),
303            ];
304
305            for (tessellation, (width, height), transform, wanted) in expectations {
306                let ((transformed_width, transformed_height), positions) =
307                    mapping(tessellation, transform, width, height);
308                let covered: HashSet<Position> = positions.values().copied().collect();
309
310                let holes: Vec<Position> = (0..transformed_height)
311                    .flat_map(|y| (0..transformed_width).map(move |x| (x, y)))
312                    .filter(|position| !covered.contains(position))
313                    .collect();
314
315                assert_eq!(holes, wanted, "{tessellation:?} {width}x{height} under {transform:?}");
316            }
317        }
318    }
319
320    #[test]
321    fn every_transform_is_injective_and_preserves_adjacency() {
322        for tessellation in TESSELLATIONS {
323            for (width, height) in SIZES {
324                for transform in TRANSFORMS {
325                    let ((new_width, new_height), positions) =
326                        mapping(tessellation, transform, width, height);
327                    let image: HashSet<_> = positions.values().copied().collect();
328                    assert_eq!(
329                        image.len(),
330                        width * height,
331                        "{tessellation:?} {width}x{height} {transform:?} is not injective"
332                    );
333
334                    for (source, destination) in &positions {
335                        let mapped_neighbors: HashSet<_> =
336                            neighbors(tessellation, *source, width, height)
337                                .iter()
338                                .map(|neighbor| positions[neighbor])
339                                .collect();
340                        let destination_neighbors: HashSet<_> =
341                            neighbors(tessellation, *destination, new_width, new_height)
342                                .intersection(&image)
343                                .copied()
344                                .collect();
345
346                        assert_eq!(
347                            mapped_neighbors, destination_neighbors,
348                            "{tessellation:?} {width}x{height} {transform:?}: {source:?} -> \
349                             {destination:?}"
350                        );
351                    }
352                }
353            }
354        }
355    }
356
357    #[test]
358    fn every_transform_preserves_the_multiset_of_non_floor_tiles() {
359        const MARKERS: [TileKind; 6] = [
360            TileKind::Wall,
361            TileKind::Pusher,
362            TileKind::Box,
363            TileKind::Goal,
364            TileKind::BoxOnGoal,
365            TileKind::PusherOnGoal,
366        ];
367
368        for tessellation in TESSELLATIONS {
369            for (width, height) in SIZES {
370                let body: ParsedBoard = (0..height)
371                    .map(|y| {
372                        (0..width)
373                            .map(|x| MARKERS[(y * width + x) % MARKERS.len()])
374                            .collect()
375                    })
376                    .collect();
377                for transform in TRANSFORMS {
378                    let result = transformed(&body, tessellation, transform);
379                    let actual = MARKERS.map(|marker| {
380                        result
381                            .iter()
382                            .flatten()
383                            .filter(|&&tile| tile == marker)
384                            .count()
385                    });
386                    let expected = MARKERS.map(|marker| {
387                        body.iter()
388                            .flatten()
389                            .filter(|&&tile| tile == marker)
390                            .count()
391                    });
392                    assert_eq!(actual, expected, "{tessellation:?} {width}x{height} {transform:?}");
393                }
394            }
395        }
396    }
397
398    #[test]
399    fn empty_boards_are_unchanged_by_every_transform() {
400        for tessellation in TESSELLATIONS {
401            for transform in TRANSFORMS {
402                assert!(transformed(&ParsedBoard::new(), tessellation, transform).is_empty());
403            }
404        }
405    }
406}