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, 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)] pub enum Input { /// A click on a board point. Click(Pos), Key(KeyEvent), } /// Points for the coordinator to highlight on the board. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Highlights { pub selected: Option, pub targets: Vec, } /// A side in the game. The coordinator calls `pick_move` once per loop while it's this player's /// turn, and passes on input with `handle_input`; whichever returns a move first plays it. pub trait Player { fn name(&self) -> String; /// Called before each draw while it's this player's turn, with the position to play. fn update(&mut self, _board: GameBoard, _turn: Turn) {} /// Computer players search here and return Some. Interactive players return None and /// 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 { 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 { None } /// Points for the coordinator to highlight on the board. fn highlights(&self) -> Highlights { Highlights::default() } /// The game was reverted: drop any half-made selection, and anything said about the last move. fn reset(&mut self) {} /// Draw into the player-output panel while it's this player's turn. By default this shows the player's name. fn render(&self, area: Rect, buf: &mut Buffer) { Paragraph::new(self.name()).render(area, buf); } /// Draw some end-of-turn comments on the move this player just made, shown beside the other /// player's panel during their turn. By default this shows nothing. fn render_last_turn(&self, _area: Rect, _buf: &mut Buffer) {} } /// Every player is a ratatui widget through `Player::render`. /// (A blanket `impl Widget for &P` would break the orphan rule.) impl Widget for &dyn Player { fn render(self, area: Rect, buf: &mut Buffer) { Player::render(self, area, buf); } } pub type ScoreType = f32; /// What a `MinimaxPlayer`'s last `pick_move` found. #[derive(Clone, Copy, Debug, PartialEq)] pub struct SearchReport { pub mv: Move, /// The deepest search that finished, in plies counting the move itself. pub depth: usize, /// The score of `mv` at `depth`; positive favours tigers, and ±`WIN` is a forced win. pub score: ScoreType, } /// What a search has proved about a board's true score: it lies in `lower..=upper`. /// An alpha-beta cutoff only proves one side, so the other is left infinite. /// `best` is the move that gave the score, tried first when the position is searched again deeper. #[derive(Clone, Copy, Debug, PartialEq)] pub struct CacheEntry { pub lower: ScoreType, pub upper: ScoreType, pub best: Option, } /// Score cache for a `MinimaxPlayer`. No defaults, so an implementor must supply all of it. /// `minimax` looks boards up by `GameBoard::canonical`, so symmetric boards share an entry. Entries are keyed by the side to move and the remaining search depth as well as the board, /// since the same board can be reached with either side to move, or at different depths. pub trait ScoreCache { fn lookup(&mut self, board: &GameBoard, turn: Turn, depth: usize) -> Option; fn insert(&mut self, board: &GameBoard, turn: Turn, depth: usize, entry: CacheEntry); fn clear(&mut self); } impl ScoreCache for HashMap<(GameBoard, Turn, usize), CacheEntry> { fn lookup(&mut self, board: &GameBoard, turn: Turn, depth: usize) -> Option { self.get(&(*board, turn, depth)).copied() } fn insert(&mut self, board: &GameBoard, turn: Turn, depth: usize, entry: CacheEntry) { self.insert((*board, turn, depth), entry); } fn clear(&mut self) { self.clear(); } } /// A fixed-size score cache. Each position hashes to a bucket of two slots: the first keeps /// whichever entry was searched deepest, so the many shallow entries near the leaves can't push /// it out, and the second takes everything else. /// Slots keep their full key, so a collision loses an entry but never returns the wrong one. /// It is allocated once, so unlike a `HashMap` it never grows or rehashes during a search. pub struct TranspositionTable { slots: Vec, /// Bits of the slot index, so there are `1 << bits` slots in `1 << (bits - 1)` buckets. bits: u32, /// Slots from an older generation count as empty, which makes `clear` cheap. /// 0 is never current, so `EMPTY_SLOT`s start out empty. generation: u8, } #[derive(Clone, Copy)] struct Slot { board: GameBoard, turn: Turn, depth: u8, generation: u8, entry: CacheEntry, } const EMPTY_SLOT: Slot = Slot { board: GameBoard::EMPTY, turn: Turn::Sheep, depth: 0, generation: 0, entry: CacheEntry { lower: f32::NEG_INFINITY, upper: f32::INFINITY, best: None }, }; impl Slot { fn holds(&self, board: &GameBoard, turn: Turn, depth: usize, generation: u8) -> bool { self.generation == generation && self.board == *board && self.turn == turn && self.depth as usize == depth } } impl TranspositionTable { /// A table of `1 << bits` slots (about 24 bytes each). pub fn new(bits: u32) -> Self { assert!(bits >= 1, "the table needs at least one bucket of two slots"); Self { slots: vec![EMPTY_SLOT; 1 << bits], bits, generation: 1 } } /// The index of the first slot of the position's bucket. fn bucket(&self, board: &GameBoard, turn: Turn, depth: usize) -> usize { let key = board.key() ^ (depth as u64) << 1 ^ matches!(turn, Turn::Sheep) as u64; ((key.wrapping_mul(0x9E3779B97F4A7C15) >> (65 - self.bits)) as usize) << 1 } } impl ScoreCache for TranspositionTable { fn lookup(&mut self, board: &GameBoard, turn: Turn, depth: usize) -> Option { let b = self.bucket(board, turn, depth); self.slots[b..b + 2].iter().find(|s| s.holds(board, turn, depth, self.generation)).map(|s| s.entry) } fn insert(&mut self, board: &GameBoard, turn: Turn, depth: usize, entry: CacheEntry) { let generation = self.generation; let b = self.bucket(board, turn, depth); let [deep, any] = &mut self.slots[b..b + 2] else { unreachable!() }; let slot = if deep.holds(board, turn, depth, generation) || any.holds(board, turn, depth, generation) { // Update the position's own entry, wherever it is if deep.holds(board, turn, depth, generation) { deep } else { any } } else if deep.generation != generation || depth >= deep.depth as usize { // The deep slot's entry moves down rather than being lost *any = *deep; deep } else { any }; #[cfg(feature = "search-stats")] if slot.generation == generation && !slot.holds(board, turn, depth, generation) { count!(cache_overwrites); } let depth = u8::try_from(depth).expect("search depth fits in a u8"); *slot = Slot { board: *board, turn, depth, generation, entry }; } fn clear(&mut self) { self.generation = self.generation.wrapping_add(1); // After 255 clears the oldest slots' generation would come round again, so empty them for real if self.generation == 0 { self.slots.fill(EMPTY_SLOT); self.generation = 1; } } } /// The deepest search a `MinimaxPlayer` may do, in plies. pub const MAX_DEPTH: usize = 64; /// Killer moves: for each remaining search depth, the last two quiet (non-jump) moves that cut a /// search off there. Within one pass of iterative deepening a remaining depth is a ply from the /// root, and a move that refuted one position at a ply often refutes its siblings too. #[derive(Clone, Debug)] pub struct Killers([[Option; 2]; MAX_DEPTH + 1]); impl Default for Killers { fn default() -> Self { Self([[None; 2]; MAX_DEPTH + 1]) } } impl Killers { pub fn clear(&mut self) { *self = Self::default(); } fn get(&self, depth: usize) -> [Option; 2] { self.0.get(depth).copied().unwrap_or_default() } fn add(&mut self, depth: usize, m: Move) { if let Some(k) = self.0.get_mut(depth) { if k[0] != Some(m) { *k = [Some(m), k[0]]; } } } } /// Counts of what the search did, for profiling. They are only counted with the `search-stats` /// feature; without it `count!` compiles to nothing and `SearchStats::take` returns zeros. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct SearchStats { /// Calls to `minimax_uncached`, i.e. positions searched rather than answered from the cache. pub nodes: u64, /// Positions scored with `evaluate`. 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. pub cache_returns: u64, /// Cache entries lost because another position's entry took their slot. pub cache_overwrites: u64, /// Searches stopped early because alpha met beta. pub cutoffs: u64, /// Cutoffs on the first move tried; a high share means the moves are well ordered. pub first_move_cutoffs: u64, /// Calls to `GameBoard::get_moves`, from the search, `check_win` and `evaluate` alike. pub move_gens: u64, } #[cfg(feature = "search-stats")] thread_local! { pub(crate) static STATS: std::cell::RefCell = std::cell::RefCell::new(SearchStats::default()); } /// Add one to a `SearchStats` field, if the `search-stats` feature is on. macro_rules! count { ($field:ident) => { #[cfg(feature = "search-stats")] crate::player::STATS.with_borrow_mut(|s| s.$field += 1); }; } pub(crate) use count; impl SearchStats { /// The counts so far on this thread, resetting them to zero. pub fn take() -> Self { #[cfg(feature = "search-stats")] return STATS.with_borrow_mut(std::mem::take); #[cfg(not(feature = "search-stats"))] Self::default() } } 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, path: &mut Vec) -> Option { // 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) }; let back = |m: Move| transform_move(m, inverse_symmetry(sym)); let known = self.cache().and_then(|c| { count!(cache_probes); c.lookup(&canon, turn, depth) }); let mut hint = None; if let Some(k) = known { count!(cache_hits); if k.lower >= beta { count!(cache_returns); return Some(k.lower); } if k.upper <= alpha || k.lower == k.upper { count!(cache_returns); return Some(k.upper); } alpha = alpha.max(k.lower); beta = beta.min(k.upper); hint = k.best.map(back); } // Otherwise try the best move from the previous, one-ply-shallower iteration first if hint.is_none() && depth > 0 { 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, path)?; if let Some(c) = self.cache() { let best = best.map(|m| transform_move(m, sym)); // On a fail-low every move was only bounded, so none is known to be best let mut entry = if score <= alpha { CacheEntry { lower: f32::NEG_INFINITY, upper: score, best: None } } else if score >= beta { CacheEntry { lower: score, upper: f32::INFINITY, best } } else { CacheEntry { lower: score, upper: score, best } }; if let Some(k) = known { entry.lower = entry.lower.max(k.lower); entry.upper = entry.upper.min(k.upper); entry.best = entry.best.or(k.best); } c.insert(&canon, turn, depth, entry); } Some(score) } /// 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, deadline: Option, path: &mut Vec) -> Option<(ScoreType, Option)> { count!(nodes); if board.check_win(turn) { count!(terminal); let score = match turn { Turn::Tiger => -Self::WIN, Turn::Sheep => Self::WIN, }; return Some((score, None)); } if depth == 0 { count!(leaves); return Some((self.evaluate(&board, turn), None)); } if deadline.is_some_and(|d| Instant::now() >= d) { return None; } // Try the cache's best move first, then jumps (which `get_moves` lists first), then killer // moves, then the rest in generated order. Rotating keeps that cheaper than sorting. let mut moves = board.get_moves(turn); if let Some(killers) = self.killers().map(|k| k.get(depth)) { let quiet = moves.iter().take_while(|&&m| is_jump(m)).count(); // The older killer goes first, so the newer one ends up in front of it for k in killers.into_iter().rev().flatten() { if let Some(i) = moves[quiet..].iter().position(|&m| m == k) { moves[quiet..=quiet + i].rotate_right(1); } } } if let Some(i) = hint.and_then(|h| moves.iter().position(|&m| m == h)) { moves[..=i].rotate_right(1); } let next = turn.next_turn(); // Keep the first move on ties, so a line of all-losing (-inf) moves still picks one let mut best: Option<(ScoreType, Move)> = None; for (_i, m) in moves.into_iter().enumerate() { 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, Some((best_score, _)) => match turn { Turn::Tiger => score > best_score, // Maximising player Turn::Sheep => score < best_score, // Minimising player }, }; if better { best = Some((score, m)); match turn { Turn::Tiger => alpha = alpha.max(score), Turn::Sheep => beta = beta.min(score), } if alpha >= beta { count!(cutoffs); #[cfg(feature = "search-stats")] if _i == 0 { count!(first_move_cutoffs); } if !is_jump(m) { if let Some(k) = self.killers() { k.add(depth, m); } } break; } } } let (score, m) = best.expect("check_win is false, so there is a legal move"); Some((score, Some(m))) } /// Override to return a cache to enable caching; the default is no cache. fn cache(&mut self) -> Option<&mut dyn ScoreCache> { None } /// Override to return storage for killer moves, to try them earlier; the default is none. fn killers(&mut self) -> Option<&mut Killers> { None } /// More positive for tiger winning, less positive for sheep winning, with `turn` to move next fn evaluate(&mut self, board: &GameBoard, turn: Turn) -> ScoreType; /// The most plies `pick_move` searches, counting its own move. Normally the time limit stops it first. fn depth(&self) -> usize { MAX_DEPTH } /// How long `pick_move` may spend searching. fn time_limit(&self) -> Duration { Duration::from_secs(1) } fn name(&self) -> String { "Computer".to_string() } /// Override with `last_search` to keep each search's report for the last-turn panel. /// By default it is dropped. fn set_last_search(&mut self, _report: Option) {} fn last_search(&self) -> Option { None } } impl Player for P { fn name(&self) -> String { MinimaxPlayer::name(self) } /// 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, previous: &[GameBoard]) -> Option { let start = Instant::now(); let time_limit = self.time_limit(); let deadline = start + time_limit; // Entries would stay valid across moves (the key includes depth), but clear to bound memory if let Some(c) = self.cache() { c.clear(); } if let Some(k) = self.killers() { k.clear(); } let mut path = previous.to_vec(); let mut best = None; let mut report = None; for depth in 1..=self.depth().max(1) { // 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, &mut path) else { break; }; best = m; report = m.map(|mv| SearchReport { mv, depth, score }); // A forced win or loss won't change with more depth, // and past half the budget the next depth probably won't finish if score.abs() >= Self::WIN || start.elapsed() > time_limit / 2 { break; } } self.set_last_search(report); Some(best.expect("pick_move called with no legal moves")) } fn reset(&mut self) { self.set_last_search(None); } fn render(&self, area: Rect, buf: &mut Buffer) { Paragraph::new(format!("{}\n\nThinking…", MinimaxPlayer::name(self))).render(area, buf); } fn render_last_turn(&self, area: Rect, buf: &mut Buffer) { let Some(r) = self.last_search() else { return }; let score = if r.score >= Self::WIN { "forced win for tigers".to_string() } else if r.score <= -Self::WIN { "forced win for sheep".to_string() } else { format!("{} (+ favours tigers)", significant(r.score, 5)) }; Paragraph::new(format!("Played {}\nSearched to depth {}\nScore: {score}", r.mv, r.depth)) .wrap(Wrap { trim: true }).render(area, buf); } } /// `x` to `digits` significant figures, with its sign: 1234.5678 to 5 is "+1234.6", 0.012345678 /// is "+0.012346", and 1234567 is "+1234600". fn significant(x: f32, digits: usize) -> String { if x == 0.0 { return format!("{:.*}", digits.saturating_sub(1), 0.0); } // The power of ten of the first significant digit let magnitude = (x.abs() as f64).log10().floor() as i32; let decimals = digits as i32 - 1 - magnitude; if decimals >= 0 { // Rounding can carry into a new digit (9.99999 to 10.000), which needs one decimal fewer let rounded = format!("{:+.*}", decimals as usize, x); let carried = rounded.trim_start_matches(['+', '-']).trim_start_matches(['0', '.']).chars().filter(char::is_ascii_digit).count() > digits; if carried && decimals > 0 { format!("{:+.*}", decimals as usize - 1, x) } else { rounded } } else { let unit = 10f64.powi(-decimals); format!("{:+.0}", (x as f64 / unit).round() * unit) } } #[cfg(test)] mod tests { use super::*; use crate::board::PieceType; #[test] fn significant_keeps_five_figures() { assert_eq!(significant(1234.5678, 5), "+1234.6"); assert_eq!(significant(-212.0, 5), "-212.00"); assert_eq!(significant(0.012345678, 5), "+0.012346"); assert_eq!(significant(-0.5, 5), "-0.50000"); assert_eq!(significant(1234567.0, 5), "+1234600"); assert_eq!(significant(9.999999, 5), "+10.000"); assert_eq!(significant(0.0, 5), "0.0000"); } /// A minimax player with a cheap evaluation that still varies a lot between positions. struct TestPlayer { depth: usize, time_limit: Duration, cache: Option>, killers: Option, } /// Which cache a `TestPlayer` searches with. #[derive(Clone, Copy, Debug)] enum CacheKind { None, Map, /// A table so small that slots are overwritten all the time. TinyTable, } const CACHED: [CacheKind; 2] = [CacheKind::Map, CacheKind::TinyTable]; const ALL_CACHES: [CacheKind; 3] = [CacheKind::None, CacheKind::Map, CacheKind::TinyTable]; impl TestPlayer { fn new(depth: usize, cache: CacheKind) -> Self { let cache: Option> = match cache { CacheKind::None => None, CacheKind::Map => Some(Box::new(HashMap::<(GameBoard, Turn, usize), CacheEntry>::new())), CacheKind::TinyTable => Some(Box::new(TranspositionTable::new(3))), }; // Long enough that only the depth cap stops a search, so results are deterministic Self { depth, time_limit: Duration::from_secs(3600), cache, killers: None } } /// Also order moves with killer moves if `on`. fn with_killers(mut self, on: bool) -> Self { self.killers = on.then(Killers::default); self } } impl MinimaxPlayer for TestPlayer { fn evaluate(&mut self, board: &GameBoard, _turn: Turn) -> ScoreType { let dead = 20 - board.sheep_count() - board.sheep_left(); 100.0 * dead as f32 + board.get_moves(Turn::Tiger).len() as f32 - board.get_moves(Turn::Sheep).len() as f32 / 2.0 } fn cache(&mut self) -> Option<&mut dyn ScoreCache> { match &mut self.cache { Some(c) => Some(c.as_mut()), None => None, } } fn killers(&mut self) -> Option<&mut Killers> { self.killers.as_mut() } fn depth(&self) -> usize { self.depth } fn time_limit(&self) -> Duration { self.time_limit } } /// 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) -> 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 }; } if depth == 0 { return player.evaluate(&board, turn); } path.push(board); let scores: Vec = 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.into_iter().fold(f32::NEG_INFINITY, f32::max), Turn::Sheep => scores.into_iter().fold(f32::INFINITY, f32::min), } } /// Positions reached by playing `plies` pseudo-random moves from the start, or None if the game ended. fn position(plies: usize, seed: u64) -> Option<(GameBoard, Turn)> { let mut board = GameBoard::default(); let mut turn = Turn::Sheep; let mut rng = seed; for _ in 0..plies { if board.check_win(turn) { return None; } let moves = board.get_moves(turn); rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); board.make_move_unchecked(moves[(rng >> 33) as usize % moves.len()]); turn = turn.next_turn(); } (!board.check_win(turn)).then_some((board, turn)) } fn test_positions() -> Vec<(GameBoard, Turn)> { [0, 1, 5, 12, 25, 41, 50, 60].iter() .flat_map(|&plies| (1..4).filter_map(move |seed| position(plies, seed))) .collect() } #[test] fn alpha_beta_matches_plain_minimax() { for (board, turn) in test_positions() { for depth in 1..=3 { 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, &mut Vec::new()).unwrap(); assert_eq!(score, expected, "depth {depth}, {turn:?} to move"); } } } #[test] fn cached_alpha_beta_matches_plain_minimax() { for (board, turn) in test_positions() { for depth in 1..=3 { for kind in CACHED { for killers in [false, true] { 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, &mut Vec::new()).unwrap(); assert_eq!(score, expected, "depth {depth}, {turn:?} to move, {kind:?}, killers {killers}"); } } } } } #[test] fn any_hint_gives_the_same_score() { for (board, turn) in test_positions() { let mut player = TestPlayer::new(3, CacheKind::None); let expected = reference(&mut player, board, turn, 3); for hint in board.get_moves(turn) { 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, &mut Vec::new()).unwrap(); assert_eq!(score, expected, "{turn:?} to move, hint {hint:?}, {kind:?}"); } } } } #[test] fn aborted_search_is_not_cached() { for kind in CACHED { 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), &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:?}"); } } #[test] fn pick_move_respects_time_limit() { let mut player = TestPlayer::new(64, CacheKind::TinyTable); player.time_limit = Duration::from_millis(50); let board = GameBoard::default(); let start = Instant::now(); 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(())); } #[test] fn pick_move_picks_a_best_move() { for (board, turn) in test_positions() { for kind in ALL_CACHES { for killers in [false, true] { 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 score = reference(&mut player, board.after_move_unchecked(m), turn.next_turn(), 3); assert_eq!(score, best, "{turn:?} picked {m:?} ({kind:?}, killers {killers})"); } } } } /// 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 { 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() { // 16 sheep placed, 4 of them then removed as if killed, so one more kill wins. // (1, 0) is left next to the tiger at (0, 0) with (2, 0) empty behind it. let mut board = GameBoard::default(); let mut placed = Vec::new(); for y in 0..5 { for x in 0..5 { if placed.len() < 16 && (x, y) != (2, 0) && matches!(board.get((x, y)), PieceType::Empty) { board.make_move(Move::PlaceSheep { pos: (x, y) }).unwrap(); placed.push((x, y)); } } } for &pos in &placed[12..] { board.set(pos, PieceType::Empty); } assert_eq!(board.sheep_count() + board.sheep_left(), 16); for kind in ALL_CACHES { 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:?})"); } } #[test] fn table_keeps_entries_apart_and_forgets_them_on_clear() { let entry = CacheEntry { lower: 1.0, upper: 2.0, best: None }; let board = GameBoard::default(); let mut table = TranspositionTable::new(3); table.insert(&board, Turn::Sheep, 3, entry); assert_eq!(table.lookup(&board, Turn::Sheep, 3), Some(entry)); assert_eq!(table.lookup(&board, Turn::Tiger, 3), None); assert_eq!(table.lookup(&board, Turn::Sheep, 2), None); table.clear(); assert_eq!(table.lookup(&board, Turn::Sheep, 3), None); // Enough clears to wrap the generation round to the one the entry was stored in table.insert(&board, Turn::Sheep, 3, entry); for _ in 0..256 { table.clear(); } assert_eq!(table.lookup(&board, Turn::Sheep, 3), None); } #[test] fn killers_keep_the_last_two_different_moves() { let a = Move::PlaceSheep { pos: (0, 1) }; let b = Move::PlaceSheep { pos: (0, 2) }; let c = Move::PlaceSheep { pos: (0, 3) }; let mut killers = Killers::default(); killers.add(2, a); killers.add(2, a); assert_eq!(killers.get(2), [Some(a), None]); killers.add(2, b); killers.add(2, c); assert_eq!(killers.get(2), [Some(c), Some(b)]); assert_eq!(killers.get(1), [None, None]); // Depths past the table are ignored rather than panicking killers.add(MAX_DEPTH + 1, a); assert_eq!(killers.get(MAX_DEPTH + 1), [None, None]); killers.clear(); assert_eq!(killers.get(2), [None, None]); } }