From dd3b918561647b90eb511e8bb00df7894556041f Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Mon, 19 Oct 2026 19:56:37 +0000 Subject: [PATCH] replace broken AI search with negamax and alpha-beta - fix the no-op best-score update that ignored all but the first reply - alternate sides correctly with negamax and add alpha-beta pruning - score terminal wins and losses above material evaluations - treat depth zero as one ply instead of underflowing - guard relative_score against an empty board - add hanging-piece, recapture, terminal, and depth regression tests --- README.md | 8 ++ rdraught/src/draughts.rs | 189 ++++++++++++++++++++++++++++----------- 2 files changed, 146 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index cb99570..1be186f 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,14 @@ The rules implemented by the engine are an 8x8 draughts variant: - A man is promoted to a king when it reaches the opponent's back row. - Draw rules (repetition, move limits) are not implemented yet. +## AI + +The engine exposes a fixed-depth negamax search with alpha-beta pruning through +`DraughtsGame::get_best_move(max_depth)`. A depth of zero is treated as one ply. +Wins and losses dominate material scoring, and faster wins are preferred. The +search has no time or node budget and no draw/repetition detection, so choose a +modest depth for positions with many pieces. + ## Requirements - Rust 1.87 or newer. The workspace uses edition 2024. diff --git a/rdraught/src/draughts.rs b/rdraught/src/draughts.rs index 08e372c..6816ee4 100644 --- a/rdraught/src/draughts.rs +++ b/rdraught/src/draughts.rs @@ -6,6 +6,8 @@ use crate::piece::Piece; use crate::player::Player; use heapless::Vec; +const WIN_SCORE: f32 = 1_000_000.0; + pub trait RectangularBoard { fn rows() -> usize; fn columns() -> usize; @@ -465,40 +467,52 @@ impl DraughtsGame { pub fn relative_score(&self, p: Player) -> f32 { let red = self.score_for_player(Player::Red); let white = self.score_for_player(Player::White); + let total = red + white; + if total == 0 { + return 0.5; + } let subject = match p { Player::White => white, Player::Red => red, }; - subject as f32 / (red + white) as f32 + subject as f32 / total as f32 } - fn move_score( - mut self, - mv: &Move, - player: Player, - depth: u32, - analyzed_moves: &mut usize, - ) -> f32 { - self.apply_validated_move(mv) - .expect("available move must be legal"); - if depth != 0 { - let mut best_score = None; - for mv in self.available_moves() { - *analyzed_moves += 1usize; - let clone = self.clone(); - let score = clone.move_score(&mv, player, depth - 1, analyzed_moves); - if best_score.is_none() { - best_score = Some(score); - } else if let Some(bs) = best_score { - if bs < score { - best_score = Some(bs); - } - } - } - best_score.unwrap_or_else(|| self.relative_score(player)) - } else { - self.relative_score(player) + /// Fixed-depth negamax search with alpha-beta pruning. + /// + /// Scores are relative to the side to move. A position with no legal moves + /// is a loss for the side to move; wins and losses dominate material scores + /// and the remaining depth prefers faster wins and slower losses. + fn negamax(&self, depth: u32, mut alpha: f32, beta: f32, analyzed_moves: &mut usize) -> f32 { + let mut moves = self.available_moves(); + let Some(first) = moves.next() else { + return -WIN_SCORE - depth as f32; + }; + + if depth == 0 { + return self.relative_score(self.next_move); } + + let mut best = f32::NEG_INFINITY; + for mv in core::iter::once(first).chain(moves) { + let mut child = self.clone(); + child + .apply_validated_move(&mv) + .expect("generated move must be legal"); + *analyzed_moves += 1; + + let score = -child.negamax(depth - 1, -beta, -alpha, analyzed_moves); + if score > best { + best = score; + } + if best > alpha { + alpha = best; + } + if alpha >= beta { + break; + } + } + best } fn available_moves(&self) -> impl Iterator + '_ { @@ -507,33 +521,37 @@ impl DraughtsGame { .flat_map(|pos| self.moves_for_piece(pos)) } + /// Returns the best move and the number of moves examined. + /// + /// `max_depth` is the number of plies searched. A value of zero behaves + /// like one ply, so this method always returns a legal move when one is + /// available. The search has no time or node budget; callers should choose + /// a depth appropriate for the position. pub fn get_best_move(&self, max_depth: u32) -> (Option, usize) { + let depth = max_depth.max(1); let mut analyzed_moves = 0usize; let mut result: Option<(Move, f32)> = None; - let available_moves = self.available_moves().count(); - let best_move = match available_moves { - 0 => None, - 1 => self.available_moves().next(), - _ => { - for mv in self.available_moves() { - let score = self.clone().move_score( - &mv, - self.next_move, - max_depth - 1, - &mut analyzed_moves, - ); - if result.is_none() { - result = Some((mv, score)); - } else if let Some((_, best_score)) = result { - if score > best_score { - result = Some((mv, score)); - } - } - } - result.map(|(mv, _)| mv) + let mut alpha = f32::NEG_INFINITY; + + for mv in self.available_moves() { + let mut child = self.clone(); + child + .apply_validated_move(&mv) + .expect("generated move must be legal"); + analyzed_moves += 1; + + let score = -child.negamax(depth - 1, f32::NEG_INFINITY, -alpha, &mut analyzed_moves); + let replace = match &result { + None => true, + Some((_, best_score)) => score > *best_score, + }; + if replace { + result = Some((mv, score)); + alpha = score; } - }; - (best_move, analyzed_moves) + } + + (result.map(|(mv, _)| mv), analyzed_moves) } pub fn winner(&self) -> Option { @@ -1106,7 +1124,7 @@ mod ai_tests { } #[test] - fn ai_test_multiple_capture() { + fn ai_test_avoids_hanging_move() { let mut pieces = HashMap::::new(); pieces.insert(Position::new(4, 4).unwrap(), Piece::SimpleWhitePawn); pieces.insert(Position::new(4, 0).unwrap(), Piece::SimpleWhitePawn); @@ -1120,13 +1138,82 @@ mod ai_tests { Player::Red, ); + // Moving SW to (5, 1) lets the white pawn at (4, 0) capture it. + // Moving SE to (5, 3) is safe. let (best_move, _) = game.get_best_move(5); assert_eq!( Some(Move::movement( Position::new(6, 2).unwrap(), + MoveDirection::SE, + )), + best_move + ); + } + + #[test] + fn ai_test_zero_depth_returns_a_legal_move() { + let game = DraughtsGame::default(); + let (best_move, analyzed_moves) = game.get_best_move(0); + assert!(best_move.is_some()); + assert!(analyzed_moves > 0); + } + + #[test] + fn ai_test_no_moves_returns_none() { + let game = DraughtsGame::new(|_| Piece::NoPiece, Player::Red); + let (best_move, analyzed_moves) = game.get_best_move(4); + assert_eq!(None, best_move); + assert_eq!(0, analyzed_moves); + } + + #[test] + fn ai_test_depth_two_avoids_recapture() { + let mut pieces = HashMap::::new(); + pieces.insert(Position::new(7, 5).unwrap(), Piece::SimpleRedPawn); + pieces.insert(Position::new(4, 4).unwrap(), Piece::SimpleWhitePawn); + pieces.insert(Position::new(5, 5).unwrap(), Piece::SimpleRedPawn); + pieces.insert(Position::new(6, 4).unwrap(), Piece::SimpleWhitePawn); + let game = DraughtsGame::new( + |p| match pieces.get(&p) { + None => Piece::NoPiece, + Some(piece) => *piece, + }, + Player::Red, + ); + + // Capturing with the pawn at (7, 5) captures (6, 4) but leaves the + // pawn at (5, 5) for the white pawn at (4, 4) to recapture. Capturing + // with the pawn at (5, 5) first is safe. + let (best_move, _) = game.get_best_move(2); + assert_eq!( + Some(Move::capture( + Position::new(5, 5).unwrap(), MoveDirection::SW, )), best_move ); } + + #[test] + fn ai_test_takes_immediate_win() { + let mut pieces = HashMap::::new(); + pieces.insert(Position::new(2, 2).unwrap(), Piece::SimpleRedPawn); + pieces.insert(Position::new(1, 3).unwrap(), Piece::SimpleWhitePawn); + let game = DraughtsGame::new( + |p| match pieces.get(&p) { + None => Piece::NoPiece, + Some(piece) => *piece, + }, + Player::Red, + ); + + let (best_move, _) = game.get_best_move(2); + assert_eq!( + Some(Move::capture( + Position::new(2, 2).unwrap(), + MoveDirection::SE, + )), + best_move + ); + } }