aboutsummaryrefslogtreecommitdiff

Players

A side in the game is a Player. The game coordinator doesn't know or care whether it's a human clicking, a search, or a network: it asks the player for a move, passes on input, and draws whatever the player wants to show. Both traits are in src/player.rs.

The Player trait

pub trait Player
{
    fn name(&self) -> String;
    fn update(&mut self, _board: GameBoard, _turn: Turn) {}
    fn pick_move(&mut self, _board: GameBoard, _turn: Turn, _previous: &[GameBoard]) -> Option<Move> { None }
    fn handle_input(&mut self, _input: Input, _board: GameBoard, _turn: Turn) -> Option<Move> { None }
    fn highlights(&self) -> Highlights { Highlights::default() }
    fn reset(&mut self) {}
    fn render(&self, area: Rect, buf: &mut Buffer) { /* shows name() */ }
    fn render_last_turn(&self, _area: Rect, _buf: &mut Buffer) {}
}

Only name is required. Players are polled, not blocking: no method waits for anything, so the coordinator can keep redrawing and handling quit keys whatever kind of player is on move.

Method When it's called What to do
name For panel titles and the result A short name, e.g. "Claude"
update(board, turn) Before every redraw, while it's this player's turn Remember the position if render needs it
pick_move(board, turn, previous) Once per loop, while it's this player's turn and the game isn't over Computer players: search and return Some(move). Interactive players: return None. previous is every earlier board in the game, oldest first, for spotting repetitions.
handle_input(input, board, turn) When the user clicks a board point (Input::Click(pos)) or presses a key (Input::Key(event)) during this player's turn Return Some(move) once the input completes a move
highlights() When drawing the board during this player's turn Highlights { selected, targets }: a selected piece and the points it can move to
reset() On both players after the user reverts to an earlier move Drop any half-made selection and any comments about the last move. It is not called after a normal move, so a player clears its own selection when it returns a move.
render(area, buf) Every redraw, while it's this player's turn Draw the player's panel. The default shows name().
render_last_turn(area, buf) Every redraw during the other player's turn Draw comments on the move this player just made. The default draws nothing.

Rules a player must follow:

  • Only return legal moves. The coordinator plays them with make_move(..).unwrap(), so an illegal move panics. Moves from board.get_moves(turn) are always legal; board.check_move(mv) checks any other.
  • pick_move is never called once the game is over, so get_moves(turn) is never empty there.
  • Input that arrives while a computer player is searching is handled after the search, so a long pick_move delays quitting until it returns. Keep a time limit.

impl Widget for &dyn Player makes every player a ratatui widget by forwarding to Player::render, which is how the coordinator draws the panel. (A blanket impl<P: Player> Widget for &P would break the orphan rule.)

Where each method shows up on screen

┌ Board ───────────────────────────┬ Moves ─────┐
│   highlights() of the player     │            │
│   to move are drawn here         │            │
├──────────────────────────────────┼────────────┤
│ render() of the player to move   │ render_last│
│                                  │ _turn() of │
│                                  │ the other  │
└────────── Sheep: name() ─────────┴────────────┘

LocalPlayer: a human with a mouse

src/local-player.rs. It's the example of an interactive player:

  • pick_move keeps the default None, so the coordinator waits for input.
  • handle_input takes clicks. While sheep are in hand, a click on an empty point places a sheep. Otherwise a click on one of its own pieces selects it and keeps its legal moves; a click on one of their destinations plays that move; any other click clears the selection. Illegal clicks leave a message in red, from MoveError's Display.
  • highlights returns the selected piece and its destinations.
  • update stores the position so render can show the sheep counts and an instruction.
  • render_last_turn shows Played <move>.
  • reset clears the selection, message and last move.

The MinimaxPlayer trait

Every computer player so far is a search with a different evaluation. MinimaxPlayer holds that search, and a blanket impl makes every MinimaxPlayer a Player:

impl<P: MinimaxPlayer> Player for P { … }

So a computer player implements MinimaxPlayer only, never Player directly (the two impls would conflict).

pub trait MinimaxPlayer
{
    const WIN: f32 = f32::INFINITY;
    const DRAW: f32 = 0.0;

    // Required
    fn evaluate(&mut self, board: &GameBoard, turn: Turn) -> ScoreType;

    // Optional settings
    fn depth(&self) -> usize { MAX_DEPTH }                     // at most 64
    fn time_limit(&self) -> Duration { Duration::from_secs(1) }
    fn name(&self) -> String { "Computer".to_string() }

    // Optional storage that speeds up the search
    fn cache(&mut self) -> Option<&mut dyn ScoreCache> { None }
    fn killers(&mut self) -> Option<&mut Killers> { None }

    // Optional storage for the last-turn panel
    fn set_last_search(&mut self, _report: Option<SearchReport>) {}
    fn last_search(&self) -> Option<SearchReport> { None }

    // The search itself; not meant to be overridden
    fn minimax(…) -> Option<ScoreType>;
    fn minimax_uncached(…) -> Option<(ScoreType, Option<Move>)>;
}
Item Purpose
evaluate(board, turn) The score of a position where the search stops. Positive favours tigers, whichever side is to move; turn is there for evaluations that care. Return finite values: ±WIN (infinity) is reserved for won games. If the player has a cache, give symmetric boards the same score (see the search).
depth() The most plies to search, counting the move itself. Normally the time limit stops the search first.
time_limit() How long pick_move may take. It stops deepening after half of it.
cache() Return a ScoreCache (normally a TranspositionTable) to turn on the transposition table.
killers() Return a Killers to turn on killer-move ordering.
set_last_search / last_search Store and return the SearchReport { mv, depth, score } of the last pick_move, which the blanket impl's render_last_turn shows as "Played … / Searched to depth … / Score: …".
WIN, DRAW Scores of a won game and of a repetition

The blanket Player impl provides:

  • pick_move: iterative deepening with the time limit (see The minimax search).
  • render: the name and "Thinking…", which is on screen while the search runs.
  • render_last_turn: the search report, with the score to 5 significant figures, or "forced win for tigers/sheep".
  • reset: forgets the search report.

Supporting types

Type Purpose
ScoreType f32
SearchReport The move, depth and score of the last search
CacheEntry Bounds lower..=upper on a position's score and its best move
ScoreCache Trait for caches: lookup, insert, clear. Implemented by TranspositionTable and (for tests) HashMap
TranspositionTable Fixed-size cache: new(bits) gives 1 << bits slots of ~24 bytes
Killers Two killer moves per remaining depth
SearchStats Counters filled in with the search-stats feature
MAX_DEPTH 64

ClaudePlayer

src/claude-player.rs. A MinimaxPlayer with a 2^20-slot TranspositionTable, killers, and a hand-written evaluation from the tigers' side:

100 × dead sheep            five dead sheep win the game for the tigers
+20 × jumps available now   sheep left hanging
−15 × trapped tigers        tigers with no move; trapping all of them wins for the sheep
 +2 × tiger moves           mobility in general, which the sheep win by squeezing to zero

time_limit and max_depth are public fields. ClaudePlayer::new(time_limit) makes one; the bench and train code set max_depth and a huge time limit to get fixed-depth, repeatable searches.

NeuralPlayer

src/neural-player.rs. The same as ClaudePlayer, except that evaluate returns self.net.forward(board, turn), the network's output in -1..1. NeuralPlayer::new(net, time_limit, table_bits) lets training use a smaller table (2^16 slots), since it runs one player per thread. Forced wins are still ±WIN from the search itself, far outside the network's range.