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:
2026-10-19 19:56:37 +00:00
parent dc0ad17a95
commit dd3b918561
2 changed files with 146 additions and 51 deletions
+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.
- 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.
+138 -51
View File
@@ -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<Item = Move> + '_ {
@@ -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<Move>, 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<Player> {
@@ -1106,7 +1124,7 @@ mod ai_tests {
}
#[test]
fn ai_test_multiple_capture() {
fn ai_test_avoids_hanging_move() {
let mut pieces = HashMap::<Position, Piece>::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::<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,
)),
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
);
}
}