summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorDylan <boss@tehbox.org>2026-11-09 16:17:04 +1300
committerDylan <boss@tehbox.org>2026-11-09 16:17:04 +1300
commit0fd43eb6d9a4bae8d4c29fd614f9cf2a362f7b7c (patch)
treeeb926ba3fbc0dcde9717509e9fa02cc79a3679f9 /src
parent046969c820e89b74d55c2d07c47470237f11cc2a (diff)
downloadtigers-and-sheep-0fd43eb6d9a4bae8d4c29fd614f9cf2a362f7b7c.tar.gz
tigers-and-sheep-0fd43eb6d9a4bae8d4c29fd614f9cf2a362f7b7c.zip
feat: Draw by threefold repetition
A position coming up for the third time with the same side to move ends the game as a draw, in the TUI and in training self-play. Players now get the game's earlier boards in pick_move, and the search scores the first repetition on its path as a draw, so a side that is ahead avoids one and a side that is behind seeks one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'src')
-rw-r--r--src/bench.rs9
-rw-r--r--src/board.rs118
-rw-r--r--src/claude-player.rs4
-rw-r--r--src/game.rs114
-rw-r--r--src/main.rs9
-rw-r--r--src/neural-player.rs4
-rw-r--r--src/player.rs132
-rw-r--r--src/train.rs53
8 files changed, 362 insertions, 81 deletions
diff --git a/src/bench.rs b/src/bench.rs
index ad6d63f..8ee2c00 100644
--- a/src/bench.rs
+++ b/src/bench.rs
@@ -23,7 +23,7 @@ fn find_position(plies: usize) -> (GameBoard, Turn)
for _ in 0..plies
{
assert!(!board.check_win(turn), "the self-play game ended before ply {plies}");
- board.make_move_unchecked(player.pick_move(board, turn).expect("a move"));
+ board.make_move_unchecked(player.pick_move(board, turn, &[]).expect("a move"));
turn = turn.next_turn();
}
(board, turn)
@@ -44,7 +44,7 @@ pub fn run(depth: usize)
player.max_depth = depth;
SearchStats::take();
let start = Instant::now();
- let m = player.pick_move(board, turn).expect("a move");
+ let m = player.pick_move(board, turn, &[]).expect("a move");
let time = start.elapsed();
let stats = SearchStats::take();
total_time += time;
@@ -65,6 +65,7 @@ fn add(total: &mut SearchStats, s: &SearchStats)
total.nodes += s.nodes;
total.leaves += s.leaves;
total.terminal += s.terminal;
+ total.repetitions += s.repetitions;
total.cache_probes += s.cache_probes;
total.cache_hits += s.cache_hits;
total.cache_returns += s.cache_returns;
@@ -81,8 +82,8 @@ fn print_stats(s: &SearchStats, time: Duration)
return;
}
let pct = |a: u64, b: u64| if b == 0 { 0.0 } else { 100.0 * a as f64 / b as f64 };
- println!(" {} nodes ({:.2} M/s), {} leaves, {} terminal, {:.2} move gens/node",
- s.nodes, s.nodes as f64 / time.as_secs_f64() / 1e6, s.leaves, s.terminal,
+ println!(" {} nodes ({:.2} M/s), {} leaves, {} terminal, {} repetitions, {:.2} move gens/node",
+ s.nodes, s.nodes as f64 / time.as_secs_f64() / 1e6, s.leaves, s.terminal, s.repetitions,
s.move_gens as f64 / s.nodes.max(1) as f64);
println!(" cache: {} probes, {:.1}% hit, {:.1}% of hits settle the score, {} overwrites; cutoffs: {}, {:.1}% on the first move",
s.cache_probes, pct(s.cache_hits, s.cache_probes), pct(s.cache_returns, s.cache_hits), s.cache_overwrites,
diff --git a/src/board.rs b/src/board.rs
index 087ac0e..8957111 100644
--- a/src/board.rs
+++ b/src/board.rs
@@ -45,6 +45,54 @@ impl Turn
}
}
+/// How a game ended.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Outcome
+{
+ Tiger,
+ Sheep,
+ Draw,
+}
+
+impl Outcome
+{
+ pub const ALL: [Outcome; 3] = [Outcome::Tiger, Outcome::Sheep, Outcome::Draw];
+
+ /// The win for `turn`'s side.
+ pub fn win(turn: Turn) -> Self
+ {
+ match turn { Turn::Tiger => Outcome::Tiger, Turn::Sheep => Outcome::Sheep }
+ }
+
+ /// +1 for a tiger win, -1 for a sheep win, matching the sign of the scores.
+ pub fn value(self) -> f32
+ {
+ match self { Outcome::Tiger => 1.0, Outcome::Sheep => -1.0, Outcome::Draw => 0.0 }
+ }
+}
+
+/// A position seen this many times before, with the same side to move, is a draw:
+/// the third occurrence, as in chess.
+pub const REPETITION_DRAW: usize = 2;
+
+/// How many times `board` came up before with the same side to move. `previous` is the boards
+/// before it, oldest first, so the last one had the other side to move.
+///
+/// Placing a sheep and killing one can't be undone, so the scan stops at the first board with a
+/// different number of sheep, and nothing repeats while sheep are still in hand.
+pub fn repetitions(previous: &[GameBoard], board: &GameBoard) -> usize
+{
+ if board.sheep_left() > 0
+ {
+ return 0;
+ }
+ let sheep = board.sheep_count();
+ previous.iter().rev().skip(1).step_by(2)
+ .take_while(|b| b.sheep_left() == 0 && b.sheep_count() == sheep)
+ .filter(|&b| b == board)
+ .count()
+}
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Move
{
@@ -1366,6 +1414,76 @@ mod tests
assert!(!board.check_win(Turn::Tiger));
}
+ // --- repetitions ---
+
+ /// Movement phase: a tiger at a5 and sheep at e1 and d1, none in hand.
+ fn shuffle_board() -> GameBoard
+ {
+ let mut board = GameBoard::EMPTY;
+ board.set((0, 0), PieceType::Tiger);
+ board.set((4, 4), PieceType::Sheep);
+ board.set((3, 4), PieceType::Sheep);
+ board
+ }
+
+ /// The boards from `shuffle_board` with the tiger stepping a5-b5-a5 and a sheep e1-e2-e1, `cycles` times.
+ fn shuffle(cycles: usize) -> Vec<GameBoard>
+ {
+ let moves = [
+ Move::MoveTiger { from: (0, 0), to: (1, 0) },
+ Move::MoveSheep { from: (4, 4), to: (4, 3) },
+ Move::MoveTiger { from: (1, 0), to: (0, 0) },
+ Move::MoveSheep { from: (4, 3), to: (4, 4) },
+ ];
+ let mut boards = vec![shuffle_board()];
+ for m in moves.iter().cycle().take(4 * cycles)
+ {
+ let next = boards.last().unwrap().after_move_unchecked(*m);
+ boards.push(next);
+ }
+ boards
+ }
+
+ #[test]
+ fn repetitions_count_earlier_occurrences()
+ {
+ let boards = shuffle(2);
+ assert_eq!(repetitions(&[], &boards[0]), 0);
+ assert_eq!(repetitions(&boards[..4], &boards[4]), 1);
+ assert_eq!(repetitions(&boards[..8], &boards[8]), 2);
+ // After the tiger's move: b5 came up once before, with sheep to move
+ assert_eq!(repetitions(&boards[..5], &boards[5]), 1);
+ }
+
+ #[test]
+ fn repetitions_need_the_same_side_to_move()
+ {
+ let board = shuffle_board();
+ let other = shuffle(1)[1];
+ // The same board, but with the other side to move
+ assert_eq!(repetitions(&[board], &board), 0);
+ assert_eq!(repetitions(&[board, other, board], &board), 0);
+ assert_eq!(repetitions(&[board, other], &board), 1);
+ }
+
+ #[test]
+ fn repetitions_stop_at_a_kill()
+ {
+ let boards = shuffle(1);
+ let mut killed = boards[2];
+ killed.set((3, 4), PieceType::Empty);
+ // An identical board from before a kill doesn't count
+ let previous = [boards[0], boards[1], killed, boards[3]];
+ assert_eq!(repetitions(&previous, &boards[4]), 0);
+ }
+
+ #[test]
+ fn nothing_repeats_with_sheep_in_hand()
+ {
+ let board = GameBoard::default();
+ assert_eq!(repetitions(&[board, board], &board), 0);
+ }
+
// --- symmetries ---
#[test]
diff --git a/src/claude-player.rs b/src/claude-player.rs
index c25c5e1..2b7004c 100644
--- a/src/claude-player.rs
+++ b/src/claude-player.rs
@@ -92,12 +92,12 @@ mod tests
fn pick_move_reports_its_search()
{
let mut player = ClaudePlayer::new(Duration::from_millis(50));
- let m = player.pick_move(GameBoard::default(), Turn::Tiger).unwrap();
+ let m = player.pick_move(GameBoard::default(), Turn::Tiger, &[]).unwrap();
let report = player.last_search.expect("a report");
assert_eq!(report.mv, m);
assert!(report.depth >= 1);
assert_eq!(report.score, player.minimax(GameBoard::default(), Turn::Tiger, report.depth,
- f32::NEG_INFINITY, f32::INFINITY, None).unwrap());
+ f32::NEG_INFINITY, f32::INFINITY, None, &mut Vec::new()).unwrap());
player.reset();
assert_eq!(player.last_search, None);
diff --git a/src/game.rs b/src/game.rs
index 5aef809..716446d 100644
--- a/src/game.rs
+++ b/src/game.rs
@@ -10,7 +10,7 @@ use ratatui::{
DefaultTerminal, Frame,
};
-use crate::{board::{GameBoard, Move, Turn}, board_view::{pos_at, BoardView}, player::{Input, Player}};
+use crate::{board::{repetitions, GameBoard, Move, Outcome, Turn, REPETITION_DRAW}, board_view::{pos_at, BoardView}, player::{Input, Player}};
/// Runs a game in the terminal: draws the board, the move history and the current player's panel,
/// and passes mouse clicks on the board to the player whose turn it is.
@@ -64,11 +64,23 @@ impl GameHandler
if self.history.len().is_multiple_of(2) { Turn::Sheep } else { Turn::Tiger }
}
- /// The side that has won, if the game is over.
- pub fn winner(&self) -> Option<Turn>
+ /// The boards before the current one, oldest first.
+ pub fn previous(&self) -> Vec<GameBoard>
{
+ std::iter::once(self.start).chain(self.history.iter().map(|&(_, board)| board))
+ .take(self.history.len()).collect()
+ }
+
+ /// How the game ended, if it is over: a win, or a draw once a position comes up a third time.
+ pub fn outcome(&self) -> Option<Outcome>
+ {
+ let (board, turn) = (self.board(), self.turn());
// check_win runs before `turn`'s move, so whoever moved last has won.
- self.board().check_win(self.turn()).then(|| self.turn().next_turn())
+ if board.check_win(turn)
+ {
+ return Some(Outcome::win(turn.next_turn()));
+ }
+ (repetitions(&self.previous(), &board) >= REPETITION_DRAW).then_some(Outcome::Draw)
}
/// Play `m` for the side to move. Panics if it is illegal.
@@ -86,8 +98,8 @@ impl GameHandler
}
/// Play in the terminal with `p1` as the sheep and `p2` as the tigers, until someone wins and
- /// the user quits. Returns the winner, or None if the user quit before the game was won.
- pub fn play_game(&mut self, p1: &mut dyn Player, p2: &mut dyn Player) -> io::Result<Option<Turn>>
+ /// the user quits. Returns how it ended, or None if the user quit before the game was over.
+ pub fn play_game(&mut self, p1: &mut dyn Player, p2: &mut dyn Player) -> io::Result<Option<Outcome>>
{
let mut terminal = ratatui::init();
let result = execute!(io::stdout(), EnableMouseCapture)
@@ -97,19 +109,19 @@ impl GameHandler
result
}
- fn run(&mut self, terminal: &mut DefaultTerminal, mut players: [&mut dyn Player; 2]) -> io::Result<Option<Turn>>
+ fn run(&mut self, terminal: &mut DefaultTerminal, mut players: [&mut dyn Player; 2]) -> io::Result<Option<Outcome>>
{
let idx = |turn: Turn| match turn { Turn::Sheep => 0, Turn::Tiger => 1 };
loop
{
let (board, turn) = (self.board(), self.turn());
- let over = self.winner().is_some();
+ let over = self.outcome().is_some();
players[idx(turn)].update(board, turn);
// Drawn before `pick_move`, so a computer's "thinking" panel is on screen while it searches
terminal.draw(|frame| self.draw(frame, &players))?;
let player = &mut *players[idx(turn)];
- let action = match (!over).then(|| player.pick_move(board, turn)).flatten()
+ let action = match (!over).then(|| player.pick_move(board, turn, &self.previous())).flatten()
{
// Input that came in during the search goes first, so a quit or a revert isn't
// lost when both players are computers and nothing ever waits for input
@@ -120,7 +132,7 @@ impl GameHandler
match action
{
Action::None => (),
- Action::Quit => return Ok(self.winner()),
+ Action::Quit => return Ok(self.outcome()),
Action::Play(m) => self.play(m),
Action::Revert(i) =>
{
@@ -188,20 +200,26 @@ impl GameHandler
let [board_pane, history_pane] = Layout::horizontal([Constraint::Min(0), Constraint::Length(20)]).spacing(-1).areas(top);
let [current_pane, last_pane] = Layout::horizontal([Constraint::Percentage(70), Constraint::Fill(1)]).spacing(-1).areas(bottom);
- let winner = self.winner();
+ let outcome = self.outcome();
let turn = self.turn();
let player = &*players[match turn { Turn::Sheep => 0, Turn::Tiger => 1 }];
// The bottom row is drawn first, so the top row's bottom titles (keybinds, revert hint)
// land on the shared border line over it. Its own titles go on its bottom border.
// Player output, or the result once the game is over
- match winner
+ match outcome
{
- Some(w) =>
+ Some(o) =>
{
let block = panel().title_bottom(" Game over ");
+ let result = match o
+ {
+ Outcome::Tiger => format!("{} win!", side_name(Turn::Tiger)),
+ Outcome::Sheep => format!("{} win!", side_name(Turn::Sheep)),
+ Outcome::Draw => "Draw by repetition!".to_string(),
+ };
let text = vec![
- Line::styled(format!("{} win!", side_name(w)), Style::new().add_modifier(Modifier::BOLD)),
+ Line::styled(result, Style::new().add_modifier(Modifier::BOLD)),
Line::from("Click a move to revert to it, or press q to quit."),
];
frame.render_widget(Paragraph::new(text).block(block), current_pane);
@@ -247,7 +265,7 @@ impl GameHandler
let view = BoardView
{
board: self.board(),
- highlights: if winner.is_none() { player.highlights() } else { Default::default() },
+ highlights: if outcome.is_none() { player.highlights() } else { Default::default() },
last_move: self.history.last().map(|&(m, _)| m),
};
frame.render_widget(&view, padded);
@@ -318,7 +336,7 @@ mod tests
}
use ratatui::{backend::TestBackend, crossterm::event::{MouseEvent, KeyEvent}, Terminal};
- use crate::{claude_player::ClaudePlayer, local_player::LocalPlayer};
+ use crate::{board::PieceType, claude_player::ClaudePlayer, local_player::LocalPlayer};
fn draw(game: &mut GameHandler, players: &[&mut dyn Player; 2]) -> Terminal<TestBackend>
{
@@ -394,7 +412,7 @@ mod tests
let mut sheep = LocalPlayer::default();
let mut tiger = ClaudePlayer::new(std::time::Duration::from_millis(50));
game.play(Move::PlaceSheep { pos: (2, 2) });
- let m = tiger.pick_move(game.board(), game.turn()).unwrap();
+ let m = tiger.pick_move(game.board(), game.turn(), &[]).unwrap();
game.play(m);
let mut terminal = Terminal::new(TestBackend::new(100, 24)).unwrap();
@@ -419,6 +437,66 @@ mod tests
#[test]
fn no_winner_at_the_start()
{
- assert_eq!(GameHandler::default().winner(), None);
+ assert_eq!(GameHandler::default().outcome(), None);
+ }
+
+ /// Movement phase with 16 sheep: tigers in the corners, and sheep filling rows 1 to 4 except
+ /// c4 and c1, with a sheep at b4 free to step to c4 and the tiger at a5 free to step to b5.
+ fn movement_game() -> GameHandler
+ {
+ let mut start = GameBoard::EMPTY;
+ for pos in [(0, 0), (4, 0), (0, 4), (4, 4)]
+ {
+ start.set(pos, PieceType::Tiger);
+ }
+ for y in 1..5
+ {
+ for x in 0..5
+ {
+ if matches!(start.get((x, y)), PieceType::Empty) && (x, y) != (2, 1) && (x, y) != (2, 4)
+ {
+ start.set((x, y), PieceType::Sheep);
+ }
+ }
+ }
+ assert_eq!((start.sheep_count(), start.sheep_left()), (16, 0));
+ GameHandler { start, ..Default::default() }
+ }
+
+ /// The sheep at b4 and the tiger at a5 step out and back.
+ const SHUFFLE: [Move; 4] = [
+ Move::MoveSheep { from: (1, 1), to: (2, 1) },
+ Move::MoveTiger { from: (0, 0), to: (1, 0) },
+ Move::MoveSheep { from: (2, 1), to: (1, 1) },
+ Move::MoveTiger { from: (1, 0), to: (0, 0) },
+ ];
+
+ #[test]
+ fn third_repetition_is_a_draw()
+ {
+ let mut game = movement_game();
+ for (i, &m) in SHUFFLE.iter().cycle().take(8).enumerate()
+ {
+ assert_eq!(game.outcome(), None, "before move {i}");
+ game.play(m);
+ }
+ // The start position, for the third time
+ assert_eq!(game.board(), game.start);
+ assert_eq!(game.outcome(), Some(Outcome::Draw));
+ assert_eq!(game.previous().len(), 8);
+
+ game.revert(7);
+ assert_eq!(game.outcome(), None);
+ }
+
+ #[test]
+ fn game_over_panel_shows_a_draw()
+ {
+ let mut game = movement_game();
+ SHUFFLE.iter().cycle().take(8).for_each(|&m| game.play(m));
+ let (mut sheep, mut tiger) = (LocalPlayer::default(), LocalPlayer::default());
+ let terminal = draw(&mut game, &[&mut sheep, &mut tiger]);
+ let screen = format!("{:?}", terminal.backend().buffer());
+ assert!(screen.contains("Draw by repetition!"), "{screen}");
}
}
diff --git a/src/main.rs b/src/main.rs
index b6a4ad0..5a3c798 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2,7 +2,7 @@ use std::{path::PathBuf, time::Duration};
use clap::{Args, Parser, Subcommand, ValueEnum};
-use crate::{claude_player::ClaudePlayer, game::GameHandler, local_player::LocalPlayer, network::Network, neural_player::NeuralPlayer, player::Player};
+use crate::{board::Outcome, claude_player::ClaudePlayer, game::GameHandler, local_player::LocalPlayer, network::Network, neural_player::NeuralPlayer, player::Player};
mod board;
mod player;
@@ -218,9 +218,12 @@ fn play(args: PlayArgs) -> std::io::Result<()>
let mut sheep = make_player(args.sheep, time_limit)?;
let mut tiger = make_player(args.tiger, time_limit)?;
let mut game = GameHandler::default();
- if let Some(winner) = game.play_game(sheep.as_mut(), tiger.as_mut())?
+ match game.play_game(sheep.as_mut(), tiger.as_mut())?
{
- println!("{winner:?} wins!");
+ Some(Outcome::Tiger) => println!("Tigers win!"),
+ Some(Outcome::Sheep) => println!("Sheep win!"),
+ Some(Outcome::Draw) => println!("Draw by repetition."),
+ None => (),
}
Ok(())
}
diff --git a/src/neural-player.rs b/src/neural-player.rs
index 3a9c4a0..f7e54c7 100644
--- a/src/neural-player.rs
+++ b/src/neural-player.rs
@@ -59,10 +59,10 @@ mod tests
let mut player = NeuralPlayer::new(Network::random(2), Duration::from_secs(3600), 12);
player.max_depth = 3;
let board = GameBoard::default();
- let m = player.pick_move(board, Turn::Sheep).unwrap();
+ let m = player.pick_move(board, Turn::Sheep, &[]).unwrap();
assert_eq!(board.check_move(m), Ok(()));
let report = player.last_search.expect("a report");
assert_eq!((report.mv, report.depth), (m, 3));
- assert_eq!(report.score, player.minimax(board, Turn::Sheep, 3, f32::NEG_INFINITY, f32::INFINITY, None).unwrap());
+ assert_eq!(report.score, player.minimax(board, Turn::Sheep, 3, f32::NEG_INFINITY, f32::INFINITY, None, &mut Vec::new()).unwrap());
}
}
diff --git a/src/player.rs b/src/player.rs
index 0c58a58..1766d24 100644
--- a/src/player.rs
+++ b/src/player.rs
@@ -2,7 +2,7 @@ use std::{collections::HashMap, time::{Duration, Instant}};
use ratatui::{buffer::Buffer, crossterm::event::KeyEvent, layout::Rect, widgets::{Paragraph, Widget, Wrap}};
-use crate::board::{inverse_symmetry, is_jump, transform_move, GameBoard, Move, Pos, Turn};
+use crate::board::{inverse_symmetry, is_jump, repetitions, transform_move, GameBoard, Move, Pos, Turn};
/// Input the game coordinator passes on to the player whose turn it is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -31,8 +31,9 @@ pub trait Player
fn update(&mut self, _board: GameBoard, _turn: Turn) {}
/// Computer players search here and return Some. Interactive players return None and
- /// wait for `handle_input`.
- fn pick_move(&mut self, _board: GameBoard, _turn: Turn) -> Option<Move> { None }
+ /// wait for `handle_input`. `previous` is the game's boards before this one, oldest first,
+ /// for spotting repetitions.
+ fn pick_move(&mut self, _board: GameBoard, _turn: Turn, _previous: &[GameBoard]) -> Option<Move> { None }
/// A click on a board point, or a key press, while it's this player's turn.
fn handle_input(&mut self, _input: Input, _board: GameBoard, _turn: Turn) -> Option<Move> { None }
@@ -267,6 +268,8 @@ pub struct SearchStats
pub leaves: u64,
/// Positions where `check_win` ended the search.
pub terminal: u64,
+ /// Positions scored as a draw because they repeat an earlier one.
+ pub repetitions: u64,
pub cache_probes: u64,
pub cache_hits: u64,
/// Cache hits whose bounds settled the score without searching.
@@ -313,11 +316,23 @@ impl SearchStats
pub trait MinimaxPlayer
{
const WIN: f32 = f32::INFINITY;
+ /// The score of a position that repeats one on `path`.
+ const DRAW: f32 = 0.0;
/// Scores outside `alpha..beta` are only bounds: at most `alpha`, or at least `beta`.
+ /// `path` is the boards before this one, in the game and then in the search, oldest first.
/// Returns None if `deadline` passed before the search finished.
+ #[allow(clippy::too_many_arguments)]
fn minimax(&mut self, board: GameBoard, turn: Turn, depth: usize,
- mut alpha: ScoreType, mut beta: ScoreType, deadline: Option<Instant>) -> Option<ScoreType> {
+ mut alpha: ScoreType, mut beta: ScoreType, deadline: Option<Instant>, path: &mut Vec<GameBoard>) -> Option<ScoreType> {
+ // A side that can repeat a position once can repeat it until the game is drawn, so the
+ // first repetition already scores as a draw. The cache doesn't know the path, so an entry
+ // that came from a repetition can be slightly wrong for the same board reached another way.
+ if repetitions(path, &board) > 0
+ {
+ count!(repetitions);
+ return Some(Self::DRAW);
+ }
// Symmetric boards are equally good, so they share an entry, under the canonical board.
// Entries' best moves are stored as played on that board, and turned back for this one.
let (canon, sym) = if self.cache().is_some() { board.canonical() } else { (board, 0) };
@@ -346,7 +361,7 @@ pub trait MinimaxPlayer
{
hint = self.cache().and_then(|c| { count!(cache_probes); c.lookup(&canon, turn, depth - 1) }).and_then(|e| e.best).map(back);
}
- let (score, best) = self.minimax_uncached(board, turn, depth, alpha, beta, hint, deadline)?;
+ let (score, best) = self.minimax_uncached(board, turn, depth, alpha, beta, hint, deadline, path)?;
if let Some(c) = self.cache()
{
let best = best.map(|m| transform_move(m, sym));
@@ -376,9 +391,10 @@ pub trait MinimaxPlayer
/// The score and the move that gave it (None at a leaf), trying `hint` first if it is legal.
/// Returns None if `deadline` passed before the search finished.
+ #[allow(clippy::too_many_arguments)]
fn minimax_uncached(&mut self, board: GameBoard, turn: Turn, depth: usize,
mut alpha: ScoreType, mut beta: ScoreType,
- hint: Option<Move>, deadline: Option<Instant>) -> Option<(ScoreType, Option<Move>)> {
+ hint: Option<Move>, deadline: Option<Instant>, path: &mut Vec<GameBoard>) -> Option<(ScoreType, Option<Move>)> {
count!(nodes);
if board.check_win(turn)
{
@@ -423,7 +439,10 @@ pub trait MinimaxPlayer
let mut best: Option<(ScoreType, Move)> = None;
for (_i, m) in moves.into_iter().enumerate()
{
- let score = self.minimax(board.after_move_unchecked(m), next, depth - 1, alpha, beta, deadline)?;
+ path.push(board);
+ let score = self.minimax(board.after_move_unchecked(m), next, depth - 1, alpha, beta, deadline, path);
+ path.pop();
+ let score = score?;
let better = match best
{
None => true,
@@ -500,7 +519,7 @@ impl<P: MinimaxPlayer> Player for P
/// Iterative deepening: searches depth 1, 2, … until time runs out, each depth trying the
/// previous one's best moves first, and plays the best move from the deepest finished search.
- fn pick_move(&mut self, board: GameBoard, turn: Turn) -> Option<Move> {
+ fn pick_move(&mut self, board: GameBoard, turn: Turn, previous: &[GameBoard]) -> Option<Move> {
let start = Instant::now();
let time_limit = self.time_limit();
let deadline = start + time_limit;
@@ -513,6 +532,7 @@ impl<P: MinimaxPlayer> Player for P
{
k.clear();
}
+ let mut path = previous.to_vec();
let mut best = None;
let mut report = None;
for depth in 1..=self.depth().max(1)
@@ -520,7 +540,7 @@ impl<P: MinimaxPlayer> Player for P
// Depth 1 ignores the deadline, so there is always a move to play
let dl = (depth > 1).then_some(deadline);
// Out of time: drop the partial search and keep the last finished one's move
- let Some((score, m)) = self.minimax_uncached(board, turn, depth, f32::NEG_INFINITY, f32::INFINITY, best, dl)
+ let Some((score, m)) = self.minimax_uncached(board, turn, depth, f32::NEG_INFINITY, f32::INFINITY, best, dl, &mut path)
else
{
break;
@@ -650,6 +670,16 @@ mod tests
/// Plain minimax with no pruning or cache, to check the real search against.
fn reference(player: &mut TestPlayer, board: GameBoard, turn: Turn, depth: usize) -> ScoreType
{
+ reference_from(player, board, turn, depth, &mut Vec::new())
+ }
+
+ /// `reference` with `path` the boards before this one, like `minimax`'s.
+ fn reference_from(player: &mut TestPlayer, board: GameBoard, turn: Turn, depth: usize, path: &mut Vec<GameBoard>) -> ScoreType
+ {
+ if repetitions(path, &board) > 0
+ {
+ return TestPlayer::DRAW;
+ }
if board.check_win(turn)
{
return match turn { Turn::Tiger => -TestPlayer::WIN, Turn::Sheep => TestPlayer::WIN };
@@ -658,12 +688,15 @@ mod tests
{
return player.evaluate(&board, turn);
}
- let scores = board.get_moves(turn).into_iter()
- .map(|m| reference(player, board.after_move_unchecked(m), turn.next_turn(), depth - 1));
+ path.push(board);
+ let scores: Vec<ScoreType> = board.get_moves(turn).into_iter()
+ .map(|m| reference_from(player, board.after_move_unchecked(m), turn.next_turn(), depth - 1, path))
+ .collect();
+ path.pop();
match turn
{
- Turn::Tiger => scores.fold(f32::NEG_INFINITY, f32::max),
- Turn::Sheep => scores.fold(f32::INFINITY, f32::min),
+ Turn::Tiger => scores.into_iter().fold(f32::NEG_INFINITY, f32::max),
+ Turn::Sheep => scores.into_iter().fold(f32::INFINITY, f32::min),
}
}
@@ -703,7 +736,7 @@ mod tests
{
let mut player = TestPlayer::new(depth, CacheKind::None);
let expected = reference(&mut player, board, turn, depth);
- let score = player.minimax(board, turn, depth, f32::NEG_INFINITY, f32::INFINITY, None).unwrap();
+ let score = player.minimax(board, turn, depth, f32::NEG_INFINITY, f32::INFINITY, None, &mut Vec::new()).unwrap();
assert_eq!(score, expected, "depth {depth}, {turn:?} to move");
}
}
@@ -722,7 +755,7 @@ mod tests
{
let mut player = TestPlayer::new(depth, kind).with_killers(killers);
let expected = reference(&mut player, board, turn, depth);
- let score = player.minimax(board, turn, depth, f32::NEG_INFINITY, f32::INFINITY, None).unwrap();
+ let score = player.minimax(board, turn, depth, f32::NEG_INFINITY, f32::INFINITY, None, &mut Vec::new()).unwrap();
assert_eq!(score, expected, "depth {depth}, {turn:?} to move, {kind:?}, killers {killers}");
}
}
@@ -742,7 +775,7 @@ mod tests
for kind in CACHED
{
let mut player = TestPlayer::new(3, kind).with_killers(true);
- let (score, _) = player.minimax_uncached(board, turn, 3, f32::NEG_INFINITY, f32::INFINITY, Some(hint), None).unwrap();
+ let (score, _) = player.minimax_uncached(board, turn, 3, f32::NEG_INFINITY, f32::INFINITY, Some(hint), None, &mut Vec::new()).unwrap();
assert_eq!(score, expected, "{turn:?} to move, hint {hint:?}, {kind:?}");
}
}
@@ -756,7 +789,7 @@ mod tests
{
let mut player = TestPlayer::new(3, kind);
let past = Instant::now();
- assert_eq!(player.minimax(GameBoard::default(), Turn::Sheep, 3, f32::NEG_INFINITY, f32::INFINITY, Some(past)), None);
+ assert_eq!(player.minimax(GameBoard::default(), Turn::Sheep, 3, f32::NEG_INFINITY, f32::INFINITY, Some(past), &mut Vec::new()), None);
// The root gives up before searching any move, so its entry is the only one there could be
assert_eq!(player.cache.unwrap().lookup(&GameBoard::default(), Turn::Sheep, 3), None, "{kind:?}");
}
@@ -769,7 +802,7 @@ mod tests
player.time_limit = Duration::from_millis(50);
let board = GameBoard::default();
let start = Instant::now();
- let m = player.pick_move(board, Turn::Sheep).unwrap();
+ let m = player.pick_move(board, Turn::Sheep, &[]).unwrap();
assert!(start.elapsed() < Duration::from_millis(500), "took {:?}", start.elapsed());
assert_eq!(board.check_move(m), Ok(()));
}
@@ -785,7 +818,7 @@ mod tests
{
let mut player = TestPlayer::new(4, kind).with_killers(killers);
let best = reference(&mut player, board, turn, 4);
- let m = player.pick_move(board, turn).unwrap();
+ let m = player.pick_move(board, turn, &[]).unwrap();
let score = reference(&mut player, board.after_move_unchecked(m), turn.next_turn(), 3);
assert_eq!(score, best, "{turn:?} picked {m:?} ({kind:?}, killers {killers})");
}
@@ -793,6 +826,65 @@ mod tests
}
}
+ /// A movement-phase position with `turn` to move and at least one sheep dead, so the
+ /// evaluation is well above the draw score, and at least two moves.
+ fn sheep_down_position(turn: Turn) -> (GameBoard, Turn)
+ {
+ (41..80).flat_map(|plies| (1..20).filter_map(move |seed| position(plies, seed)))
+ .find(|&(board, t)| t == turn && board.sheep_left() == 0 && board.sheep_count() < 20
+ && board.get_moves(t).len() >= 2)
+ .expect("a position with a sheep down")
+ }
+
+ /// A history in which the board after `m` came up before, with the same side to move:
+ /// that board, then `board`, then that board again.
+ fn history_repeating(board: GameBoard, m: Move) -> Vec<GameBoard>
+ {
+ let after = board.after_move_unchecked(m);
+ vec![after, board, after]
+ }
+
+ #[test]
+ fn search_scores_a_repeated_position_as_a_draw()
+ {
+ let (board, turn) = sheep_down_position(Turn::Tiger);
+ let m = board.get_moves(turn)[0];
+ let mut path = history_repeating(board, m);
+ path.push(board);
+ let mut player = TestPlayer::new(3, CacheKind::None);
+ assert_eq!(player.minimax(board.after_move_unchecked(m), turn.next_turn(), 3,
+ f32::NEG_INFINITY, f32::INFINITY, None, &mut path), Some(TestPlayer::DRAW));
+ assert_eq!(path.len(), 4, "the search leaves the path as it found it");
+ }
+
+ #[test]
+ fn tiger_ahead_avoids_a_repetition()
+ {
+ let (board, turn) = sheep_down_position(Turn::Tiger);
+ for kind in ALL_CACHES
+ {
+ let m = TestPlayer::new(3, kind).pick_move(board, turn, &[]).unwrap();
+ assert!(reference(&mut TestPlayer::new(3, kind), board.after_move_unchecked(m), turn.next_turn(), 2) > TestPlayer::DRAW);
+ let previous = history_repeating(board, m);
+ let again = TestPlayer::new(3, kind).pick_move(board, turn, &previous).unwrap();
+ assert_ne!(again, m, "{kind:?}");
+ }
+ }
+
+ #[test]
+ fn sheep_behind_takes_a_repetition()
+ {
+ let (board, turn) = sheep_down_position(Turn::Sheep);
+ for kind in ALL_CACHES
+ {
+ let best = TestPlayer::new(3, kind).pick_move(board, turn, &[]).unwrap();
+ assert!(reference(&mut TestPlayer::new(3, kind), board.after_move_unchecked(best), turn.next_turn(), 2) > TestPlayer::DRAW);
+ let m = *board.get_moves(turn).iter().find(|&&m| m != best).unwrap();
+ let previous = history_repeating(board, m);
+ assert_eq!(TestPlayer::new(3, kind).pick_move(board, turn, &previous), Some(m), "{kind:?}");
+ }
+ }
+
#[test]
fn tiger_takes_the_winning_kill()
{
@@ -819,7 +911,7 @@ mod tests
for kind in ALL_CACHES
{
- let m = TestPlayer::new(3, kind).pick_move(board, Turn::Tiger).unwrap();
+ let m = TestPlayer::new(3, kind).pick_move(board, Turn::Tiger, &[]).unwrap();
assert!(board.after_move_unchecked(m).check_win(Turn::Sheep), "picked {m:?} ({kind:?})");
}
}
diff --git a/src/train.rs b/src/train.rs
index e6ed822..1e68080 100644
--- a/src/train.rs
+++ b/src/train.rs
@@ -1,6 +1,6 @@
use std::{collections::VecDeque, fs, path::{Path, PathBuf}, sync::atomic::{AtomicUsize, Ordering}, time::{Duration, Instant}};
-use crate::{board::{GameBoard, Move, Turn, SYMMETRIES}, claude_player::ClaudePlayer, network::{Adam, Network, Rng, Sample}, neural_player::NeuralPlayer, player::{MinimaxPlayer, Player}};
+use crate::{board::{repetitions, GameBoard, Move, Outcome, Turn, REPETITION_DRAW, SYMMETRIES}, claude_player::ClaudePlayer, network::{Adam, Network, Rng, Sample}, neural_player::NeuralPlayer, player::{MinimaxPlayer, Player}};
/// Settings for `run`.
#[derive(Clone, Debug)]
@@ -58,25 +58,6 @@ impl Default for Config
}
}
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub enum Outcome
-{
- Tiger,
- Sheep,
- Draw,
-}
-
-impl Outcome
-{
- const ALL: [Outcome; 3] = [Outcome::Tiger, Outcome::Sheep, Outcome::Draw];
-
- /// +1 for a tiger win, -1 for a sheep win, matching the sign of the scores.
- fn value(self) -> f32
- {
- match self { Outcome::Tiger => 1.0, Outcome::Sheep => -1.0, Outcome::Draw => 0.0 }
- }
-}
-
/// A position from a game, with the searching player's score for it (clamped to -1..1).
#[derive(Clone, Copy, Debug)]
struct Ply
@@ -88,24 +69,29 @@ struct Ply
random: bool,
}
-/// Plays one game from the start. `choose` gives the move and score for a position; for the
-/// first `random_plies` plies, and with chance `epsilon` after, a random move is played instead
-/// (the position keeps `choose`'s score, and is marked `random` unless the two moves are the same).
-fn play(mut choose: impl FnMut(GameBoard, Turn) -> (Move, f32), ply_cap: usize, random_plies: usize, epsilon: f32, rng: &mut Rng)
+/// Plays one game from the start. `choose` gives the move and score for a position, given the
+/// boards before it; for the first `random_plies` plies, and with chance `epsilon` after, a random
+/// move is played instead (the position keeps `choose`'s score, and is marked `random` unless the
+/// two moves are the same). A threefold repetition, or reaching `ply_cap`, is a draw.
+fn play(mut choose: impl FnMut(GameBoard, Turn, &[GameBoard]) -> (Move, f32), ply_cap: usize, random_plies: usize, epsilon: f32, rng: &mut Rng)
-> (Outcome, Vec<Ply>)
{
let mut board = GameBoard::default();
let mut turn = Turn::Sheep;
let mut plies = Vec::new();
+ let mut previous = Vec::new();
while plies.len() < ply_cap
{
if board.check_win(turn)
{
// The side that just moved has won
- let winner = match turn { Turn::Sheep => Outcome::Tiger, Turn::Tiger => Outcome::Sheep };
- return (winner, plies);
+ return (Outcome::win(turn.next_turn()), plies);
+ }
+ if repetitions(&previous, &board) >= REPETITION_DRAW
+ {
+ return (Outcome::Draw, plies);
}
- let (searched, score) = choose(board, turn);
+ let (searched, score) = choose(board, turn, &previous);
let mut m = searched;
if plies.len() < random_plies || rng.next_f32() < epsilon
{
@@ -113,6 +99,7 @@ fn play(mut choose: impl FnMut(GameBoard, Turn) -> (Move, f32), ply_cap: usize,
m = moves[rng.below(moves.len())];
}
plies.push(Ply { board, turn, score: score.clamp(-1.0, 1.0), random: m != searched });
+ previous.push(board);
board.make_move_unchecked(m);
turn = turn.next_turn();
}
@@ -180,9 +167,9 @@ fn claude(depth: usize) -> ClaudePlayer
}
/// The move a minimax player picks, and the score it gave it.
-fn search(player: &mut (impl MinimaxPlayer + Player), board: GameBoard, turn: Turn) -> (Move, f32)
+fn search(player: &mut (impl MinimaxPlayer + Player), board: GameBoard, turn: Turn, previous: &[GameBoard]) -> (Move, f32)
{
- let m = player.pick_move(board, turn).expect("the game is not over, so there is a move");
+ let m = player.pick_move(board, turn, previous).expect("the game is not over, so there is a move");
(m, player.last_search().map_or(0.0, |r| r.score))
}
@@ -191,7 +178,7 @@ fn self_play(net: &Network, cfg: &Config, seed: u64) -> (Outcome, Vec<Sample>)
{
let mut player = neural(net, cfg.depth);
let mut rng = Rng::new(seed);
- let (outcome, plies) = play(|b, t| search(&mut player, b, t), cfg.ply_cap, cfg.random_plies, cfg.epsilon, &mut rng);
+ let (outcome, plies) = play(|b, t, p| search(&mut player, b, t, p), cfg.ply_cap, cfg.random_plies, cfg.epsilon, &mut rng);
let targets = td_targets(&plies, outcome, cfg.lambda);
let samples = plies.iter().zip(targets).map(|(p, target)| Sample { board: p.board, turn: p.turn, target }).collect();
(outcome, samples)
@@ -208,7 +195,7 @@ fn match_score<P: MinimaxPlayer + Player>(net: &Network, opponent: impl Fn() ->
let mut them = opponent();
let neural_side = if i % 2 == 0 { Turn::Sheep } else { Turn::Tiger };
let mut rng = Rng::new(seed ^ (i / 2) as u64);
- let (outcome, _) = play(|b, t| if t == neural_side { search(&mut me, b, t) } else { search(&mut them, b, t) },
+ let (outcome, _) = play(|b, t, p| if t == neural_side { search(&mut me, b, t, p) } else { search(&mut them, b, t, p) },
cfg.ply_cap, 4, 0.0, &mut rng);
match (outcome, neural_side)
{
@@ -389,9 +376,11 @@ mod tests
assert!(w[0].board.get_moves(w[0].turn).iter().any(|&m| w[0].board.after_move_unchecked(m) == w[1].board));
}
assert!(samples.iter().all(|s| (-1.0..=1.0).contains(&s.target)));
+ // A draw is the ply cap, or a position coming up a third time (the last time isn't a sample)
+ let repeated = samples.iter().any(|s| samples.iter().filter(|o| o.board == s.board && o.turn == s.turn).count() >= 2);
if outcome == Outcome::Draw
{
- assert_eq!(samples.len(), cfg.ply_cap);
+ assert!(samples.len() == cfg.ply_cap || repeated);
}
}