feat(web client): local heuristic bot

This commit is contained in:
Henri Bourcereau 2026-05-02 11:26:55 +02:00
parent 20134ce468
commit e61448b627

View file

@ -1,5 +1,4 @@
use rand::prelude::IndexedRandom; use trictrac_store::{Board, CheckerMove, Color, GameState, MoveRules, Stage, TurnStage};
use trictrac_store::{CheckerMove, Color, GameState, MoveRules, Stage, TurnStage};
use super::types::{PlayerAction, PreGameRollState}; use super::types::{PlayerAction, PreGameRollState};
@ -29,15 +28,65 @@ pub fn bot_decide(game: &GameState, pgr: Option<&PreGameRollState>) -> Option<Pl
TurnStage::Move | TurnStage::HoldOrGoChoice => { TurnStage::Move | TurnStage::HoldOrGoChoice => {
let rules = MoveRules::new(&Color::Black, &game.board, game.dice); let rules = MoveRules::new(&Color::Black, &game.board, game.dice);
let sequences = rules.get_possible_moves_sequences(true, vec![]); let sequences = rules.get_possible_moves_sequences(true, vec![]);
let mut rng = rand::rng();
let (m1, m2) = sequences
.choose(&mut rng)
.cloned()
.unwrap_or((CheckerMove::default(), CheckerMove::default()));
// MoveRules with Color::Black mirrors the board internally, so // MoveRules with Color::Black mirrors the board internally, so
// returned move coordinates are in mirrored (White) space — mirror back. // returned move coordinates are in mirrored (White) space — mirror back.
let (m1, m2) = sequences
.iter()
.max_by(|(m1a, m2a), (m1b, m2b)| {
score_seq(&game.board, m1a, m2a)
.partial_cmp(&score_seq(&game.board, m1b, m2b))
.unwrap_or(std::cmp::Ordering::Equal)
})
.cloned()
.unwrap_or((CheckerMove::default(), CheckerMove::default()));
Some(PlayerAction::Move(m1.mirror(), m2.mirror())) Some(PlayerAction::Move(m1.mirror(), m2.mirror()))
} }
_ => None, _ => None,
} }
} }
/// Score a candidate move sequence from the bot's (Black) perspective.
/// `m1` and `m2` are in mirrored (White) space, as returned by MoveRules for Color::Black.
fn score_seq(board: &Board, m1: &CheckerMove, m2: &CheckerMove) -> f32 {
let mut b = board.mirror();
let _ = b.move_checker(&Color::White, *m1);
let _ = b.move_checker(&Color::White, *m2);
evaluate(&b)
}
/// Evaluate a board position from White's perspective (call after mirroring for Black).
fn evaluate(board: &Board) -> f32 {
let mut score = 0.0f32;
let white_fields = board.get_color_fields(Color::White);
let black_fields = board.get_color_fields(Color::Black);
// Quarter fill progress — quarters 1-6, 7-12, 19-24.
// Quarter 13-18 is skipped: field 13 is the opponent's rest corner so White can never fill it.
for &q in &[1usize, 7, 19] {
if board.is_quarter_filled(Color::White, q) {
score += 8.0;
} else {
let missing = board.get_quarter_filling_candidate(Color::White);
score += (6 - missing.len().min(6)) as f32 * 0.3;
}
}
// Singleton exposure: penalise a White singleton at field f only when there is at least
// one Black checker at a field g > f (opponent can potentially threaten it).
let max_black_field = black_fields.iter().map(|(f, _)| *f).max().unwrap_or(0);
for (f, count) in &white_fields {
if *count == 1 && *f < max_black_field {
score -= 0.5;
}
}
// Exit zone progress: reward checkers already in fields 19-24.
for (field, count) in &white_fields {
if *field >= 19 {
score += count.abs() as f32 * 0.3;
}
}
score
}