diff options
| author | Dylan <boss@tehbox.org> | 2026-11-09 23:32:43 +1300 |
|---|---|---|
| committer | Dylan <boss@tehbox.org> | 2026-11-09 23:32:43 +1300 |
| commit | f17308e3c132ee18cc4cbd458329958c99a712ed (patch) | |
| tree | 098800a2f4efd37fd3ffd0c639b732fdc8227a65 /docs/board.md | |
| parent | 4eea7f726b5a7e0a55716a5c87e398dfb65fceb7 (diff) | |
| download | tigers-and-sheep-e8c890685bd674656a35e795b8fea35b39aa7753.tar.gz tigers-and-sheep-e8c890685bd674656a35e795b8fea35b39aa7753.zip | |
Covers building and dependencies, the architecture and game loop, the board
representation, the Player and MinimaxPlayer traits, the minimax search,
the network and self-play training, benchmarking, and how to add a player.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'docs/board.md')
| -rw-r--r-- | docs/board.md | 163 |
1 files changed, 163 insertions, 0 deletions
diff --git a/docs/board.md b/docs/board.md new file mode 100644 index 0000000..8719d2b --- /dev/null +++ b/docs/board.md @@ -0,0 +1,163 @@ +# The board + +Everything about the rules lives in [`src/board.rs`](../src/board.rs), along with its unit tests at +the bottom of the file. + +## Types + +| Type | Meaning | +|------|---------| +| `Pos = (u8, u8)` | A point `(x, y)`, both in `0..5`. Out-of-range points panic in `bit_idx`. | +| `PieceType` | `Tiger`, `Sheep` or `Empty`; what `get(pos)` returns | +| `Turn` | `Tiger` or `Sheep`, the side about to move. `next_turn()` flips it. | +| `Move` | `PlaceSheep { pos }`, `MoveSheep { from, to }` or `MoveTiger { from, to }`. A tiger jump is a `MoveTiger` of two points; `is_jump` tells them apart. | +| `MoveError` | Why `check_move` rejected a move, with a human-readable `Display` | +| `Outcome` | `Tiger`, `Sheep` or `Draw`; `value()` is +1, -1 or 0, matching the sign of scores | +| `GameBoard` | The position, including the sheep still in hand. `Copy`, 8 bytes. | + +## The bitboard + +A `GameBoard` is two `u32`s: + +```text +tiger_mask: [ unused (7 bits) ][ 25 board bits ] +sheep_mask: [ sheep in hand ][ 25 board bits ] + bits 25..32 bits 0..25 +``` + +Point `(x, y)` is bit `x + 5*y`, so `(0, 0)` (a5, top left) is bit 0 and `(4, 4)` (e1) is bit 24: + +```text + a b c d e +5 0 1 2 3 4 +4 5 6 7 8 9 +3 10 11 12 13 14 +2 15 16 17 18 19 +1 20 21 22 23 24 +``` + +The high 7 bits of `sheep_mask`, from `SHEEP_COUNT_SHIFT` = 25, aren't points: they hold the +number of sheep still in hand, 20 at the start. `sheep_left()` reads them, and placing a sheep sets +its board bit and subtracts `ONE_SHEEP` (`1 << 25`) in one go. Killing a sheep only clears its board +bit; the in-hand count is unplaced sheep, not living ones. So the number of dead sheep is +`20 - sheep_count() - sheep_left()`. + +The default board has tigers on bits 0, 4, 20 and 24 (the corners) and 20 sheep in hand. +`GameBoard::EMPTY` has nothing at all and is used as the "no entry" board in the transposition table. + +Useful accessors: + +- `tiger_points()`, `sheep_points()` — the 25 board bits alone. +- `sheep_count()` — sheep on the board (`popcount` of the board bits). +- `key()` — the whole board as one `u64` (`sheep_mask << 32 | tiger_mask`). Different boards give + different keys, so it serves as a hash key and for ordering. + +## Lines + +Every point has horizontal and vertical lines. Diagonals only run through points where `x + y` is +even (the corners, the centre and so on), as on a Bagh-Chal board: + +```text +5 T───·───·───·───T + │ ╲ │ ╱ │ ╲ │ ╱ │ +4 ·───·───·───·───· + │ ╱ │ ╲ │ ╱ │ ╲ │ +3 ·───·───·───·───· + … +``` + +`on_line(from, to, dist)` checks this for a single move: orthogonal and `dist` away, or diagonal, +`dist` away and starting from an even point. A diagonal step keeps `x + y`'s parity, so a diagonal +never leaves the even points. + +## Move generation with bitmasks + +`get_moves(turn)` doesn't loop over pieces. It works on all pieces of a side at once, one direction +at a time. + +- `DIRS` lists the 8 directions as `(dx, dy)`: 4 orthogonal, then 4 diagonal. `DELTAS` is the + matching change in bit index, `dx + 5*dy`. +- `STEP_SOURCES[d]` is a mask, built at compile time by `line_sources`, of the points from which + one step in direction `d` stays on the board *and* follows a line. `JUMP_SOURCES[d]` is the same + for two steps. +- `shift(mask, delta)` moves every point in a mask by `delta` bits. Shifting can wrap a point from + the end of one row to the start of the next, but the source masks exclude every point for which + that would happen. + +Then: + +```text +sheep steps in direction d = shift(sheep & STEP_SOURCES[d], δ) & empty +tiger steps in direction d = shift(tigers & STEP_SOURCES[d], δ) & empty +jumped sheep = shift(tigers & JUMP_SOURCES[d], δ) & sheep +tiger jumps in direction d = shift(jumped sheep, δ) & empty +``` + +`sheep_steps()` and `tiger_targets()` compute these masks, and `get_moves` turns each set bit back +into a `Move` (the source is the destination minus `δ`, or minus `2δ` for a jump). Tiger jumps are +listed before tiger steps, since the search wants to try kills first. While sheep are in hand, the +sheep's only moves are placements on every empty point. + +`has_moves(turn)` uses the same masks but only asks whether any is non-zero, without building a +`Vec`. `check_win` uses it, since it's called at every node of the search. + +## Playing moves + +- `check_move(mv)` returns `Ok(())` or the specific `MoveError`: placing with none in hand, onto an + occupied point, moving a sheep while some are still in hand, moving something that isn't there, + moving off a line, jumping over something that isn't a sheep, and so on. The tests assert on the + exact variant. +- `make_move(mv)` checks first and leaves the board unchanged on an error. +- `make_move_unchecked(mv)` skips the checks. Use it only for moves from `get_moves` (the search + does, for speed). An illegal move here leaves the board in a state the rules can't reach. +- `after_move_unchecked(mv)` returns a new board instead of changing this one. + +A tiger move of two points always kills the sheep in between (`move_tiger` uses the `midpoint`). + +## Winning and drawing + +`check_win(turn)` is called with the side **about to move**, and `true` means the side that **just +moved** has won: + +- Tigers have won once 5 sheep are dead: `sheep_count() + sheep_left() <= 15`. +- Otherwise, the side that just moved has won if `turn` has no legal move. This is how sheep win, + by leaving every tiger without a move. (Tigers could in principle win this way too, by leaving the + sheep with no moves.) + +So `Outcome::win(turn.next_turn())` is the winner when `check_win(turn)` is true. + +### Repetitions + +A game is drawn on a threefold repetition, as in chess: the same position with the same side to +move, for the third time. `repetitions(previous, board)` counts how many times `board` came up +before with the same side to move, where `previous` is every earlier board, oldest first. Since the +last board in `previous` had the other side to move, it looks at every second board going back: +`previous.iter().rev().skip(1).step_by(2)`. + +Two shortcuts keep it cheap: + +- Placing and killing can't be undone, so nothing can repeat while sheep are in hand: it returns 0. +- For the same reason, it stops scanning at the first earlier board with a different number of + sheep on it. + +`REPETITION_DRAW` = 2: a position that has come up twice before is a draw. The search uses a +stricter rule internally (one earlier occurrence scores as a draw); see [The minimax search](search.md#repetitions). + +## Symmetries + +The board has 8 symmetries: 4 rotations, each optionally mirrored. All of them map lines to lines, +because they keep the parity of `x + y`, so a transformed position is just as legal and equally good +for either side. The search uses this to share cache entries between symmetric positions, and +training uses it to show the network each position in a random orientation. + +- `transform_pos(pos, sym)` with `sym` in `0..SYMMETRIES` (8): bit 2 of `sym` swaps x and y, then + bit 0 mirrors x and bit 1 mirrors y. +- `inverse_symmetry(sym)` undoes it. Plain mirrors undo themselves; with the swap first, undoing + means swapping which axis is mirrored. +- `transform_move(mv, sym)` transforms both ends of a move. +- `GameBoard::transformed(sym)` transforms a whole board. Doing that point by point would be slow, + so `ROW_MAPS` (built at compile time) holds, for each symmetry and each of the 5 rows, where each + of the 32 possible sets of points in that row end up. A mask then transforms in 5 table lookups + ORed together. The sheep-in-hand bits are carried over unchanged. +- `canonical()` returns the transform with the smallest `key()` and the `sym` that reaches it. All 8 + symmetric boards have the same canonical board. |
