aboutsummaryrefslogtreecommitdiff
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<Pos>,
	pub targets: Vec<Pos>,
}

/// 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<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 }

	/// 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<P: Player> 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<Move>,
}

/// 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<CacheEntry>;
	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<CacheEntry> {
        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<Slot>,
	/// 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<CacheEntry>
	{
		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<Move>; 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<Move>; 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<SearchStats> = 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<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) };
		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<Move>, deadline: Option<Instant>, path: &mut Vec<GameBoard>) -> Option<(ScoreType, Option<Move>)> {
		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<SearchReport>) {}

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

impl<P: MinimaxPlayer> 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<Move> {
		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<Box<dyn ScoreCache>>,
		killers: Option<Killers>,
	}

	/// 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<Box<dyn ScoreCache>> = 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<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 };
		}
		if depth == 0
		{
			return player.evaluate(&board, turn);
		}
		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.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<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()
	{
		// 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]);
	}
}