sokoban-elements 0.2.1

Parsing and writing Sokoban, Hexoban, Trioban and Octoban file formats.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
# Game board geometry and transformations

Internal reference for crate developers.

Read this before touching `src/game_board/transform.rs`, `src/game_board/parser.rs`'s resizing
hooks, or the `transformed_raw` / `is_rigid_translation` functions in `src/tessellation/`. It
explains the geometry those rest on, why the obvious implementations are wrong, and which parts are
deliberate choices rather than derived facts.

- [0. The one rule]#0-at-a-glance
- [1. The one rule]#1-the-one-rule
- [2. Coordinate geometry of the four tessellations]#2-coordinate-geometry-of-the-four-tessellations
- [3. Rigid translations]#3-rigid-translations
- [4. The transform maps]#4-the-transform-maps
- [5. The transform engine]#5-the-transform-engine
- [6. Resizing, trimming and cropping]#6-resizing-trimming-and-cropping
- [7. Hexoban's half-column freedom]#7-hexobans-half-column-freedom
- [8. What a transform invalidates]#8-what-a-transform-invalidates
- [9. Why it looks like this]#9-why-it-looks-like-this
- [10. The bug this replaced]#10-the-bug-this-replaced
- [11. Verification]#11-verification
- [12. Traps]#12-traps
- [13. Where the code lives]#13-where-the-code-lives

---

## 0. At a glance

`GameBoard` has rigid `mirror(MirrorAxis)` and tessellation-step `rotate(Rotation)` operations
backed by one coordinate-transform engine. Resizing is ultimately exact and batched rather than
rounded to a period: Trioban and Octoban preserve geometry during odd Top or Left shifts by adding
one perpendicular padding line, while Hexoban Top shifts use a specialized internal odd-r reindex.
`trim()` and `normalize()` crop all four edges in one operation so that compensation is chosen once
from the original rectangle. `enclose()` goes the other way, growing the edges the play area reaches
and then walling them, and it is the only operation that makes a board *closed*. Public docs warn
that all board editing invalidates positional IDs, Sokoban+, saved games, and Zobrist hashes.

For a non-empty Trioban or Octoban board:

| operation         | even batch         | odd batch                   |
|-------------------|--------------------|-----------------------------|
| add/remove Top    | width unchanged    | add one left padding column |
| add/remove Left   | height unchanged   | add one top padding row     |
| add/remove Bottom | exact, height only | exact, height only          |
| add/remove Right  | exact, width only  | exact, width only           |

For Hexoban, only `Top` operations need special handling: an odd batch reindexes alternating rows
and adds at most one column. Left is an ordinary integer translation; `Bottom` and `Right` do not
move retained cells. Sokoban never needs perpendicular compensation.

All public editing methods restore the width-normalization invariant. Once an operation empties a
board, later edge additions cannot grow it back; `GameBoard::grow` can, because it is not a
per-edge operation.

---

## 1. The one rule

Neither tile shape nor adjacency is stored anywhere. `TileShape` is derived from position by
`Tessellation::tile_shape_from_pos(x, y)`, and adjacency by `Tessellation::neighbor(..)`. A board's
geometry is therefore entirely a function of each tile's `(x, y)` index.

> A board transform is valid **iff** the coordinate map it applies is a rigid motion of the
> underlying tiling — an isometry carrying tiles onto tiles of the same shape.

Every transform is two parts: the geometric map itself (a mirror or rotation about the origin),
then a **re-anchoring translation** that moves the result back into non-negative coordinates. The
first part is rigid by construction. So the whole thing is rigid iff the translation is — and, as
§3 shows, only *some* index translations are rigid motions.

That single sentence is the origin of everything else in this document: the padding columns, the
parity conditions, the batching semantics of resizing, and the reason `body.reverse()` is not a
mirror.

---

## 2. Coordinate geometry of the four tessellations

All of this is derivable from `neighbor_checked()` in each `src/tessellation/<name>.rs`, and is
confirmed against it by the probe (§11). Side length 1, `h = √3/2`, screen coordinates — origin
top-left, **y grows downward**.

### Sokoban — square grid

Cell `(x, y)` centred at `(x, y)`. One tile shape. Four neighbours.

### Octoban — truncated square tiling

Cell `(x, y)` centred at `(x, y)`.

```
Octagon  iff (x + y) is even
Square   otherwise
```

Octagons have all 8 neighbours; squares only the 4 orthogonal ones —
`octoban::neighbor_checked` returns `InvalidDirectionForTile` for a diagonal from a square. The two
shapes are genuinely different, which is why Octoban has no escape hatch analogous to Hexoban's
(§7).

### Hexoban — pointy-top hexagons, odd-r offset

Implemented via odd-r / cube coordinates rotation. Odd rows are shifted right by half a tile:

```
centre_x = x + 0.5 * (y mod 2)
centre_y = y * h
```

Read it off `hexoban::neighbor_checked`: for even `y`, `NW = (x-1, y-1)` and `NE = (x, y-1)`; for
odd `y`, `NW = (x, y-1)` and `NE = (x+1, y-1)`. That is the odd-r convention. Every tile is a
`Hexagon`, so nothing can be *re-shaped* — what breaks under a bad transform is the row offset.

> This describes the **internal** dense form, which is what all transforms operate on. Hexoban's
> **external** text form (the interlocked `-#-#-` picture; see the module docs at the top of
> `src/game_board/hexoban.rs`) is a separate encoding used only for parsing and printing.
> The two differ by at least a global half-tile shift. Do not read the internal row-offset
> convention off the external ASCII, and do not implement geometry through the external form —
> that dance was removed precisely because it obscured this.

### Trioban — triangular grid

```
TriangleDown  iff (x + y) is even
TriangleUp    otherwise
```

`tile_shape_from_pos` computes `(x + (y % 2)) % 2`, which is the same thing. Column `x` spans
horizontally `[x/2, x/2 + 1]`, so consecutive columns in a row overlap by half:

```
centroid_x = x/2 + 0.5
centroid_y = y*h + h/3     for TriangleDown (flat top)
           = y*h + 2h/3    for TriangleUp   (flat bottom)
```

Check against `trioban::neighbor_checked`: from a ▽ at `(x, y)`, both `NE` and `NW` yield
`(x, y-1)` — the single △ sharing its flat top edge — while `SE`/`SW` alias onto `Right`/`Left`.
A triangle has 3 neighbours but 6 direction labels; this **direction aliasing** is why Trioban is
the awkward one in §8.

---

## 3. Rigid translations

| tessellation | rigid `(dx, dy)` | why                                          |
| ------------ | ---------------- | -------------------------------------------- |
| Sokoban      | all              | one tile shape, no row offset                |
| Hexoban      | `dy` even        | odd rows carry a half-tile horizontal offset |
| Trioban      | `dx + dy` even   | shape is `(x + y) mod 2`                     |
| Octoban      | `dx + dy` even   | shape is `(x + y) mod 2`                     |

Implemented as `is_rigid_translation()` in each `src/tessellation/<name>.rs`.

Justification for the two "even" rows, in real coordinates:

- **Trioban.** The triangular tiling's translation lattice is generated by `(1, 0)` and `(0.5, h)`.
  One index column is `0.5` horizontally, one index row is `h` vertically. So `(dx, dy) = (1, 1)`
  is the lattice vector `(0.5, h)` ✓, `(2, 0)` is `(1, 0)` ✓, and `(0, 2)` is `2·(0.5, h) − (1, 0)`
  ✓ — exactly the `dx + dy` even set. An odd translation would land a ▽ where the index parity
  says △.
- **Octoban.** The truncated-square tiling is generated by `(1, 1)` and `(1, −1)` in cell units:
  diagonal moves preserve the octagon/square checkerboard, orthogonal ones do not.
- **Hexoban.** Shifting by an odd number of rows demands a compensating half-tile *horizontal*
  shift, which is not expressible as an integer column shift. See §7 — this one has a way out that
  the other two do not.

`Tessellation::translation_period()` reports the smallest axis-aligned integer translation
preserving each tiling:

| tessellation | period |
| ------------ | -----: |
| Sokoban      | `1×1`  |
| Hexoban      | `1×2`  |
| Trioban      | `2×2`  |
| Octoban      | `2×2`  |

**It is an explicit dispatch table, not derived from `is_rigid_translation`, and resizing does not
consult it.** It is informational — for a UI that wants to offer sensible granularity. Resizing
applies exact counts and compensates differently (§6). The four values are pinned by
`tessellation::tests::it_dispatches_transform_geometry_to_its_own_tessellation`.

---

## 4. The transform maps

All **unanchored** — they may return negative coordinates. Re-anchoring is the engine's job (§5).
Implemented as `transformed_raw()` per tessellation.

|         | mirror vertical (left↔right) | mirror horizontal (top↔bottom) | rotate clockwise                                                                |
| ------- | ---------------------------- | ------------------------------ | ------------------------------------------------------------------------------- |
| Sokoban | `(-x, y)`                    | `(x, -y)`                      | `(-y, x)`                                                                       |
| Octoban | `(-x, y)`                    | `(x, -y)`                      | `(-y, x)`                                                                       |
| Hexoban | `(-x - (y mod 2), y)`        | `(x, -y)`                      | odd-r → cube, `(x,y,z) → (-z,-x,-y)`, → odd-r                                   |
| Trioban | `(-x - 2, y)`                | `(x, -y - 1)`                  | `x' = ⌊(x−p)/2⌋ − ⌊3y/2⌋ − 1`,<br>`y' = ⌊(x+1−p)/2⌋ + ⌊(y+1)/2⌋`, `p = y mod 2` |

One rotation step is **90°** for Sokoban and Octoban, **60°** for Hexoban and Trioban — the rotation
subgroup of each tiling's symmetry group. Exposed as `Tessellation::rotation_step_degrees()`.
Octoban's step is not 45°: a 45° turn about an octagon centre would carry octagon-neighbours onto
square-neighbours, so it is not a symmetry.

### How each was derived

Write the real-coordinate transform, then solve for the new index. Reproduce these rather than
trusting them.

**Mirror about the vertical line `x = 0`:**

- Sokoban / Octoban: `centre_x = x`, so `x' = -x`. Octoban parity `(-x + y) ≡ (x + y)` ✓ — octagons
  stay octagons, as they must.
- Hexoban: `x' + 0.5(y mod 2) = -(x + 0.5(y mod 2))`, and the row is unchanged, so
  `x' = -x - (y mod 2)`.
- Trioban: `x'/2 + 0.5 = -(x/2 + 0.5)` ⟹ `x' = -x - 2`. A vertical mirror preserves triangle
  orientation (▽ mirrored left-right is still ▽), and the parity agrees: `(-x-2+y) ≡ (x+y)` ✓.

**Mirror about the horizontal line `y = 0`:**

- Sokoban / Octoban: `y' = -y`. Octoban parity `(x - y) ≡ (x + y)` ✓.
- Hexoban: `y' = -y`; the row parity is unchanged so `x' = x`.
- Trioban: orientation **flips**. For a ▽ at `(x, y)`, `centroid_y = y·h + h/3`; its reflection is
  a △, whose centroid is `y'·h + 2h/3`. Solving `-(y·h + h/3) = y'·h + 2h/3` gives `y' = -y - 1`.
  The same substitution from a △ source gives `y' = -y - 1` too, so the map is uniform. Parity
  `x + (-y-1) ≡ x + y + 1` — flipped, exactly as the orientation flip requires.

**Rotation, clockwise:**

- Sokoban / Octoban, 90°: `(x, y) → (-y, x)`. In screen coordinates (y down) this is the *visual*
  clockwise turn — check on a 3×1 board: `(0,0) → (0,0)`, `(1,0) → (0,1)`, `(2,0) → (0,2)`, so the
  leftmost cell ends up at the top ✓.
- Hexoban, 60°: through cube coordinates.

  ```
  odd-r → cube:   q = x - (y - (y mod 2)) / 2 ;  r = y ;  (cx, cy, cz) = (q, -q - r, r)
  rotate 60° CW:  (cx, cy, cz) → (-cz, -cx, -cy)
  cube → odd-r:   row = cz ;  col = cx + (row - (row mod 2)) / 2
  ```

  Use `rem_euclid(2)`, not `& 1`, so negative rows behave; the division numerator stays even
  either way, so it is exact. Direction check on a 3×1 board: `(0,0) → (0,0)`, `(1,0) → (0,1)`,
  `(2,0) → (1,2)` — leftmost at the top ✓, same sense as Sokoban.
- Trioban, 60°: the closed form above, with `div_euclid` so it floors for negatives. It was fitted
  from a float embedding (rotate the centroid, snap back to the lattice) and then checked against
  that embedding over a 40×40 region **including negative coordinates**: zero mismatches. Direction
  check on a 3×1 board: `(0,0) → (-1,0)`, `(1,0) → (-1,1)`, `(2,0) → (0,1)` ✓ same sense.

**Counter-clockwise** is `M ∘ CW ∘ M`, where `M` is the vertical-mirror map. Conjugating a rotation
by a reflection whose axis passes through the rotation centre yields the inverse rotation, and in
all four tessellations it does. Sokoban and Octoban still spell it out directly as `(y, -x)`;
Hexoban and Trioban use the conjugation, which is why their `transformed_raw` has that shape. `M`
is an involution in every case — check Hexoban: `-(-x - (y mod 2)) - (y mod 2) = x` ✓.

Hand-check of the conjugation on Sokoban: `M(x,y) = (-x,y)`, `CW(x,y) = (-y,x)`, so
`M(CW(M(x,y))) = M(CW(-x,y)) = M(-y,-x) = (y,-x)` ✓, the inverse of `CW`.

---

## 5. The transform engine

`src/game_board/transform.rs`. Public surface is `MirrorAxis {Horizontal, Vertical}` and
`Rotation {Clockwise, CounterClockwise}`, driving `GameBoard::mirror()` and `GameBoard::rotate()`.
Internally everything funnels through `BoardTransform` and one function:

```rust
pub(super) fn transformed(body: &ParsedBoard, tessellation: Tessellation, transform: BoardTransform)
    -> ParsedBoard
```

1. Assert width-normalization. Empty board in → empty board out and in this case `mirror` and
   `rotate` are no-ops.
2. Map every cell through `transformed_raw`, tracking `min_x`, `min_y`, `max_x`, `max_y`.
3. **Anchor rule** (`best_anchor`). Start from the tight anchor `(-min_x, -min_y)`. Consider the
   four candidates obtained by adding `(0,0)`, `(1,0)`, `(0,1)`, `(1,1)`. Discard those where
   `is_rigid_translation(dx, dy)` fails — at least one always survives, since every predicate in §3
   has period at most two on each axis. Among the survivors take the one minimising
   `new_width * new_height`. The comparison is strict `<` over the candidate order, so **an exact
   area tie adds a column, not a row.**
4. Allocate a `Floor`-filled grid of the resulting size and copy each tile to its image.

Two consequences worth remembering:

- **Not every destination cell has a preimage.** Hexoban's vertical mirror carries even and odd
  rows onto interleaved column ranges, so the result legitimately has floor cells with no source.
  That is not a bug.
- **`GameBoard::mirror()` and `rotate()` do not use the `match self.tessellation` dispatch** that
  the other editing methods use — the tessellation is a parameter to the engine instead. Deliberate:
  it keeps eight arms off the miswire surface.

Why the anchor rule is the interesting part: it is the **only free choice in the whole design**.
The maps are forced by geometry; the anchor is a convention. Minimum area with a documented
tie-break makes it deterministic, and `(1,0)` costing a column of `H` cells versus `(0,1)` costing
a row of `W` cells is why a fixed lexicographic order would have been wrong about half the time.

Transforms keep their complete bounding box and never inspect content. Repeated rotation therefore
grows the board — six 60° steps return the original content but not the original size. Tightening
is an explicit, separate `trim()`.

Numeric hygiene: the crate warns on `clippy::as_conversions`, `cast_*` and
`default_numeric_fallback`, so work in `i64` and convert with `try_from`, never `as`.
`unwrap`/`expect`/`panic` are also warned on, but `unwrap_or` is not — boards are tiny, so
saturating with a comment is the clean way through.

---

## 6. Resizing, trimming and cropping

`src/game_board/parser.rs`. This is where the implementation diverged most from the original design
(§9), so it is worth reading carefully.

### Exact counts, with perpendicular compensation

Resizing applies **one complete requested batch, exactly**:

- an addition to a non-empty board applies and returns exactly `count`;
- a removal applies and returns `min(count, available)`;
- removing the last row or column yields the canonical empty `0×0` board, with no padding;
- `add_rows`/`remove_rows`/`add_columns`/`remove_columns` all return `usize` — how many lines were
  actually applied.

Only the **Top** and **Left** edges shift existing content, so only they can break geometry. Bottom
and Right are dimension-local and never need compensation.

For Trioban and Octoban, `(dx + dy)` must be even, so an odd shift is made rigid by adding one
**perpendicular** padding line:

| operation         | even batch         | odd batch               |
| ----------------- | ------------------ | ----------------------- |
| add/remove Top    | width unchanged    | one left padding column |
| add/remove Left   | height unchanged   | one top padding row     |
| add/remove Bottom | exact, height only | exact, height only      |
| add/remove Right  | exact, width only  | exact, width only       |

In the code this is a single line per hook — for example in `add_rows_top`:

```rust
let extra_left = usize::from(!Self::TESSELLATION.is_rigid_translation(0, to_i64(count)));
*body = copied_rect(body, source, extra_left, count);
```

`BoardResizer` carries `const TESSELLATION: Tessellation;`, declared once per implementor. That is
a new arm of the miswire family; all four are pinned by test.

**Batching is observable, and that is intentional.** Adding two Top rows to a Trioban board in one
call adds no column; two separate one-row calls add two. A removal can shrink one dimension while
growing the perpendicular one by one. Callers control operation order — there is deliberately no
combined four-edge `resize(cols_left, cols_right, rows_top, rows_bottom)`. It existed and was
removed: with four edges changing in one call, the result depended on the order the four loops
happened to run in, and some orders collapsed the board to empty while others didn't. The four
`add_*`/`remove_*` methods take a `HorizontalEdge` (`Top`/`Bottom`, for rows) or a `VerticalEdge`
(`Left`/`Right`, for columns) plus a `usize` count, so sequencing is the caller's to choose. Two
consequences worth knowing:

- Removals saturate. `remove_rows(Top, 42)` on a 11-row board removes 11, it is not an error.
  Removing every column removes the rows with it, per the invariant above.
- An emptied board cannot be grown back one edge at a time. Because `normalize_dimensions()` runs at
  the end of *each* call, `add_rows` on an empty board produces zero-length rows that are
  immediately collapsed away, and `add_columns` has no rows to add to.

### Growing is the one resize that is not per-edge

`GameBoard::grow(width: NonZeroU16, height: NonZeroU16)` takes each axis to
`max(current, wanted)`. It never shrinks, so a request below the current size on both axes is a
no-op, and it is the way back from the empty board.

It is the one resizing operation that takes no edge, and it needs none: it only ever extends
**Bottom** and **Right**, which "Exact counts, with perpendicular compensation" above establishes
as dimension-local in every tessellation. So it adds no perpendicular padding line, existing tiles
keep their `(x, y)`, and —
like the transform engine (§5) — it needs no `match self.tessellation`. The primitive is the free
`grown()` in `parser.rs`, alongside `normalize_dimensions()`, deliberately *not* a `BoardResizer`
method: a four-arm dispatch for a tessellation-independent operation would be a new arm of the
miswire family for nothing. The empty board needs no special case there either — it has no rows to
widen, and every row it then gains is built at the wanted width.

`NonZeroU16` caps a request at 65535 and `usize: From<u16>` is guaranteed, so no dimension
arithmetic here can overflow. The only saturation in play is the one `Size2D::size()` already
performs on `width * height`.

The discriminating test is `grow_adds_no_padding_and_moves_no_tile_in_any_tessellation`: it asserts
the exact resulting dimensions and that every pre-existing tile is still at its original
coordinates, across all four tessellations, for an odd growth on both axes. Reimplementing `grow`
through Top or Left would pass everything else and fail only that.

### Cropping is one operation, not four

`trim()` and `normalize()` compute **one source rectangle and reindex it once**:

- `trim()` finds the bounding rectangle of cells that are not `TileKind::is_empty_floor()` — walls,
  boxes, goals and pushers all anchor it, so a goal-only or pusher-only edge line survives;
- `normalize()` uses a different anchor: it replaces non-playable cells and reconstructs significant
  walls first, then crops the bounding rectangle of everything it retained — the playable area
  *union* the significant walls. Both halves matter. Anchoring on
  `StaticBoardAnalysis::significant_walls()` alone was a bug: a playable
  tile on the board edge has no off-board neighbour that could become a significant wall, so it fell
  outside that rectangle and was cropped away despite having just been retained. On a board whose
  playable area does not touch the edge the two rectangles coincide, which is why it went unnoticed;
- `best_crop_offset()` picks the rigid anchor with the smallest resulting area, same tie-break as
  the transform engine — a tie adds a column;
- a rectangle with no retained cells becomes the empty board and gets no compensation. `normalize()`
  is the exception: an empty retained set means the board has no pusher, hence no playable area and
  no significant walls, so there is nothing to normalize *around* and the board is left untouched
  rather than floored flat.

This had to become a single operation. Once an odd Top or Left removal can add a perpendicular
line, counts computed before the first edit go stale and the result depends on the order the four
edges are processed in.

### Enclosing grows edges, and its order is pinned

`enclose()` is the inverse direction and answers a different question. `flood_fill()` stops at walls
**and at the board edge**, so the play area is always confined by the two together; a board is
*closed* exactly when the edge is doing none of that work
(`StaticBoardAnalysis::is_playable_area_confined()`). Nothing has to be reconstructed inward — every
on-board position beside the play area and not in it is already a wall, because that is why the flood
stopped there. So enclosing only has to make room:

1. grow one line on each edge the play area's bounding box touches, then
2. turn every `TileKind::is_empty_floor()` tile on the resulting outer edge into a wall.

Step 2 is what keeps it a single pass with no offset arithmetic: every line added in step 1 is floor,
and so is any perpendicular padding line Top or Left brought with it, so walling the outer edge seals
all of them without tracking where they landed. Growth only ever moves content *away* from an edge,
so an edge the play area did not touch is still untouched afterwards, and any non-floor tile
surviving on such an edge is dead content the play area cannot reach.

Unlike cropping, this one **is** processed edge by edge, so the order is fixed: **Bottom, Right, Top,
Left**. Bottom and Right never shift content and never add a padding line (§6), so doing them first
leaves the Top/Left padding decision to be made once on the final geometry. A board leaking both Top
and Left would otherwise land on different dimensions depending on order — the same order-dependence
that removed `resize()`. The sizes this produces for Sokoban, Trioban and Octoban are pinned by
`game_board::tests::test_enclosing::enclose_is_deterministic_when_two_edges_leak`.

Two consequences worth knowing: an already-closed board returns early, so `enclose()` is idempotent;
and dead floor on an edge that was not grown becomes wall as well, which is outside the play area and
so leaves the puzzle unchanged. The result is not tightened — follow with `trim()` or `normalize()`.

---

## 7. Hexoban's half-column freedom

Hexoban is the one tessellation that can shift by an odd number of rows, and understanding why is
the subtlest thing here.

Every Hexoban tile is a hexagon. An odd row shift only needs a compensating **half-tile horizontal**
shift — and a half-tile shift of the whole board is semantically a no-op, since only relative
geometry matters. Concretely, the re-indexing is:

```
(x, y) -> (x + (y mod 2), y + dy)        for odd dy
```

which is a rigid motion but *not* an integer translation. It costs one column of width instead of
one row of height. Trioban and Octoban have no equivalent escape: their two tile shapes are
genuinely different, and no shift turns one into the other.

`HexobanBoardParser::reindexed_rect()` implements this directly on the dense internal board:

- even Top shifts preserve width;
- odd shifts offset alternating source rows by one column;
- if the retained rows span both parities, the bounding box gains one column;
- if only one row parity remains, its minimum offset is subtracted so the width stays tight;
- the calculation depends only on dimensions and row parity — **never on which cells happen to be
  floor**.

The same helper serves odd Top addition, odd Top removal, and combined Hexoban cropping. Left,
Bottom and Right are ordinary. Parsing and printing still go through the external text form, but
editing no longer does.

Note the split this creates: `translation_period()` reports `1×2` for Hexoban because that is the
integer-translation fact, while Hexoban resizing *can* move by one row because it uses a
half-column-adjusted rigid motion instead. Both statements are true; they are about different
things.

The shared transform engine (§5) deliberately does **not** exploit this — it uses integer
re-anchoring only. Integer anchoring is sufficient for every mirror and rotation, and keeping the
engine uniform across tessellations was judged worth an occasional extra padding column.

---

## 8. What a transform invalidates

The rotation step is tessellation-specific.

A transform may add one row or column, because re-anchoring has to be a rigid translation. Mirroring
twice restores the original *content*, not necessarily the original size. A full turn of rotations
likewise (4 steps for Sokoban/Octoban, 6 for Hexoban/Trioban). Rotation grows the board to the
rotated bounding box and nothing trims it back — call `trim()` if you want it tight.

Editing is one edge at a time, removals saturate, and an emptied board cannot be grown back by a
further edge addition — only by `grow()`, which is not a per-edge operation.

Everything keyed to position. This is documented for users under `GameBoard`'s `# Editing game
boards` section; the detail behind it:

- **`BoxID` / `GoalID` / `PusherID` are positional.** Assigned by scanning row-major and
  `.enumerate()`-ing (`GameBoard::boxes()`, `goals()`, `pushers()`). Any transform renumbers every
  piece.
- **`SokobanPlus` is keyed to those IDs**, so it is silently invalidated. Nothing detects it.
- **`SavedGame` moves are directional** and no longer describe the board.
- **`ZobristHasher`** values (`manyban-engine`) are position-keyed and do not survive.

Nothing in the crate neither detects this nor prevents this invalidation. The transformation API is
board-only and this is intentional.

The whole thing rests on one idea, *a board transform is valid iff it is a rigid motion of the
underlying tiling.* Everything else (piece IDs, saved games, Zobrist hashes, ...) is bookkeeping.

Rotation / mirroring intended use is in two places: (1) designing a new puzzle, (2) comparing two
game boards in search of duplicates (or at least potential duplicates). If you are designing new
puzzle with `Sokoban+`, your `Sokoban+` design must come after the game board for that puzzle is
finalized and piece IDs become stable.

### Direction relabelling, if you ever do transform a saved game

For one clockwise step:

| tessellation | relabelling                                                                                                                                     |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| Sokoban      | `Up → Right → Down → Left → Up` (uniform, `+2 mod 8` in the `Direction` enum)                                                                   |
| Octoban      | same, plus `NE → SE → SW → NW → NE`; diagonals exist only on octagon cells                                                                      |
| Hexoban      | uniform 6-cycle `Right → SE → SW → Left → NW → NE → Right` — *not* a uniform enum shift, six directions in an eight-slot enum                   |
| Trioban      | **not uniform.** Each source direction splits by triangle orientation, and each target is itself an aliased pair, e.g. `Left → Left, NorthWest` |

So Trioban is the one case that could never be a character substitution — it would need the replay
to know each step's tile orientation. That is the concrete reason saved-game transformation is out
of scope rather than merely unimplemented.

---

## 9. Why it looks like this

Decisions worth not re-litigating, and the ones that changed under implementation.

**Settled up front and unchanged:**

| decision      | choice                                                                                             |
| ------------- | -------------------------------------------------------------------------------------------------- |
| Rotation API  | one natural step per call, `rotate(Rotation)`, plus `rotation_step_degrees()`                      |
| Result size   | never trim; a transform keeps its full bounding box                                                |
| Mirror naming | `mirror(MirrorAxis)`; `Horizontal` is the top↔bottom flip, matching `HorizontalEdge {Top, Bottom}` |

`MirrorAxis::Horizontal` meaning the top↔bottom flip reads backwards to some people. The tie-breaker
was consistency with the existing `HorizontalEdge {Top, Bottom}` / `VerticalEdge {Left, Right}`
vocabulary.

**Revised during implementation:**

1. **Resizing applies exact counts instead of rounding to a period.** The original design rounded
   Top/Left additions up and removals down, so `remove_rows(Top, 1)` on Octoban would return `0`.
   Rejected in favour of applying the caller's edit exactly and compensating with a perpendicular
   padding line — consistent with the already-accepted transform behaviour that dimensions may grow
   to preserve the tiling, and it avoids an operation that silently does nothing.
2. **`translation_period()` was decoupled from resize granularity** and became informational (§3).
3. **`trim()`/`normalize()` crop once** rather than editing four edges in sequence (§6).
4. **Both axes got batched hooks.** `BoardResizer` now has `add_rows_top/bottom`,
   `remove_rows_top/bottom`, `add_columns_left/right`, `remove_columns_left/right`. Sokoban, Trioban
   and Octoban share `copied_rect()`; Hexoban overrides only the Top operations.

---

## 10. The bug this replaced

Historical, but keep it — it is the clearest illustration of §1, and the failure mode is easy to
reintroduce.

Historical naive approach was that `reverse_rows` / `reverse_columns` used to be `body.reverse()`
and `row.reverse()`. Measured behaviour before the fix:

|         | `reverse_columns`                   | `reverse_rows`                 |
| ------- | ----------------------------------- | ------------------------------ |
| Sokoban | ok                                  | ok                             |
| Hexoban | naive always wrong — was overridden | broken when height is **even** |
| Trioban | broken when width is **even**       | broken when height is **odd**  |
| Octoban | broken when width is **even**       | broken when height is **even** |

Trioban's row condition is inverted relative to Octoban's because a top↔bottom mirror *flips*
triangle orientation, so Trioban needs the parity to flip while Octoban needs it preserved.

**Hexoban, even height.** Four-row board, `reverse_rows`:

```
BEFORE            AFTER
-#-#-#-#-         -#-#-#-#-
--#---$-#         --#-@---#
-#-@---#-         -#---$-#-
--#-#-#-#         --#-#-#-#
```

A shear, not a mirror. `@` at internal `(1,2)` and `$` at `(2,1)` are two hexes apart before; after,
`@` at `(1,1)` and `$` at `(2,2)` are **neighbours**. The puzzle changed.

**Octoban, even width.** `"####|#@$.|####"`, `reverse_columns` → `"####|.$@#|####"`. The box was on
a `Square` at `(2,1)`; it lands on an `Octagon` at `(1,1)`, gaining four diagonal moves it did not
have.

**Why it survived for so long.** Both operations are self-inverse on the array, so every round-trip
test passed. And all four Hexoban mirror fixtures happened to use height 7 — odd, the case that
works. The lesson, still binding: *round trips prove nothing here.* Only adjacency preservation
discriminates.

---

## 11. Verification

### The acceptance property

`transform.rs`'s `every_transform_is_injective_and_preserves_adjacency`:

> For a transform `T` on a `W×H` board: `T` is **injective**, and for every cell `p`,
> `T(neighbours(p)) == neighbours(T(p)) ∩ image(T)`.

`neighbours(p)` is `{ tessellation.neighbor(p, d) : d ∈ known_directions() }`, filtered to `Some`.
Intersecting with the image is what makes the two sides comparable, since `neighbor` clips at the
board edge.

Run across all four tessellations × `{(4,4), (4,5), (5,4), (5,5), (7,3), (1,5)}` × all four
transforms. **The even/odd size mix is the whole point** — every bug in §10 is invisible at one
parity and fatal at the other.

Paired with `every_transform_preserves_the_multiset_of_non_floor_tiles`, which closes the one hole
adjacency leaves open: a copy loop that lands tiles on geometrically correct cells but carries the
wrong contents.

### The standalone probe

`geometry_probe.rs` is a small implementation depending on `sokoban-elements` by path and
using **only its public API**. It was written before the implementation existed and produced every
measured claim in this document, so it is a genuine independent check rather than a restatement of
the code.

- Part A — the naive index flips, the §10 table
- Part B — 90° rotation for Sokoban and Octoban, plain and parity-fixed.
- Part C — 60° Hexoban rotation through cube coordinates.
- Part D — 60° Trioban rotation, the integer-formula cross-check against the float centroid
  embedding, checked against that embedding over a 40×40 region including negatives.
- Part E — the direction relabelling tables
- Part F — the finished public `GameBoard` API. It marks one source cell at a time to reconstruct
  the actual mapping and runs `check()` for every transform, tessellation, parity, and test size.
- `check()` — the adjacency-preservation property of §7.1. **Lift this into the crate tests.**

Two notes. It anchors by "shift to non-negative, then bump to satisfy the parity constraint" — bump
**up** (`dx += 1`), not down, or cells go negative and the check reports a spurious out-of-bounds
failure. And it does not implement the minimal-area anchor rule; it takes the first valid candidate,
which is fine for a correctness probe but is not the behaviour the engine ships.

Recorded probe run. Part A deliberately applies the old naive index flips directly, so
its `BROKE` results remain as regression evidence. Part F exercises the finished public
API and must report only `OK`.

```
=== A. naive index flips (what BoardResizer's defaults do) ===
  OK    Sokoban 5x5 reverse_columns      OK    Sokoban 5x5 reverse_rows
  OK    Sokoban 4x5 reverse_columns      OK    Sokoban 4x5 reverse_rows
  OK    Sokoban 5x4 reverse_columns      OK    Sokoban 5x4 reverse_rows
  OK    Sokoban 4x4 reverse_columns      OK    Sokoban 4x4 reverse_rows
  BROKE Hexoban 5x5 reverse_columns      OK    Hexoban 5x5 reverse_rows
  BROKE Hexoban 4x5 reverse_columns      OK    Hexoban 4x5 reverse_rows
  BROKE Hexoban 5x4 reverse_columns      BROKE Hexoban 5x4 reverse_rows
  BROKE Hexoban 4x4 reverse_columns      BROKE Hexoban 4x4 reverse_rows
  OK    Trioban 5x5 reverse_columns      BROKE Trioban 5x5 reverse_rows
  BROKE Trioban 4x5 reverse_columns      BROKE Trioban 4x5 reverse_rows
  OK    Trioban 5x4 reverse_columns      OK    Trioban 5x4 reverse_rows
  BROKE Trioban 4x4 reverse_columns      OK    Trioban 4x4 reverse_rows
  OK    Octoban 5x5 reverse_columns      OK    Octoban 5x5 reverse_rows
  BROKE Octoban 4x5 reverse_columns      OK    Octoban 4x5 reverse_rows
  OK    Octoban 5x4 reverse_columns      BROKE Octoban 5x4 reverse_rows
  BROKE Octoban 4x4 reverse_columns      BROKE Octoban 4x4 reverse_rows

  (representative failure detail)
  Hexoban 4x4 reverse_rows     (0,0)->(0,3): gained neighbour (1,2)
  Octoban 4x4 reverse_columns  (0,0)->(3,0): lost neighbour (2,1)

=== B. 90 degrees, Sokoban and Octoban ===
  OK    Sokoban  all sizes, plain and parity-fixed
  OK    Octoban 5x5, 4x5 rot90 plain
  BROKE Octoban 5x4, 4x4 rot90 plain          -- (0,0)->(3,0): lost neighbour (2,1)
  OK    Octoban all sizes rot90 parity-fixed

=== C. 60 degrees, Hexoban (odd-r offset -> cube -> rotate -> offset) ===
  OK    Hexoban 5x5 rot60 -> 6x7      OK    Hexoban 4x5 rot60 -> 5x6
  OK    Hexoban 5x4 rot60 -> 5x7      OK    Hexoban 4x4 rot60 -> 4x6
  OK    Hexoban 7x3 rot60 -> 6x8

=== D. 60 degrees, Trioban (rotate centroids, snap back onto the lattice) ===
  OK    Trioban 5x5 rot60 -> 10x5     OK    Trioban 4x5 rot60 -> 9x5
  OK    Trioban 5x4 rot60 -> 8x5      OK    Trioban 4x4 rot60 -> 7x4
  OK    Trioban 6x3 rot60 -> 6x5
  integer trioban rot60 formula: 0 mismatches over 1600 cells

=== E. how directions relabel under rotation ===
Sokoban rot90:  Up -> Right, Right -> Down, Down -> Left, Left -> Up
Octoban rot90:  same, plus NE -> SE, SE -> SW, SW -> NW, NW -> NE
                (diagonals only defined on the 72 octagon cells of 144)
Hexoban rot60:  Right -> SE, SE -> SW, SW -> Left, Left -> NW, NW -> NE, NE -> Right
                (uniform over all cells)
Trioban rot60:  NOT uniform -- each source direction splits 72/72 cells by triangle
                orientation, and each target is itself an aliased pair, e.g.
                  Left -> Left|NW   on 72 cells,  NE|NW on the other 72
                  Right -> Right|SE on 72 cells,  SE|SW on the other 72
```

### The raw maps, pinned per tessellation

`mod test_transforms` in each of the four `src/tessellation/*.rs` files. Before it existed the only
value-level assertion anywhere was the single point `(2, 1)` in `tessellation::tests`, which is an
anti-miswiring check and nothing more. Each module now pins, from the §4 formulas rather than from
the code: the images of both row and column parities; the images of negative coordinates, where
`rem_euclid`/`div_euclid` differ from `%` and `/`; `CW^(360/step) == id` **and not sooner**, with a
witness away from the origin; `CCW ∘ CW == id`; both mirrors as involutions; and
`is_rigid_translation` over all 49 pairs in `-3..=3` (49 for Sokoban, 21 Hexoban, 25 Trioban, 25
Octoban — the only difference between Sokoban's and Octoban's transform behaviour).

**Part E is now asserted, not just measured.** `it_rotates_every_direction_by_one_step` checks the
relabelling permutation for Sokoban, Octoban and Hexoban, and it is the only thing in the suite that
pins the rotation *angle*: adjacency preservation says the image of a neighbour is *some* neighbour,
this says *which*. Trioban is excluded on purpose — its relabelling is set-valued (§8) — and keeps
the adjacency half so the omission is visible.

Measured with deliberate perturbations, each reverted afterwards. The sharpest is a
**self-consistent 180° map** — `CW` and `CCW` both replaced by `(-x, -y)`, so they stay mutually
inverse. It is
injective, preserves adjacency and the tile multiset, and round trips both ways, so it passes
`every_transform_is_injective_and_preserves_adjacency`,
`every_transform_preserves_the_multiset_of_non_floor_tiles`,
`mirror_and_rotation_round_trips_restore_content` **and**
`counter_clockwise_inverts_clockwise_and_mirrors_are_involutions`. Of the whole pre-existing suite
only the one-point dispatch check notices it; it now fails seven of the new tests as well. That is
the concrete reason §12 says not to trust a round trip, and why the relabelling and full-turn tests
carry the weight here.

Dropping Hexoban's `y.rem_euclid(2)` term or Trioban's `-y - 1` is coarser and trips the property
tests too, but likewise fails the new per-tessellation ones first.

`best_anchor`'s rule is pinned in `transform.rs`'s `mod test_anchoring`: exact resulting dimensions,
the exact-area tie-break (relaxing `<` to `<=` fails those two tests and nothing else), and the
padding cells with no preimage that §5 calls "not a bug". Note the dimensions there are the
*engine's*, computed from §4 plus the minimal-area rule — not the figures in the probe run above,
which takes the first valid candidate instead.

Board level: `game_board/sokoban.rs` asserts a full rotated board as text, and
`game_board/{hexoban,trioban}.rs` mark one cell at a time and locate it after the turn — Part F's
idiom — because Hexoban's printed form is a normalizing separate encoding (§2) and a Trioban turn
legitimately changes the text's shape.

### Full tests suite

Beyond the transform property, the suite pins: exact return counts and cell coordinates for odd and
even Top/Left additions and removals; adjacency, injectivity and tile-shape preservation across
parity-sensitive tessellations and batch sizes; one batch versus repeated one-line calls; all-floor
boards, single-row/column boards, saturated removal and empty results; content-independent padding;
smallest-area crop selection and the column tie-break; combined Hexoban trimming and Trioban/Octoban
normalization; and text and serde round trips after dimension-changing edits.

---

## 12. Traps

- **Do not assert that `tile_shape_from_pos` is preserved by every transform** — assert the *signed*
  table instead, which is strictly sharper. Shape is preserved by all four Octoban transforms and by
  the 90° rotations, but on Trioban a 60° rotation and a top↔bottom mirror genuinely flip triangle
  orientation (▽↔△), while a vertical mirror preserves it. That is a real tiling symmetry, and the
  Trioban maps in §4 encode it deliberately. `trioban::tests::test_transforms` pins the flips and
  `octoban::tests::test_transforms` pins Octoban's preservation, both on the parity `(x + y) % 2` —
  **not** through
  `tile_shape_from_pos`, which takes `usize` and cannot be handed the negative coordinates
  `transformed_raw` produces. Adjacency remains the invariant that always holds.
- **Do not trust a round-trip test.** See §10.
- **Do not reintroduce geometry through Hexoban's external text form.** It is a serialization
  concern; §2 and §7 explain why.
- **`rem_euclid` / `div_euclid`, never `%` and `/`,** in the raw maps. They operate on negative
  coordinates, and Rust's `%` and `/` truncate toward zero.
- **Every `&mut self` method on `GameBoard` must call `normalize_dimensions()`.** It is the one way
  the width-normalization invariant leaks, and it leaks silently. `enclose()` is the newest one that
  has to. The exception is an early return that mutates nothing — `normalize()` and `enclose()` both
  bail out on a board with no play area, and there the invariant already holds.
- **The `match self` arms in `src/tessellation.rs` are easy to miswire** — all four have the same
  shape and the module names echo the variant names. Commit "fix: `Tessellation` dispatched
  `Sokoban` and `Hexoban` to each other's module" fixed exactly that. `tessellation::tests` is the
  only coverage of the dispatch itself.
- **A new public type must be added to the `pub use` list in `src/lib.rs`** or it is unnameable by
  callers.