The board
Everything about the rules lives in 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 u32s:
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:
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 (popcountof the board bits).key()— the whole board as oneu64(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:
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.
DIRSlists the 8 directions as(dx, dy): 4 orthogonal, then 4 diagonal.DELTASis the matching change in bit index,dx + 5*dy.STEP_SOURCES[d]is a mask, built at compile time byline_sources, of the points from which one step in directiondstays 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 bydeltabits. 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:
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)returnsOk(())or the specificMoveError: 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 fromget_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
turnhas 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.
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)withsymin0..SYMMETRIES(8): bit 2 ofsymswaps 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, soROW_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 smallestkey()and thesymthat reaches it. All 8 symmetric boards have the same canonical board.
