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:
1 parent
dc0ad17a95
commit
dd3b918561
2 files changed
+146
-51
No files matched your search
@@ -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
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user