Skip to main content

turnbase_bots/
minimax.rs

1//! Alpha-beta minimax for two-player, zero-sum, perfect-information games.
2
3use turnbase::{Game, PlayerId, Reversible};
4
5use crate::{Bot, RankedBot};
6
7/// Depth-limited alpha-beta minimax.
8///
9/// Assumes exactly one active player per node (sequential play) and two-player
10/// zero-sum outcomes. Leaves are evaluated with [`Game::reward`], so give the
11/// search enough depth to reach terminals (or a game whose `reward` doubles as
12/// a heuristic at non-terminal nodes).
13///
14/// Two search paths are offered and always agree: [`Minimax::best_action`]
15/// clones state per node (needs `State: Clone`), and
16/// [`Minimax::best_action_unmake`] uses a game's [`Reversible`] make/unmake.
17pub struct Minimax {
18    max_depth: u32,
19}
20
21impl Minimax {
22    /// Creates a search that looks up to `max_depth` plies ahead.
23    #[must_use]
24    pub const fn new(max_depth: u32) -> Self {
25        Self { max_depth }
26    }
27
28    /// Returns the value-maximizing action for `player` using cloned search.
29    pub fn best_action<G>(&self, game: &G, state: &G::State, player: PlayerId) -> Option<G::Action>
30    where
31        G: Game,
32        G::State: Clone,
33        G::Action: Clone,
34    {
35        let mut best = None;
36        let mut best_value = f64::NEG_INFINITY;
37        let mut alpha = f64::NEG_INFINITY;
38        for action in game.legal_actions(state, player) {
39            let mut child = state.clone();
40            game.apply(&mut child, player, action.clone());
41            let value = value_clone(game, &child, player, alpha, f64::INFINITY, self.max_depth);
42            if value > best_value {
43                best_value = value;
44                best = Some(action);
45            }
46            alpha = alpha.max(best_value);
47        }
48        best
49    }
50
51    /// Returns the value-maximizing action for `player` using make/unmake.
52    ///
53    /// Mutates `state` during the search but restores it exactly, so `state` is
54    /// unchanged on return.
55    pub fn best_action_unmake<G>(
56        &self,
57        game: &G,
58        state: &mut G::State,
59        player: PlayerId,
60    ) -> Option<G::Action>
61    where
62        G: Reversible,
63        G::Action: Clone,
64    {
65        let mut best = None;
66        let mut best_value = f64::NEG_INFINITY;
67        let mut alpha = f64::NEG_INFINITY;
68        for action in game.legal_actions(state, player) {
69            let record = game.apply_undoable(state, player, action.clone());
70            let value = value_unmake(game, state, player, alpha, f64::INFINITY, self.max_depth);
71            game.undo(state, record);
72            if value > best_value {
73                best_value = value;
74                best = Some(action);
75            }
76            alpha = alpha.max(best_value);
77        }
78        best
79    }
80}
81
82fn value_clone<G>(
83    game: &G,
84    state: &G::State,
85    root: PlayerId,
86    mut alpha: f64,
87    mut beta: f64,
88    depth: u32,
89) -> f64
90where
91    G: Game,
92    G::State: Clone,
93{
94    let Some(active) = leaf_or_active(game, state, depth) else {
95        return game.reward(state, root);
96    };
97    let maximizing = active == root;
98    let mut value = bound(maximizing);
99    for action in game.legal_actions(state, active) {
100        let mut child = state.clone();
101        game.apply(&mut child, active, action);
102        let child_value = value_clone(game, &child, root, alpha, beta, depth - 1);
103        (value, alpha, beta) = tighten(maximizing, value, child_value, alpha, beta);
104        if alpha >= beta {
105            break;
106        }
107    }
108    value
109}
110
111fn value_unmake<G>(
112    game: &G,
113    state: &mut G::State,
114    root: PlayerId,
115    mut alpha: f64,
116    mut beta: f64,
117    depth: u32,
118) -> f64
119where
120    G: Reversible,
121{
122    let Some(active) = leaf_or_active(game, state, depth) else {
123        return game.reward(state, root);
124    };
125    let maximizing = active == root;
126    let mut value = bound(maximizing);
127    for action in game.legal_actions(state, active) {
128        let record = game.apply_undoable(state, active, action);
129        let child_value = value_unmake(game, state, root, alpha, beta, depth - 1);
130        game.undo(state, record);
131        (value, alpha, beta) = tighten(maximizing, value, child_value, alpha, beta);
132        if alpha >= beta {
133            break;
134        }
135    }
136    value
137}
138
139/// Returns the single active player if the node should be expanded, or `None`
140/// if it is a leaf (depth exhausted, terminal, or no active player).
141fn leaf_or_active<G: Game>(game: &G, state: &G::State, depth: u32) -> Option<PlayerId> {
142    if depth == 0 || game.is_terminal(state) {
143        return None;
144    }
145    game.active_players(state).iter().next()
146}
147
148/// The worst possible starting value for a maximizing or minimizing node.
149const fn bound(maximizing: bool) -> f64 {
150    if maximizing {
151        f64::NEG_INFINITY
152    } else {
153        f64::INFINITY
154    }
155}
156
157/// Folds one child's value into the running (value, alpha, beta) window.
158const fn tighten(
159    maximizing: bool,
160    value: f64,
161    child: f64,
162    alpha: f64,
163    beta: f64,
164) -> (f64, f64, f64) {
165    if maximizing {
166        let value = value.max(child);
167        (value, alpha.max(value), beta)
168    } else {
169        let value = value.min(child);
170        (value, alpha, beta.min(value))
171    }
172}
173
174impl<G> Bot<G> for Minimax
175where
176    G: Game,
177    G::State: Clone,
178    G::Action: Clone,
179{
180    fn choose(&mut self, game: &G, state: &G::State, player: PlayerId) -> Option<G::Action> {
181        self.best_action(game, state, player)
182    }
183}
184
185impl<G> RankedBot<G> for Minimax
186where
187    G: Game,
188    G::State: Clone,
189    G::Action: Clone,
190{
191    /// Scores each root action with a full alpha-beta window (no cross-sibling
192    /// pruning), so every score is the move's exact minimax value rather than a
193    /// bound. Costlier than [`Minimax::best_action`]; use it for analysis, not
194    /// hot self-play.
195    fn rank(&mut self, game: &G, state: &G::State, player: PlayerId) -> Vec<(G::Action, f64)> {
196        let mut ranked: Vec<(G::Action, f64)> = game
197            .legal_actions(state, player)
198            .into_iter()
199            .map(|action| {
200                let mut child = state.clone();
201                game.apply(&mut child, player, action.clone());
202                let value = value_clone(
203                    game,
204                    &child,
205                    player,
206                    f64::NEG_INFINITY,
207                    f64::INFINITY,
208                    self.max_depth,
209                );
210                (action, value)
211            })
212            .collect();
213        // Descending by score; total_cmp gives a deterministic total order.
214        ranked.sort_by(|a, b| b.1.total_cmp(&a.1));
215        ranked
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::Minimax;
222    use crate::{Bot, Random, RankedBot};
223    use proptest::prelude::*;
224    use tic_tac_toe::{Cell, Move, TicTacToe};
225    use turnbase::{Game, PlayerId, Prng, Reversible};
226
227    const P0: PlayerId = PlayerId::new(0);
228
229    /// Plays a full game, seat 0 driven by `x`, seat 1 by `o`. Returns the
230    /// terminal board.
231    fn run_match<X: Bot<TicTacToe>, O: Bot<TicTacToe>>(
232        x: &mut X,
233        o: &mut O,
234    ) -> <TicTacToe as Game>::State {
235        let game = TicTacToe;
236        let mut state = game.new_initial_state(0);
237        while !game.is_terminal(&state) {
238            let player = game.active_players(&state).iter().next().unwrap();
239            let action = if player.index() == 0 {
240                x.choose(&game, &state, player)
241            } else {
242                o.choose(&game, &state, player)
243            }
244            .expect("non-terminal state has a legal action");
245            game.apply(&mut state, player, action);
246        }
247        state
248    }
249
250    #[test]
251    #[allow(clippy::float_cmp)] // reward() is exactly 0.0 / ±1.0
252    fn minimax_never_loses_against_random() {
253        let game = TicTacToe;
254        for seed in 0..25 {
255            let mut x = Minimax::new(9);
256            let mut o = Random::new(seed);
257            let end = run_match(&mut x, &mut o);
258            assert!(
259                game.reward(&end, P0) >= 0.0,
260                "optimal X lost to random O (seed {seed})"
261            );
262        }
263    }
264
265    #[test]
266    #[allow(clippy::float_cmp)] // reward() is exactly 0.0 / ±1.0
267    fn optimal_play_is_a_draw() {
268        let game = TicTacToe;
269        let mut x = Minimax::new(9);
270        let mut o = Minimax::new(9);
271        let end = run_match(&mut x, &mut o);
272        assert!(game.is_terminal(&end));
273        assert_eq!(game.reward(&end, P0), 0.0);
274    }
275
276    #[test]
277    fn clone_and_unmake_choose_the_same_move() {
278        let game = TicTacToe;
279        let search = Minimax::new(9);
280        let mut state = game.new_initial_state(0);
281        let script = [4u8, 0, 8, 2, 6];
282        for &cell in &script {
283            let player = game.active_players(&state).iter().next().unwrap();
284            let clone_pick = search.best_action(&game, &state, player);
285            let mut scratch = state.clone();
286            let unmake_pick = search.best_action_unmake(&game, &mut scratch, player);
287            assert_eq!(clone_pick, unmake_pick);
288            assert_eq!(scratch, state, "unmake search must leave state unchanged");
289            game.apply(&mut state, player, Move(cell));
290        }
291    }
292
293    #[test]
294    #[allow(clippy::float_cmp)] // reward()/minimax values here are exactly 0.0 / ±1.0
295    fn rank_puts_the_winning_move_first() {
296        // X (seat 0) at 0,1 with an open 2 -> an immediate win; O at 3,4.
297        let game = TicTacToe;
298        let mut state = game.new_initial_state(0);
299        for (seat, cell) in [(0u32, 0u8), (1, 3), (0, 1), (1, 4)] {
300            game.apply(&mut state, PlayerId::new(seat), Move(cell));
301        }
302
303        let mut search = Minimax::new(9);
304        let ranked = search.rank(&game, &state, P0);
305
306        assert_eq!(ranked.len(), game.legal_actions(&state, P0).len());
307        assert_eq!(ranked[0].0, Move(2), "the winning move should rank first");
308        assert_eq!(ranked[0].1, 1.0);
309        assert!(
310            ranked.windows(2).all(|w| w[0].1 >= w[1].1),
311            "scores must be sorted best-first"
312        );
313        assert_eq!(
314            ranked.first().map(|(a, _)| *a),
315            search.best_action(&game, &state, P0),
316            "rank().first() agrees with best_action()"
317        );
318    }
319
320    #[test]
321    fn random_bot_plays_only_legal_moves() {
322        let game = TicTacToe;
323        let mut bot = Random::new(7);
324        let mut state = game.new_initial_state(0);
325        while !game.is_terminal(&state) {
326            let player = game.active_players(&state).iter().next().unwrap();
327            let action = bot.choose(&game, &state, player).unwrap();
328            assert!(game.is_legal(&state, player, &action));
329            game.apply(&mut state, player, action);
330        }
331    }
332
333    proptest! {
334        /// On every reachable position, `apply_undoable` then `undo` restores
335        /// the board exactly.
336        #[test]
337        fn undo_restores_the_board(seed in any::<u64>()) {
338            let game = TicTacToe;
339            let mut rng = Prng::new(seed);
340            let mut state = game.new_initial_state(0);
341            while !game.is_terminal(&state) {
342                let player = game.active_players(&state).iter().next().unwrap();
343                let actions = game.legal_actions(&state, player);
344                let index = usize::try_from(rng.below(actions.len() as u64)).unwrap();
345                let action = actions[index];
346
347                let before = state.clone();
348                let record = game.apply_undoable(&mut state, player, action);
349                prop_assert_ne!(&state, &before);
350                game.undo(&mut state, record);
351                prop_assert_eq!(&state, &before);
352
353                game.apply(&mut state, player, action);
354            }
355            prop_assert!(matches!(
356                game.view(&state, None).cell(0),
357                Cell::Empty | Cell::X | Cell::O
358            ));
359        }
360    }
361}