1use turnbase::{Game, PlayerId, Reversible};
4
5use crate::{Bot, RankedBot};
6
7pub struct Minimax {
18 max_depth: u32,
19}
20
21impl Minimax {
22 #[must_use]
24 pub const fn new(max_depth: u32) -> Self {
25 Self { max_depth }
26 }
27
28 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 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
139fn 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
148const fn bound(maximizing: bool) -> f64 {
150 if maximizing {
151 f64::NEG_INFINITY
152 } else {
153 f64::INFINITY
154 }
155}
156
157const 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 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 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 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)] 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)] 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)] fn rank_puts_the_winning_move_first() {
296 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 #[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}