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:
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user