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
This commit is contained in:
woggioni committed 2026-10-19 19:56:37 +00:00
1 parent dc0ad17a95
commit dd3b918561
2 files changed
+146 -51

No files matched your search

+8
View File
@@ -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. - A man is promoted to a king when it reaches the opponent's back row.
- Draw rules (repetition, move limits) are not implemented yet. - 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 ## Requirements
- Rust 1.87 or newer. The workspace uses edition 2024. - Rust 1.87 or newer. The workspace uses edition 2024.
+138 -51
View File
@@ -6,6 +6,8 @@ use crate::piece::Piece;
use crate::player::Player; use crate::player::Player;
use heapless::Vec; use heapless::Vec;
const WIN_SCORE: f32 = 1_000_000.0;
pub trait RectangularBoard { pub trait RectangularBoard {
fn rows() -> usize; fn rows() -> usize;
fn columns() -> usize; fn columns() -> usize;
@@ -465,40 +467,52 @@ impl DraughtsGame {
pub fn relative_score(&self, p: Player) -> f32 { pub fn relative_score(&self, p: Player) -> f32 {
let red = self.score_for_player(Player::Red); let red = self.score_for_player(Player::Red);
let white = self.score_for_player(Player::White); let white = self.score_for_player(Player::White);
let total = red + white;
if total == 0 {
return 0.5;
}
let subject = match p { let subject = match p {
Player::White => white, Player::White => white,
Player::Red => red, Player::Red => red,
}; };
subject as f32 / (red + white) as f32 subject as f32 / total as f32
} }
fn move_score( /// Fixed-depth negamax search with alpha-beta pruning.
mut self, ///
mv: &Move, /// Scores are relative to the side to move. A position with no legal moves
player: Player, /// is a loss for the side to move; wins and losses dominate material scores
depth: u32, /// and the remaining depth prefers faster wins and slower losses.
analyzed_moves: &mut usize, fn negamax(&self, depth: u32, mut alpha: f32, beta: f32, analyzed_moves: &mut usize) -> f32 {
) -> f32 { let mut moves = self.available_moves();
self.apply_validated_move(mv) let Some(first) = moves.next() else {
.expect("available move must be legal"); return -WIN_SCORE - depth as f32;
if depth != 0 { };
let mut best_score = None;
for mv in self.available_moves() { if depth == 0 {
*analyzed_moves += 1usize; return self.relative_score(self.next_move);
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)
} }
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<Item = Move> + '_ { fn available_moves(&self) -> impl Iterator<Item = Move> + '_ {
@@ -507,33 +521,37 @@ impl DraughtsGame {
.flat_map(|pos| self.moves_for_piece(pos)) .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<Move>, usize) { pub fn get_best_move(&self, max_depth: u32) -> (Option<Move>, usize) {
let depth = max_depth.max(1);
let mut analyzed_moves = 0usize; let mut analyzed_moves = 0usize;
let mut result: Option<(Move, f32)> = None; let mut result: Option<(Move, f32)> = None;
let available_moves = self.available_moves().count(); let mut alpha = f32::NEG_INFINITY;
let best_move = match available_moves {
0 => None, for mv in self.available_moves() {
1 => self.available_moves().next(), let mut child = self.clone();
_ => { child
for mv in self.available_moves() { .apply_validated_move(&mv)
let score = self.clone().move_score( .expect("generated move must be legal");
&mv, analyzed_moves += 1;
self.next_move,
max_depth - 1, let score = -child.negamax(depth - 1, f32::NEG_INFINITY, -alpha, &mut analyzed_moves);
&mut analyzed_moves, let replace = match &result {
); None => true,
if result.is_none() { Some((_, best_score)) => score > *best_score,
result = Some((mv, score)); };
} else if let Some((_, best_score)) = result { if replace {
if score > best_score { result = Some((mv, score));
result = Some((mv, score)); alpha = score;
}
}
}
result.map(|(mv, _)| mv)
} }
}; }
(best_move, analyzed_moves)
(result.map(|(mv, _)| mv), analyzed_moves)
} }
pub fn winner(&self) -> Option<Player> { pub fn winner(&self) -> Option<Player> {
@@ -1106,7 +1124,7 @@ mod ai_tests {
} }
#[test] #[test]
fn ai_test_multiple_capture() { fn ai_test_avoids_hanging_move() {
let mut pieces = HashMap::<Position, Piece>::new(); let mut pieces = HashMap::<Position, Piece>::new();
pieces.insert(Position::new(4, 4).unwrap(), Piece::SimpleWhitePawn); pieces.insert(Position::new(4, 4).unwrap(), Piece::SimpleWhitePawn);
pieces.insert(Position::new(4, 0).unwrap(), Piece::SimpleWhitePawn); pieces.insert(Position::new(4, 0).unwrap(), Piece::SimpleWhitePawn);
@@ -1120,13 +1138,82 @@ mod ai_tests {
Player::Red, 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); let (best_move, _) = game.get_best_move(5);
assert_eq!( assert_eq!(
Some(Move::movement( Some(Move::movement(
Position::new(6, 2).unwrap(), 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::<Position, Piece>::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, MoveDirection::SW,
)), )),
best_move best_move
); );
} }
#[test]
fn ai_test_takes_immediate_win() {
let mut pieces = HashMap::<Position, Piece>::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
);
}
} }