sokoban_elements/tessellation.rs
1mod errors;
2mod hexoban;
3mod octoban;
4mod sokoban;
5mod trioban;
6
7use std::fmt::Debug;
8use std::hash::Hash;
9
10use errors::{TessellationError, validate_and_apply_delta};
11
12use crate::game_board::BoardTransform;
13use crate::{Direction, DirectionsSet, Size2D, TileShape};
14
15/// Sokoban game variants.
16#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
17#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
18pub enum Tessellation {
19 /// Classic Sokoban game variant.
20 ///
21 /// Game board is grid of squares.
22 ///
23 /// [`Direction`](crate::Direction) <-> character mapping:
24 ///
25 /// | Direction | Characters |
26 /// | ------------------ | ---------- |
27 /// | `Direction::Left` | `l`, `L` |
28 /// | `Direction::Right` | `r`, `R` |
29 /// | `Direction::Up` | `u`, `U` |
30 /// | `Direction::Down` | `d`, `D` |
31 ///
32 /// Example board
33 ///
34 /// 
35 #[default]
36 Sokoban,
37
38 /// Hexoban game variant.
39 ///
40 /// Game board is grid of vertical, equilateral hexagons with following coordinate system:
41 ///
42 /// 
43 ///
44 /// Textual representation uses two characters for each hexagon. This allows different encoding
45 /// schemes.
46 ///
47 /// | Scheme 1 | Scheme 2 |
48 /// | -------------------- | -------------------- |
49 /// | ![][hexoban_scheme1] | ![][hexoban_scheme2] |
50 ///
51 /// [hexoban_scheme1]: https://raw.githubusercontent.com/tadams42/sokoban-elements/main/crates/sokoban-elements/doc/img/hexoban_scheme1.png
52 /// [hexoban_scheme2]: https://raw.githubusercontent.com/tadams42/sokoban-elements/main/crates/sokoban-elements/doc/img/hexoban_scheme2.png
53 ///
54 /// As long as encoding of single board is consistent, all methods handle any scheme
55 /// transparently - parsing of board strings 'Just Works ™'.
56 ///
57 /// [`Direction`](crate::Direction) <-> character mapping:
58 ///
59 /// | Direction | Characters |
60 /// | ---------------------- | ---------- |
61 /// | `Direction::Left` | `l`, `L` |
62 /// | `Direction::Right` | `r`, `R` |
63 /// | `Direction::NorthWest` | `u`, `U` |
64 /// | `Direction::SouthWest` | `d`, `D` |
65 /// | `Direction::NorthEast` | `n`, `N` |
66 /// | `Direction::SouthEast` | `s`, `S` |
67 ///
68 /// Example board
69 ///
70 /// 
71 Hexoban,
72
73 /// Trioban game variant.
74 ///
75 /// Game board is grid of alternating triangles with origin triangle pointing down.
76 ///
77 /// Depending on pusher position, not all move directions are allowed on all board positions:
78 ///
79 /// 
80 ///
81 /// [`Direction`](crate::Direction) <-> character mapping:
82 ///
83 /// | Direction | Characters |
84 /// | ---------------------- | ---------- |
85 /// | `Direction::Left` | `l`, `L` |
86 /// | `Direction::Right` | `r`, `R` |
87 /// | `Direction::NorthWest` | `u`, `U` |
88 /// | `Direction::SouthWest` | `s`, `S` |
89 /// | `Direction::NorthEast` | `n`, `N` |
90 /// | `Direction::SouthEast` | `d`, `D` |
91 ///
92 /// Example board
93 ///
94 /// 
95 Trioban,
96
97 /// Octoban game variant.
98 ///
99 /// Game board is grid of alternating squares and octagons with origin of coordinate system
100 /// being octagon.
101 ///
102 /// 
103 ///
104 /// This game variant allows usage of all 8 directions of movement from
105 /// [`Direction`](crate::Direction). Depending on current pusher position, some of these
106 /// directions may not result in successful move or may even be invalid (ie. trying to move
107 /// north-west while standing on square tile).
108 ///
109 /// [`Direction`](crate::Direction) <-> character mapping:
110 ///
111 /// | Direction | Characters |
112 /// | ---------------------- | ---------- |
113 /// | `Direction::Up` | `u`, `U` |
114 /// | `Direction::NorthEast` | `n`, `N` |
115 /// | `Direction::Right` | `r`, `R` |
116 /// | `Direction::SouthEast` | `e`, `E` |
117 /// | `Direction::Down` | `d`, `D` |
118 /// | `Direction::SouthWest` | `s`, `S` |
119 /// | `Direction::Left` | `l`, `L` |
120 /// | `Direction::NorthWest` | `w`, `W` |
121 ///
122 /// Example board
123 ///
124 /// 
125 Octoban,
126}
127
128impl Tessellation {
129 /// Number of all possible [`Tessellation`]s.
130 pub const COUNT: usize = 4;
131
132 /// All possible [`Tessellation`]s in order of their discriminant values.
133 pub const ALL: [Tessellation; Tessellation::COUNT] = [
134 Tessellation::Sokoban,
135 Tessellation::Hexoban,
136 Tessellation::Trioban,
137 Tessellation::Octoban,
138 ];
139
140 /// Angle of one call to [`GameBoard::rotate()`](crate::GameBoard::rotate), in degrees.
141 pub fn rotation_step_degrees(&self) -> u16 {
142 match self {
143 Tessellation::Sokoban => sokoban::ROTATION_STEP_DEGREES,
144 Tessellation::Hexoban => hexoban::ROTATION_STEP_DEGREES,
145 Tessellation::Trioban => trioban::ROTATION_STEP_DEGREES,
146 Tessellation::Octoban => octoban::ROTATION_STEP_DEGREES,
147 }
148 }
149
150 /// Smallest axis-aligned integer translation that preserves the tiling.
151 ///
152 /// Board resizing may apply a smaller shift by adding perpendicular padding or, for Hexoban,
153 /// reindexing alternating rows.
154 pub fn translation_period(&self) -> Size2D {
155 match self {
156 Tessellation::Sokoban => Size2D::new(1, 1),
157 Tessellation::Hexoban => Size2D::new(1, 2),
158 Tessellation::Trioban | Tessellation::Octoban => Size2D::new(2, 2),
159 }
160 }
161
162 /// `true` if translation by `(dx, dy)` preserves the tiling.
163 pub(crate) fn is_rigid_translation(self, dx: i64, dy: i64) -> bool {
164 match self {
165 Tessellation::Sokoban => sokoban::is_rigid_translation(dx, dy),
166 Tessellation::Hexoban => hexoban::is_rigid_translation(dx, dy),
167 Tessellation::Trioban => trioban::is_rigid_translation(dx, dy),
168 Tessellation::Octoban => octoban::is_rigid_translation(dx, dy),
169 }
170 }
171
172 /// Result of applying `transform` to `(x, y)`.
173 pub(crate) fn transformed_raw(self, transform: BoardTransform, x: i64, y: i64) -> (i64, i64) {
174 match self {
175 Tessellation::Sokoban => sokoban::transformed_raw(transform, x, y),
176 Tessellation::Hexoban => hexoban::transformed_raw(transform, x, y),
177 Tessellation::Trioban => trioban::transformed_raw(transform, x, y),
178 Tessellation::Octoban => octoban::transformed_raw(transform, x, y),
179 }
180 }
181
182 /// Slice of all directions that are supported by this tessellation.
183 pub fn known_directions(&self) -> &'static [Direction] {
184 match self {
185 Tessellation::Sokoban => &sokoban::KNOWN_DIRECTIONS,
186 Tessellation::Trioban => &trioban::KNOWN_DIRECTIONS,
187 Tessellation::Octoban => &octoban::KNOWN_DIRECTIONS,
188 Tessellation::Hexoban => &hexoban::KNOWN_DIRECTIONS,
189 }
190 }
191
192 /// Slice of all tile shapes that are supported by this tessellation.
193 pub fn tile_shapes(&self) -> &'static [TileShape] {
194 match self {
195 Tessellation::Sokoban => &sokoban::TILE_SHAPES,
196 Tessellation::Trioban => &trioban::TILE_SHAPES,
197 Tessellation::Octoban => &octoban::TILE_SHAPES,
198 Tessellation::Hexoban => &hexoban::TILE_SHAPES,
199 }
200 }
201
202 /// [`TileShape`] for given game board position.
203 ///
204 /// Doesn't check if `(x, y)` is off board position because planar tiling applies to whole 2D
205 /// plane, not just to some part of it enclosed by some game board.
206 pub const fn tile_shape_from_pos(&self, x: usize, y: usize) -> TileShape {
207 match self {
208 Tessellation::Sokoban => sokoban::tile_shape_from_pos(),
209 Tessellation::Hexoban => hexoban::tile_shape_from_pos(),
210 Tessellation::Octoban => octoban::tile_shape_from_pos(x, y),
211 Tessellation::Trioban => trioban::tile_shape_from_pos(x, y),
212 }
213 }
214
215 /// Calculates neighbor position in part of plane enclosed by some game board.
216 ///
217 /// It returns `None` if:
218 ///
219 /// - input coordinate `(x, y)` is off-board coordinate
220 /// - output coordinate in direction `to` would've been off-board coordinate
221 /// - `to` is not supported direction of movement for underlying [`Tessellation`] (for example,
222 /// trying to move north-west in [`Tessellation::Sokoban`] from any position)
223 /// - `to` is supported direction of movement for underlying [`Tessellation`] but is not allowed
224 /// for `from` (for example, trying to move north-west in [`Tessellation::Octoban`] from
225 /// position of square tile)
226 pub fn neighbor(
227 &self, x: usize, y: usize, to: Direction, width: usize, height: usize,
228 ) -> Option<(usize, usize)> {
229 match self {
230 Tessellation::Sokoban => sokoban::neighbor_checked(x, y, to, width, height).ok(),
231 Tessellation::Hexoban => hexoban::neighbor_checked(x, y, to, width, height).ok(),
232 Tessellation::Octoban => octoban::neighbor_checked(x, y, to, width, height).ok(),
233 Tessellation::Trioban => trioban::neighbor_checked(x, y, to, width, height).ok(),
234 }
235 }
236
237 /// Calculates set of directions where all directions in output set lead to valid
238 /// [`Self::neighbor()`] positions.
239 ///
240 /// If input coordinate `(x, y)` is off board position, returns `None`.
241 pub fn neighbor_directions(
242 &self, x: usize, y: usize, width: usize, height: usize,
243 ) -> Option<DirectionsSet> {
244 (x < width && y < height).then(|| {
245 DirectionsSet::from_iter(
246 self.known_directions()
247 .iter()
248 .filter_map(|d| self.neighbor(x, y, *d, width, height).map(|_| *d)),
249 )
250 })
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 /// Board size used by all dispatch tests.
259 ///
260 /// Chosen so that positions `(2, 2)` and `(3, 2)` have every one of their neighbors on-board in
261 /// every tessellation, which keeps `None` in the expectations below meaning "this direction is
262 /// not legal here" rather than "we walked off the edge".
263 const WIDTH: usize = 5;
264 const HEIGHT: usize = 5;
265
266 /// `(x, y, tile shape, neighbor)` in each of [`Direction::ALL`]
267 type PositionExpectation =
268 (usize, usize, TileShape, [Option<(usize, usize)>; Direction::COUNT]);
269
270 /// Hand-computed expectations, one row per position, derived from the coordinate systems
271 /// documented on the [`Tessellation`] variants rather than from the implementation modules.
272 ///
273 /// Every row covers all eight directions, so directions a tessellation does not support are
274 /// pinned as `None` and cannot silently start working.
275 #[rustfmt::skip]
276 const EXPECTATIONS: [(Tessellation, [PositionExpectation; 2]); 4] = [
277 // Up NorthEast Right SouthEast Down SouthWest Left NorthWest
278 (Tessellation::Sokoban, [
279 (2, 2, TileShape::Square, [Some((2, 1)), None, Some((3, 2)), None, Some((2, 3)), None, Some((1, 2)), None]),
280 (3, 2, TileShape::Square, [Some((3, 1)), None, Some((4, 2)), None, Some((3, 3)), None, Some((2, 2)), None]),
281 ]),
282 (Tessellation::Hexoban, [
283 (2, 2, TileShape::Hexagon, [None, Some((2, 1)), Some((3, 2)), Some((2, 3)), None, Some((1, 3)), Some((1, 2)), Some((1, 1))]),
284 (3, 2, TileShape::Hexagon, [None, Some((3, 1)), Some((4, 2)), Some((3, 3)), None, Some((2, 3)), Some((2, 2)), Some((2, 1))]),
285 ]),
286 (Tessellation::Trioban, [
287 (2, 2, TileShape::TriangleDown, [None, Some((2, 1)), Some((3, 2)), Some((3, 2)), None, Some((1, 2)), Some((1, 2)), Some((2, 1))]),
288 (3, 2, TileShape::TriangleUp, [None, Some((4, 2)), Some((4, 2)), Some((3, 3)), None, Some((3, 3)), Some((2, 2)), Some((2, 2))]),
289 ]),
290 (Tessellation::Octoban, [
291 (2, 2, TileShape::Octagon, [Some((2, 1)), Some((3, 1)), Some((3, 2)), Some((3, 3)), Some((2, 3)), Some((1, 3)), Some((1, 2)), Some((1, 1))]),
292 (3, 2, TileShape::Square, [Some((3, 1)), None, Some((4, 2)), None, Some((3, 3)), None, Some((2, 2)), None]),
293 ]),
294 ];
295
296 /// The four `match self` blocks in [`Tessellation`] are the only place a variant is bound to
297 /// its implementation module, and every arm has the same shape — mixing two of them up
298 /// compiles cleanly and is invisible on a read-through. This pins each variant to
299 /// hand-computed behavior that no two tessellations share.
300 #[test]
301 fn it_dispatches_to_the_behavior_of_its_own_tessellation() {
302 for (tessellation, positions) in EXPECTATIONS {
303 for (x, y, tile_shape, neighbors) in positions {
304 assert_eq!(
305 tessellation.tile_shape_from_pos(x, y),
306 tile_shape,
307 "{tessellation:?} tile shape at ({x}, {y})"
308 );
309
310 for (direction, expected) in Direction::ALL.into_iter().zip(neighbors) {
311 assert_eq!(
312 tessellation.neighbor(x, y, direction, WIDTH, HEIGHT),
313 expected,
314 "{tessellation:?} neighbor of ({x}, {y}) to {direction}"
315 );
316 }
317 }
318 }
319 }
320
321 #[test]
322 fn it_dispatches_transform_geometry_to_its_own_tessellation() {
323 #[rustfmt::skip]
324 let expectations = [
325 (Tessellation::Sokoban, 90, Size2D::new(1, 1), true, [(2, -1), (-2, 1), (-1, 2), (1, -2)]),
326 (Tessellation::Hexoban, 60, Size2D::new(1, 2), false, [(2, -1), (-3, 1), (0, 3), (2, -2)]),
327 (Tessellation::Trioban, 60, Size2D::new(2, 2), true, [(2, -2), (-4, 1), (-2, 2), (3, -1)]),
328 (Tessellation::Octoban, 90, Size2D::new(2, 2), true, [(2, -1), (-2, 1), (-1, 2), (1, -2)]),
329 ];
330 let transforms = [
331 BoardTransform::MirrorHorizontal,
332 BoardTransform::MirrorVertical,
333 BoardTransform::RotateClockwise,
334 BoardTransform::RotateCounterClockwise,
335 ];
336
337 for (tessellation, degrees, period, diagonal_is_rigid, transformed) in expectations {
338 assert_eq!(tessellation.rotation_step_degrees(), degrees);
339 assert_eq!(tessellation.translation_period(), period);
340 assert_eq!(tessellation.is_rigid_translation(1, 1), diagonal_is_rigid);
341 for (transform, expected) in transforms.into_iter().zip(transformed) {
342 assert_eq!(tessellation.transformed_raw(transform, 2, 1), expected);
343 }
344 }
345 }
346
347 /// [`Tessellation::neighbor_directions()`] is derived state: a direction belongs in the set
348 /// exactly when [`Tessellation::neighbor()`] yields a position for it, so the same
349 /// hand-computed rows pin it too.
350 ///
351 /// This says little about [`Tessellation::known_directions()`] on its own. A *shorter* list
352 /// than the tessellation needs does show up here, as directions missing from the set. A
353 /// longer one does not — the surplus directions are filtered out by
354 /// [`Tessellation::neighbor()`] anyway.
355 /// [`Self::it_agrees_with_its_implementation_module_over_the_whole_board`] is what checks the
356 /// list itself.
357 #[test]
358 fn its_neighbor_directions_are_the_directions_that_have_a_neighbor() {
359 for (tessellation, positions) in EXPECTATIONS {
360 for (x, y, _, neighbors) in positions {
361 let expected = DirectionsSet::from_iter(
362 Direction::ALL
363 .into_iter()
364 .zip(neighbors)
365 .filter_map(|(direction, neighbor)| neighbor.map(|_| direction)),
366 );
367
368 assert_eq!(
369 tessellation.neighbor_directions(x, y, WIDTH, HEIGHT),
370 Some(expected),
371 "{tessellation:?} neighbor directions at ({x}, {y})"
372 );
373 }
374 }
375 }
376
377 type TileShapeFn = fn(usize, usize) -> TileShape;
378 type NeighborFn =
379 fn(usize, usize, Direction, usize, usize) -> Result<(usize, usize), TessellationError>;
380
381 /// The implementation module a [`Tessellation`] variant is supposed to dispatch to.
382 struct Module {
383 tessellation: Tessellation,
384 known_directions: &'static [Direction],
385 tile_shapes: &'static [TileShape],
386 tile_shape_from_pos: TileShapeFn,
387 neighbor_checked: NeighborFn,
388 }
389
390 /// Broader than the hand-computed rows above: sweeps the whole board and asserts that each
391 /// variant agrees with its module everywhere, not just at the two anchor positions. Also the
392 /// only coverage of [`Tessellation::known_directions()`] and [`Tessellation::tile_shapes()`].
393 ///
394 /// On its own this table could be miswired the same way the dispatch is; it is
395 /// [`Self::it_dispatches_to_the_behavior_of_its_own_tessellation`] that anchors which module
396 /// belongs to which variant.
397 #[test]
398 fn it_agrees_with_its_implementation_module_over_the_whole_board() {
399 let modules: [Module; 4] = [
400 Module {
401 tessellation: Tessellation::Sokoban,
402 known_directions: &sokoban::KNOWN_DIRECTIONS,
403 tile_shapes: &sokoban::TILE_SHAPES,
404 tile_shape_from_pos: |_x, _y| sokoban::tile_shape_from_pos(),
405 neighbor_checked: sokoban::neighbor_checked,
406 },
407 Module {
408 tessellation: Tessellation::Hexoban,
409 known_directions: &hexoban::KNOWN_DIRECTIONS,
410 tile_shapes: &hexoban::TILE_SHAPES,
411 tile_shape_from_pos: |_x, _y| hexoban::tile_shape_from_pos(),
412 neighbor_checked: hexoban::neighbor_checked,
413 },
414 Module {
415 tessellation: Tessellation::Trioban,
416 known_directions: &trioban::KNOWN_DIRECTIONS,
417 tile_shapes: &trioban::TILE_SHAPES,
418 tile_shape_from_pos: trioban::tile_shape_from_pos,
419 neighbor_checked: trioban::neighbor_checked,
420 },
421 Module {
422 tessellation: Tessellation::Octoban,
423 known_directions: &octoban::KNOWN_DIRECTIONS,
424 tile_shapes: &octoban::TILE_SHAPES,
425 tile_shape_from_pos: octoban::tile_shape_from_pos,
426 neighbor_checked: octoban::neighbor_checked,
427 },
428 ];
429
430 for module in modules {
431 let tessellation = module.tessellation;
432
433 assert_eq!(
434 tessellation.known_directions(),
435 module.known_directions,
436 "{tessellation:?}"
437 );
438 assert_eq!(tessellation.tile_shapes(), module.tile_shapes, "{tessellation:?}");
439
440 for y in 0..HEIGHT {
441 for x in 0..WIDTH {
442 assert_eq!(
443 tessellation.tile_shape_from_pos(x, y),
444 (module.tile_shape_from_pos)(x, y),
445 "{tessellation:?} tile shape at ({x}, {y})"
446 );
447
448 for direction in Direction::ALL {
449 assert_eq!(
450 tessellation.neighbor(x, y, direction, WIDTH, HEIGHT),
451 (module.neighbor_checked)(x, y, direction, WIDTH, HEIGHT).ok(),
452 "{tessellation:?} neighbor of ({x}, {y}) to {direction}"
453 );
454 }
455 }
456 }
457 }
458 }
459
460 /// Load-bearing for any game engine using our [`Tessellation`] and [`Direction`]
461 /// implementations.
462 ///
463 /// For example, inverting a reverse mode game's steps flips every direction with
464 /// [`Direction::opposite()`]. That is sound only if stepping back that way lands exactly where
465 /// the step came from. Breaking this test silently breaks any engine that depends on that
466 /// guarantee.
467 ///
468 /// Nothing in the geometry modules enforces it. Each tessellation writes its own delta per
469 /// direction, Hexoban's depend on row parity and Trioban's on tile shape, and Trioban's six
470 /// direction names alias onto three real neighbors per triangle - so the property is pinned
471 /// here rather than assumed.
472 #[test]
473 fn opposite_is_the_geometric_inverse_of_neighbor() {
474 for (tessellation, _) in EXPECTATIONS {
475 for y in 0..HEIGHT {
476 for x in 0..WIDTH {
477 // Illegal directions yield `None` and are skipped, so this covers every
478 // position/direction pair the tessellation actually admits.
479 for direction in Direction::ALL {
480 let Some((neighbor_x, neighbor_y)) =
481 tessellation.neighbor(x, y, direction, WIDTH, HEIGHT)
482 else {
483 continue;
484 };
485
486 assert_eq!(
487 tessellation.neighbor(
488 neighbor_x,
489 neighbor_y,
490 direction.opposite(),
491 WIDTH,
492 HEIGHT
493 ),
494 Some((x, y)),
495 "{tessellation:?}: ({x}, {y}) to {direction} lands on \
496 ({neighbor_x}, {neighbor_y}), which does not step back to it"
497 );
498 }
499 }
500 }
501 }
502 }
503
504 /// The other half of what inverting a step sequence needs: a flipped direction has to be one
505 /// the tessellation still accepts, or
506 /// [`SavedGame::from_steps()`](crate::SavedGame::from_steps()) would reject the converted
507 /// game.
508 #[test]
509 fn known_directions_are_closed_under_opposite() {
510 for (tessellation, _) in EXPECTATIONS {
511 for direction in tessellation.known_directions() {
512 assert!(
513 tessellation
514 .known_directions()
515 .contains(&direction.opposite()),
516 "{:?} knows {} but not its opposite {}",
517 tessellation,
518 direction,
519 direction.opposite()
520 );
521 }
522 }
523 }
524
525 #[test]
526 fn it_has_no_neighbor_directions_off_board() {
527 for (tessellation, _) in EXPECTATIONS {
528 assert!(
529 tessellation
530 .neighbor_directions(WIDTH, 0, WIDTH, HEIGHT)
531 .is_none()
532 );
533 assert!(
534 tessellation
535 .neighbor_directions(0, HEIGHT, WIDTH, HEIGHT)
536 .is_none()
537 );
538
539 for direction in Direction::ALL {
540 assert!(
541 tessellation
542 .neighbor(WIDTH, 0, direction, WIDTH, HEIGHT)
543 .is_none()
544 );
545 assert!(
546 tessellation
547 .neighbor(0, HEIGHT, direction, WIDTH, HEIGHT)
548 .is_none()
549 );
550 }
551 }
552 }
553}