use turnbase::{Game, PlayerId, Reversible};
use crate::{Bot, RankedBot};
pub struct Minimax {
max_depth: u32,
}
impl Minimax {
#[must_use]
pub const fn new(max_depth: u32) -> Self {
Self { max_depth }
}
pub fn best_action<G>(&self, game: &G, state: &G::State, player: PlayerId) -> Option<G::Action>
where
G: Game,
G::State: Clone,
G::Action: Clone,
{
let mut best = None;
let mut best_value = f64::NEG_INFINITY;
let mut alpha = f64::NEG_INFINITY;
for action in game.legal_actions(state, player) {
let mut child = state.clone();
game.apply(&mut child, player, action.clone());
let value = value_clone(game, &child, player, alpha, f64::INFINITY, self.max_depth);
if value > best_value {
best_value = value;
best = Some(action);
}
alpha = alpha.max(best_value);
}
best
}
pub fn best_action_unmake<G>(
&self,
game: &G,
state: &mut G::State,
player: PlayerId,
) -> Option<G::Action>
where
G: Reversible,
G::Action: Clone,
{
let mut best = None;
let mut best_value = f64::NEG_INFINITY;
let mut alpha = f64::NEG_INFINITY;
for action in game.legal_actions(state, player) {
let record = game.apply_undoable(state, player, action.clone());
let value = value_unmake(game, state, player, alpha, f64::INFINITY, self.max_depth);
game.undo(state, record);
if value > best_value {
best_value = value;
best = Some(action);
}
alpha = alpha.max(best_value);
}
best
}
}
fn value_clone<G>(
game: &G,
state: &G::State,
root: PlayerId,
mut alpha: f64,
mut beta: f64,
depth: u32,
) -> f64
where
G: Game,
G::State: Clone,
{
let Some(active) = leaf_or_active(game, state, depth) else {
return game.reward(state, root);
};
let maximizing = active == root;
let mut value = bound(maximizing);
for action in game.legal_actions(state, active) {
let mut child = state.clone();
game.apply(&mut child, active, action);
let child_value = value_clone(game, &child, root, alpha, beta, depth - 1);
(value, alpha, beta) = tighten(maximizing, value, child_value, alpha, beta);
if alpha >= beta {
break;
}
}
value
}
fn value_unmake<G>(
game: &G,
state: &mut G::State,
root: PlayerId,
mut alpha: f64,
mut beta: f64,
depth: u32,
) -> f64
where
G: Reversible,
{
let Some(active) = leaf_or_active(game, state, depth) else {
return game.reward(state, root);
};
let maximizing = active == root;
let mut value = bound(maximizing);
for action in game.legal_actions(state, active) {
let record = game.apply_undoable(state, active, action);
let child_value = value_unmake(game, state, root, alpha, beta, depth - 1);
game.undo(state, record);
(value, alpha, beta) = tighten(maximizing, value, child_value, alpha, beta);
if alpha >= beta {
break;
}
}
value
}
fn leaf_or_active<G: Game>(game: &G, state: &G::State, depth: u32) -> Option<PlayerId> {
if depth == 0 || game.is_terminal(state) {
return None;
}
game.active_players(state).iter().next()
}
const fn bound(maximizing: bool) -> f64 {
if maximizing {
f64::NEG_INFINITY
} else {
f64::INFINITY
}
}
const fn tighten(
maximizing: bool,
value: f64,
child: f64,
alpha: f64,
beta: f64,
) -> (f64, f64, f64) {
if maximizing {
let value = value.max(child);
(value, alpha.max(value), beta)
} else {
let value = value.min(child);
(value, alpha, beta.min(value))
}
}
impl<G> Bot<G> for Minimax
where
G: Game,
G::State: Clone,
G::Action: Clone,
{
fn choose(&mut self, game: &G, state: &G::State, player: PlayerId) -> Option<G::Action> {
self.best_action(game, state, player)
}
}
impl<G> RankedBot<G> for Minimax
where
G: Game,
G::State: Clone,
G::Action: Clone,
{
fn rank(&mut self, game: &G, state: &G::State, player: PlayerId) -> Vec<(G::Action, f64)> {
let mut ranked: Vec<(G::Action, f64)> = game
.legal_actions(state, player)
.into_iter()
.map(|action| {
let mut child = state.clone();
game.apply(&mut child, player, action.clone());
let value = value_clone(
game,
&child,
player,
f64::NEG_INFINITY,
f64::INFINITY,
self.max_depth,
);
(action, value)
})
.collect();
ranked.sort_by(|a, b| b.1.total_cmp(&a.1));
ranked
}
}
#[cfg(test)]
mod tests {
use super::Minimax;
use crate::{Bot, Random, RankedBot};
use proptest::prelude::*;
use tic_tac_toe::{Cell, Move, TicTacToe};
use turnbase::{Game, PlayerId, Prng, Reversible};
const P0: PlayerId = PlayerId::new(0);
fn run_match<X: Bot<TicTacToe>, O: Bot<TicTacToe>>(
x: &mut X,
o: &mut O,
) -> <TicTacToe as Game>::State {
let game = TicTacToe;
let mut state = game.new_initial_state(0);
while !game.is_terminal(&state) {
let player = game.active_players(&state).iter().next().unwrap();
let action = if player.index() == 0 {
x.choose(&game, &state, player)
} else {
o.choose(&game, &state, player)
}
.expect("non-terminal state has a legal action");
game.apply(&mut state, player, action);
}
state
}
#[test]
#[allow(clippy::float_cmp)] fn minimax_never_loses_against_random() {
let game = TicTacToe;
for seed in 0..25 {
let mut x = Minimax::new(9);
let mut o = Random::new(seed);
let end = run_match(&mut x, &mut o);
assert!(
game.reward(&end, P0) >= 0.0,
"optimal X lost to random O (seed {seed})"
);
}
}
#[test]
#[allow(clippy::float_cmp)] fn optimal_play_is_a_draw() {
let game = TicTacToe;
let mut x = Minimax::new(9);
let mut o = Minimax::new(9);
let end = run_match(&mut x, &mut o);
assert!(game.is_terminal(&end));
assert_eq!(game.reward(&end, P0), 0.0);
}
#[test]
fn clone_and_unmake_choose_the_same_move() {
let game = TicTacToe;
let search = Minimax::new(9);
let mut state = game.new_initial_state(0);
let script = [4u8, 0, 8, 2, 6];
for &cell in &script {
let player = game.active_players(&state).iter().next().unwrap();
let clone_pick = search.best_action(&game, &state, player);
let mut scratch = state.clone();
let unmake_pick = search.best_action_unmake(&game, &mut scratch, player);
assert_eq!(clone_pick, unmake_pick);
assert_eq!(scratch, state, "unmake search must leave state unchanged");
game.apply(&mut state, player, Move(cell));
}
}
#[test]
#[allow(clippy::float_cmp)] fn rank_puts_the_winning_move_first() {
let game = TicTacToe;
let mut state = game.new_initial_state(0);
for (seat, cell) in [(0u32, 0u8), (1, 3), (0, 1), (1, 4)] {
game.apply(&mut state, PlayerId::new(seat), Move(cell));
}
let mut search = Minimax::new(9);
let ranked = search.rank(&game, &state, P0);
assert_eq!(ranked.len(), game.legal_actions(&state, P0).len());
assert_eq!(ranked[0].0, Move(2), "the winning move should rank first");
assert_eq!(ranked[0].1, 1.0);
assert!(
ranked.windows(2).all(|w| w[0].1 >= w[1].1),
"scores must be sorted best-first"
);
assert_eq!(
ranked.first().map(|(a, _)| *a),
search.best_action(&game, &state, P0),
"rank().first() agrees with best_action()"
);
}
#[test]
fn random_bot_plays_only_legal_moves() {
let game = TicTacToe;
let mut bot = Random::new(7);
let mut state = game.new_initial_state(0);
while !game.is_terminal(&state) {
let player = game.active_players(&state).iter().next().unwrap();
let action = bot.choose(&game, &state, player).unwrap();
assert!(game.is_legal(&state, player, &action));
game.apply(&mut state, player, action);
}
}
proptest! {
#[test]
fn undo_restores_the_board(seed in any::<u64>()) {
let game = TicTacToe;
let mut rng = Prng::new(seed);
let mut state = game.new_initial_state(0);
while !game.is_terminal(&state) {
let player = game.active_players(&state).iter().next().unwrap();
let actions = game.legal_actions(&state, player);
let index = usize::try_from(rng.below(actions.len() as u64)).unwrap();
let action = actions[index];
let before = state.clone();
let record = game.apply_undoable(&mut state, player, action);
prop_assert_ne!(&state, &before);
game.undo(&mut state, record);
prop_assert_eq!(&state, &before);
game.apply(&mut state, player, action);
}
prop_assert!(matches!(
game.view(&state, None).cell(0),
Cell::Empty | Cell::X | Cell::O
));
}
}
}