summaryrefslogtreecommitdiff
path: root/docs/adding-a-player.md
blob: 915831aa4566b6760d9604dd66c9f2782ac8f772 (plain) (blame)
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
# Adding a player

There are two kinds of player to add:

- **A minimax player** — a new evaluation function on top of the existing search. This is the
  easiest and usually what you want for a new computer opponent. Implement `MinimaxPlayer`.
- **Any other player** — a different algorithm (Monte Carlo tree search, a random mover, a player
  over the network, a keyboard-driven human…). Implement `Player` directly.

Then, either way, make it selectable on the command line.

Read [Players](players.md) first for what each trait method is for.

## Option A: a minimax player

Say we want a player that only counts dead sheep and tiger mobility. Create
`src/greedy-player.rs`:

```rust
use std::time::Duration;

use crate::{board::{GameBoard, Turn}, player::{Killers, MinimaxPlayer, ScoreCache, SearchReport, TranspositionTable}};

/// A computer player that searches like `ClaudePlayer` but only counts dead sheep and tiger moves.
pub struct GreedyPlayer
{
	pub time_limit: Duration,
	cache: TranspositionTable,
	killers: Killers,
	last_search: Option<SearchReport>,
}

impl GreedyPlayer
{
	pub fn new(time_limit: Duration) -> Self
	{
		Self { time_limit, cache: TranspositionTable::new(20), killers: Killers::default(), last_search: None }
	}
}

impl MinimaxPlayer for GreedyPlayer
{
	/// Positive favours tigers, whichever side is to move.
	fn evaluate(&mut self, board: &GameBoard, _turn: Turn) -> f32
	{
		let dead = 20 - board.sheep_count() - board.sheep_left();
		100.0 * dead as f32 + board.get_moves(Turn::Tiger).len() as f32
	}

	fn name(&self) -> String { "Greedy".to_string() }

	fn time_limit(&self) -> Duration { self.time_limit }

	fn cache(&mut self) -> Option<&mut dyn ScoreCache> { Some(&mut self.cache) }

	fn killers(&mut self) -> Option<&mut Killers> { Some(&mut self.killers) }

	fn set_last_search(&mut self, report: Option<SearchReport>) { self.last_search = report; }

	fn last_search(&self) -> Option<SearchReport> { self.last_search }
}
```

That's all: the blanket `impl<P: MinimaxPlayer> Player for P` gives it `pick_move` with iterative
deepening, the "Thinking…" panel and the last-turn report. **Don't also write `impl Player for
GreedyPlayer`**; it would conflict with the blanket impl.

Things to get right in `evaluate`:

- **Sign.** Positive is good for the tigers, negative good for the sheep, regardless of `turn`.
- **Finite values.** `±f32::INFINITY` means a won game to the search; an evaluation that returns it
  would be read as a forced win and stop iterative deepening.
- **Symmetry.** With a cache, the search shares one entry between the 8 symmetric boards, so
  `evaluate` should give them the same score. Anything built from counts (pieces, moves, jumps) is
  symmetric automatically; something like "sheep on the left half" is not.
- **Speed.** `evaluate` runs at every leaf, which is most of the nodes. `get_moves` allocates a
  `Vec`; the bitmask accessors (`tiger_points`, `sheep_points`, `sheep_count`) are much cheaper.

`cache`, `killers` and the `last_search` pair are optional. Leave them out and the player still
works, just searching more slowly and with an empty last-turn panel.

## Option B: any other player

Implement `Player`. A computer player does its work in `pick_move` and returns `Some`. Here is a
player that moves at random, using the crate's own `Rng` from `network.rs`
(`src/random-player.rs`):

```rust
use crate::{board::{GameBoard, Move, Turn}, network::Rng, player::Player};

/// Plays a uniformly random legal move.
pub struct RandomPlayer
{
	rng: Rng,
}

impl RandomPlayer
{
	pub fn new(seed: u64) -> Self
	{
		Self { rng: Rng::new(seed) }
	}
}

impl Player for RandomPlayer
{
	fn name(&self) -> String { "Random".to_string() }

	fn pick_move(&mut self, board: GameBoard, turn: Turn, _previous: &[GameBoard]) -> Option<Move>
	{
		// pick_move is only called while the game is on, so there is always a move
		let moves = board.get_moves(turn);
		Some(moves[self.rng.below(moves.len())])
	}
}
```

Things to keep in mind:

- **Return only legal moves.** The coordinator `unwrap`s `make_move`, so an illegal move panics.
  Pick from `board.get_moves(turn)`, or check with `board.check_move(mv)`.
- **Don't block forever.** The coordinator can't redraw or react to `q` while `pick_move` runs. Input
  that arrives meanwhile is handled as soon as it returns. Keep a time limit.
- **Repetitions.** `previous` holds every earlier board in the game. `board::repetitions(previous,
  &next)` says how often a position came up before; `REPETITION_DRAW` (2) earlier occurrences make a
  draw.
- **Interactive players** return `None` from `pick_move` and produce their move from
  `handle_input`, as `LocalPlayer` does. Use `update` to remember the position for `render`,
  `highlights` to show a selection, and clear your own selection when you return a move. `reset`
  is called after the user reverts, so drop any selection there too.
- **Panels.** Override `render` for the panel shown on your turn and `render_last_turn` for comments
  on your last move, shown during the opponent's turn. Both draw with ratatui into the given `area`
  of `buf`, e.g. `Paragraph::new(text).render(area, buf)` (with `ratatui::widgets::Widget` in scope).

## Wiring it into the command line

All in [`src/main.rs`](../src/main.rs).

**1. Declare the module.** File names with a hyphen need `#[path]`:

```rust
#[path = "greedy-player.rs"]
mod greedy_player;
```

and import the type: `use crate::greedy_player::GreedyPlayer;` (or add it to the existing
`use crate::{…}` list).

**2. Add a `PlayerKind` variant:**

```rust
enum PlayerKind
{
	Human,
	Claude,
	Greedy,
	Neural(PathBuf),
}
```

**3. Parse its name** in `parse_player`, before the file-path case, and mention it in the error:

```rust
"greedy" => Ok(PlayerKind::Greedy),
…
_ => Err(format!("expected `human`, `claude`, `greedy` or a weights file, but `{s}` is none of these")),
```

**4. Construct it** in `make_player`:

```rust
PlayerKind::Greedy => Box::new(GreedyPlayer::new(time_limit)),
```

**5. Update the help text** on the `sheep` and `tiger` fields of `PlayArgs` (their doc comments are
what `--help` shows), and the player list in the top-level `README.md`.

Then:

```bash
cargo run --release -- -S human -T greedy
cargo run --release -- -S greedy -T claude --time 1
```

If the new type is only used from `main.rs` through `make_player`, everything is reachable and there
are no dead-code warnings. A constructor or field that nothing uses yet will warn; that's a sign to
either use it or remove it.

## Testing it

Put tests in a `#[cfg(test)] mod tests` at the bottom of the player's file, as the other players do.
Useful checks:

- `pick_move` from a few positions returns a move with `board.check_move(m) == Ok(())`.
- For a minimax player, `last_search()` reports the move it returned.
- A player with a clear preference picks the obvious move: `player.rs` has a position where one
  tiger jump wins the game (`tiger_takes_the_winning_kill`) that is easy to copy.

To see how it plays, play it against `ClaudePlayer` in the TUI with both sides computer-controlled:

```bash
cargo run --release -- -S greedy -T claude
```

For a proper strength comparison over many games, `train.rs`'s `match_score` and `play` show how to
run games without the TUI: `play` takes a closure that returns `(move, score)` for a position, so
any `Player` can be plugged in.

## Using it in training

`train.rs` plays its matches against `ClaudePlayer` (`claude(depth)`). To measure the network
against your player instead, add a constructor like `claude` for it and pass it to `match_score`,
which accepts any `MinimaxPlayer + Player`. Keep the match opponents out of the training data:
they are a yardstick, and the network is meant to learn from self-play alone.