1use crate::bitboard::Bitboard;
29use crate::evaluation::{evaluate, EvalConfig};
30use crate::game::{current_player, has_winning_line};
31use crate::moves::{apply_move, generate_legal_moves, Move};
32use crate::state::State;
33use rand::prelude::*;
34use std::collections::HashMap;
35use std::time::Instant;
36
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39enum Bound {
40 Exact,
41 Lower,
42 Upper,
43}
44
45#[derive(Clone, Debug)]
47pub struct MinimaxConfig {
48 pub max_depth: u32,
49 pub time_limit_s: Option<f64>,
50 pub use_alpha_beta: bool,
51 pub use_transposition_table: bool,
52 pub dedup_children: bool,
53 pub eval_config: EvalConfig,
54 pub random_seed: Option<u64>,
55}
56
57impl Default for MinimaxConfig {
58 fn default() -> Self {
59 Self {
60 max_depth: 16,
61 time_limit_s: None,
62 use_alpha_beta: true,
63 use_transposition_table: true,
64 dedup_children: true,
65 eval_config: EvalConfig::default(),
66 random_seed: None,
67 }
68 }
69}
70
71#[derive(Clone, Debug)]
73pub struct MinimaxResult {
74 pub best_move: Move,
75 pub score: f64,
76 pub depth_reached: u32,
77 pub nodes: u64,
78 pub pv: Vec<Move>,
79 pub elapsed: f64,
80}
81
82type TTEntry = (u32, f64, Bound);
83
84type ChildEntry = (Move, Bitboard, Option<[u8; 18]>);
87
88struct TimeUp;
90
91fn move_sort_key(mv: &Move) -> (u8, u8) {
92 (mv.shape, mv.position)
93}
94
95pub struct MinimaxEngine {
97 pub config: MinimaxConfig,
98 tt: HashMap<[u8; 18], TTEntry>,
99 nodes: u64,
100 deadline: Option<Instant>,
101 rng: Option<StdRng>,
102 pv_hint: Vec<Move>,
103}
104
105impl MinimaxEngine {
106 pub fn new(config: MinimaxConfig) -> Self {
107 let rng = config.random_seed.map(StdRng::seed_from_u64);
108 Self {
109 config,
110 tt: HashMap::new(),
111 nodes: 0,
112 deadline: None,
113 rng,
114 pv_hint: Vec::new(),
115 }
116 }
117
118 pub fn solve(&mut self, state: &State) -> Result<MinimaxResult, String> {
124 let original = self.config.clone();
125 self.config.max_depth = 16;
126 self.config.time_limit_s = None;
127 let result = self.search(state);
128 self.config = original;
129 result
130 }
131
132 pub fn search(&mut self, state: &State) -> Result<MinimaxResult, String> {
140 let start = Instant::now();
141 self.nodes = 0;
142 self.tt.clear();
143 self.pv_hint.clear();
144 self.deadline = self
145 .config
146 .time_limit_s
147 .map(|s| start + std::time::Duration::from_secs_f64(s));
148
149 let bb = state.bb;
150 let root_moves = generate_legal_moves(&bb);
151 if root_moves.is_empty() {
152 return Err("Cannot search from a state with no legal moves.".into());
153 }
154
155 let mut result: Option<MinimaxResult> = None;
156 for depth in 1..=self.config.max_depth {
157 match self.search_root(&bb, &root_moves, depth) {
158 Ok((score, best_move, pv)) => {
159 self.pv_hint = pv.clone();
160 result = Some(MinimaxResult {
161 best_move,
162 score,
163 depth_reached: depth,
164 nodes: self.nodes,
165 pv,
166 elapsed: start.elapsed().as_secs_f64(),
167 });
168 if let Some(deadline) = self.deadline {
169 if Instant::now() >= deadline {
170 break;
171 }
172 }
173 }
174 Err(TimeUp) => break,
175 }
176 }
177
178 let mut result = result.expect("depth-1 iteration always completes");
182 result.elapsed = start.elapsed().as_secs_f64();
183 Ok(result)
184 }
185
186 fn order_root_moves(&self, moves: &[Move]) -> Vec<Move> {
192 let mut ordered: Vec<Move> = moves.to_vec();
193 ordered.sort_by_key(move_sort_key);
194 if let Some(pv_move) = self.pv_hint.first() {
195 if let Some(i) = ordered.iter().position(|m| m == pv_move) {
196 let mv = ordered.remove(i);
197 ordered.insert(0, mv);
198 }
199 }
200 ordered
201 }
202
203 fn children(bb: &Bitboard, moves: &[Move], dedup: bool) -> Vec<ChildEntry> {
211 if !dedup {
212 return moves
213 .iter()
214 .map(|&mv| (mv, apply_move(bb, &mv), None))
215 .collect();
216 }
217 let mut seen: HashMap<[u8; 18], ()> = HashMap::new();
218 let mut children = Vec::with_capacity(moves.len());
219 for &mv in moves {
220 let child_bb = apply_move(bb, &mv);
221 let key = State::new(child_bb).canonical_key();
222 if seen.insert(key, ()).is_some() {
223 continue;
224 }
225 children.push((mv, child_bb, Some(key)));
226 }
227 children
228 }
229
230 fn search_root(
239 &mut self,
240 bb: &Bitboard,
241 moves: &[Move],
242 depth: u32,
243 ) -> Result<(f64, Move, Vec<Move>), TimeUp> {
244 let ordered = self.order_root_moves(moves);
245 let children = Self::children(bb, &ordered, self.config.dedup_children);
246
247 let mut best_value = f64::NEG_INFINITY;
248 let mut scored: Vec<(Move, f64, Vec<Move>)> = Vec::with_capacity(children.len());
249
250 for (mv, child_bb, child_key) in children {
251 let mut child_pv = Vec::new();
252 let value = -self.negamax(
253 &child_bb,
254 depth - 1,
255 f64::NEG_INFINITY,
256 f64::INFINITY,
257 1,
258 &mut child_pv,
259 child_key,
260 )?;
261 if value > best_value {
262 best_value = value;
263 }
264 scored.push((mv, value, child_pv));
265 }
266
267 let candidates: Vec<&(Move, f64, Vec<Move>)> =
268 scored.iter().filter(|(_, v, _)| *v == best_value).collect();
269 let (mv, _, child_pv) = match self.rng.as_mut() {
270 Some(rng) => candidates[rng.gen_range(0..candidates.len())],
271 None => candidates[0],
272 };
273 let mut pv = vec![*mv];
274 pv.extend(child_pv.iter().copied());
275 Ok((best_value, *mv, pv))
276 }
277
278 fn check_time(&self) -> Result<(), TimeUp> {
279 if let Some(deadline) = self.deadline {
280 if Instant::now() >= deadline {
281 return Err(TimeUp);
282 }
283 }
284 Ok(())
285 }
286
287 #[allow(clippy::too_many_arguments)]
295 fn negamax(
296 &mut self,
297 bb: &Bitboard,
298 depth: u32,
299 mut alpha: f64,
300 mut beta: f64,
301 ply: u32,
302 pv_out: &mut Vec<Move>,
303 precomputed_key: Option<[u8; 18]>,
304 ) -> Result<f64, TimeUp> {
305 self.nodes += 1;
306 if self.deadline.is_some() && (self.nodes & 0x3FF) == 0 {
307 self.check_time()?;
308 }
309
310 let win = self.config.eval_config.win;
311
312 if has_winning_line(bb) {
313 return Ok(-(win - ply as f64));
317 }
318
319 let moves = generate_legal_moves(bb);
320 if moves.is_empty() {
321 return Ok(-(win - ply as f64));
323 }
324
325 if depth == 0 {
326 let side = current_player(bb).unwrap_or(0);
327 return Ok(evaluate(bb, side, &self.config.eval_config));
328 }
329
330 let mut tt_key: Option<[u8; 18]> = None;
331 let orig_alpha = alpha;
332 let orig_beta = beta;
333 if self.config.use_transposition_table {
334 let key = precomputed_key.unwrap_or_else(|| State::new(*bb).canonical_key());
335 tt_key = Some(key);
336 if let Some(&(stored_depth, stored_value, bound)) = self.tt.get(&key) {
337 if stored_depth >= depth {
338 if bound == Bound::Exact {
339 return Ok(stored_value);
340 }
341 if self.config.use_alpha_beta {
346 match bound {
347 Bound::Lower => alpha = alpha.max(stored_value),
348 Bound::Upper => beta = beta.min(stored_value),
349 Bound::Exact => unreachable!(),
350 }
351 if alpha >= beta {
352 return Ok(stored_value);
353 }
354 }
355 }
356 }
357 }
358
359 let mut ordered = moves;
360 ordered.sort_by_key(move_sort_key);
361 let mut children = Self::children(bb, &ordered, self.config.dedup_children);
362 children.sort_by_key(|(_, child_bb, _)| !has_winning_line(child_bb));
367
368 let mut best_value = f64::NEG_INFINITY;
369 let mut best_move: Option<Move> = None;
370 let mut best_child_pv: Vec<Move> = Vec::new();
371
372 for (mv, child_bb, child_key) in children {
373 let mut child_pv = Vec::new();
374 let value = -self.negamax(
375 &child_bb,
376 depth - 1,
377 -beta,
378 -alpha,
379 ply + 1,
380 &mut child_pv,
381 child_key,
382 )?;
383 if value > best_value {
384 best_value = value;
385 best_move = Some(mv);
386 best_child_pv = child_pv;
387 }
388 if self.config.use_alpha_beta {
389 alpha = alpha.max(best_value);
390 if alpha >= beta {
391 break;
392 }
393 }
394 }
395
396 if let Some(mv) = best_move {
397 pv_out.push(mv);
398 pv_out.extend(best_child_pv);
399 }
400
401 if let Some(key) = tt_key {
402 let bound = if best_value <= orig_alpha {
405 Bound::Upper
406 } else if self.config.use_alpha_beta && best_value >= orig_beta {
407 Bound::Lower
408 } else {
409 Bound::Exact
410 };
411 self.tt.insert(key, (depth, best_value, bound));
412 }
413
414 Ok(best_value)
415 }
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421
422 fn immediate_win_board() -> Bitboard {
424 Bitboard::EMPTY
425 .with_move(0, 0, 0)
426 .with_move(1, 1, 1)
427 .with_move(0, 2, 2)
428 }
429
430 #[test]
431 fn finds_immediate_winning_move() {
432 let bb = immediate_win_board();
433 let mut engine = MinimaxEngine::new(MinimaxConfig {
434 max_depth: 3,
435 ..Default::default()
436 });
437 let result = engine.search(&State::new(bb)).unwrap();
438 assert_eq!(result.best_move, Move::new(1, 3, 3));
440 assert_eq!(result.score, 10_000.0 - 1.0);
442 }
443
444 #[test]
445 fn pv_head_matches_best_move() {
446 let bb = immediate_win_board();
447 let mut engine = MinimaxEngine::new(MinimaxConfig {
448 max_depth: 4,
449 ..Default::default()
450 });
451 let result = engine.search(&State::new(bb)).unwrap();
452 assert_eq!(result.pv[0], result.best_move);
453 assert!(result.pv.len() as u32 <= result.depth_reached);
454 }
455
456 #[test]
457 fn alpha_beta_equals_plain_minimax() {
458 let bb = Bitboard::EMPTY
459 .with_move(0, 0, 0)
460 .with_move(1, 3, 8)
461 .with_move(0, 2, 2)
462 .with_move(1, 3, 13)
463 .with_move(0, 1, 1)
464 .with_move(1, 0, 10);
465 let mut pruned = MinimaxEngine::new(MinimaxConfig {
466 max_depth: 3,
467 use_alpha_beta: true,
468 ..Default::default()
469 });
470 let mut plain = MinimaxEngine::new(MinimaxConfig {
471 max_depth: 3,
472 use_alpha_beta: false,
473 ..Default::default()
474 });
475 let state = State::new(bb);
476 let a = pruned.search(&state).unwrap();
477 let b = plain.search(&state).unwrap();
478 assert_eq!(a.score, b.score);
479 assert_eq!(a.best_move, b.best_move);
480 assert!(pruned.nodes <= plain.nodes);
481 }
482
483 fn random_position(seed: u64, plies: usize) -> Bitboard {
486 let mut rng = StdRng::seed_from_u64(seed);
487 'attempt: loop {
488 let mut bb = Bitboard::EMPTY;
489 for _ in 0..plies {
490 let moves = generate_legal_moves(&bb);
491 if moves.is_empty() {
492 continue 'attempt;
493 }
494 bb = apply_move(&bb, &moves[rng.gen_range(0..moves.len())]);
495 if has_winning_line(&bb) {
496 continue 'attempt;
497 }
498 }
499 if generate_legal_moves(&bb).is_empty() {
500 continue 'attempt;
501 }
502 return bb;
503 }
504 }
505
506 #[test]
507 fn solve_reaches_terminal_depth() {
508 let bb = random_position(42, 10);
511 let mut engine = MinimaxEngine::new(MinimaxConfig::default());
512 let result = engine.solve(&State::new(bb)).unwrap();
513 assert_eq!(result.depth_reached, 16);
514 assert!(result.score.abs() > 9_000.0, "score {}", result.score);
517 }
518
519 #[test]
520 fn time_limit_returns_depth_one_result() {
521 let mut engine = MinimaxEngine::new(MinimaxConfig {
522 max_depth: 16,
523 time_limit_s: Some(0.001),
524 ..Default::default()
525 });
526 let result = engine.search(&State::empty()).unwrap();
527 assert!(result.depth_reached >= 1);
528 assert!(result.pv.first() == Some(&result.best_move));
529 }
530
531 #[test]
532 fn deterministic_without_seed() {
533 let bb = immediate_win_board();
534 let mut e1 = MinimaxEngine::new(MinimaxConfig {
535 max_depth: 2,
536 ..Default::default()
537 });
538 let mut e2 = MinimaxEngine::new(MinimaxConfig {
539 max_depth: 2,
540 ..Default::default()
541 });
542 assert_eq!(
543 e1.search(&State::new(bb)).unwrap().best_move,
544 e2.search(&State::new(bb)).unwrap().best_move
545 );
546 }
547
548 #[test]
549 fn solve_prefers_faster_win() {
550 let bb = random_position(7, 11);
553 let mut engine = MinimaxEngine::new(MinimaxConfig::default());
554 let result = engine.solve(&State::new(bb)).unwrap();
555 let winning: Vec<Move> = generate_legal_moves(&bb)
556 .into_iter()
557 .filter(|m| has_winning_line(&apply_move(&bb, m)))
558 .collect();
559 if winning.is_empty() {
560 assert!(result.score.abs() > 9_000.0);
562 } else {
563 assert!(winning.contains(&result.best_move));
564 assert_eq!(result.score, 10_000.0 - 1.0);
565 }
566 }
567}