feat: web client bot tuning

This commit is contained in:
Henri Bourcereau 2026-06-21 13:44:24 +02:00
parent 7a760980ba
commit 813cc3448a
4 changed files with 508 additions and 75 deletions

View file

@ -1,4 +1,4 @@
use trictrac_store::{Board, CheckerMove, Color, GameState, MoveRules, Stage, TurnStage};
use trictrac_store::{Board, CheckerMove, Color, Dice, GameState, MoveRules, Stage, TurnStage};
use super::types::{PlayerAction, PreGameRollState};
@ -45,13 +45,42 @@ pub fn bot_decide(game: &GameState, pgr: Option<&PreGameRollState>) -> Option<Pl
}
}
/// Score a candidate move sequence from the bot's (Black) perspective.
/// Score a candidate bot move sequence using depth-1 expectiminimax.
/// For each of the 21 possible opponent dice pairs, the opponent picks the move that
/// minimises the bot's score; we average those minima weighted by dice probability.
/// `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)
// Apply bot's moves on the mirrored board, then restore normal coordinates → B1.
let mut b_mirror = board.mirror();
let _ = b_mirror.move_checker(&Color::White, *m1);
let _ = b_mirror.move_checker(&Color::White, *m2);
let b1 = b_mirror.mirror();
// Expectiminimax: sum over all 21 distinct dice pairs, weighted by probability (out of 36).
// Non-doubles have probability 2/36 each; doubles 1/36 each.
let mut total = 0.0f32;
for d1 in 1u8..=6 {
for d2 in d1..=6 {
let weight = if d1 == d2 { 1.0f32 } else { 2.0f32 };
let opp_rules = MoveRules::new(&Color::White, &b1, Dice { values: (d1, d2) });
let opp_seqs = opp_rules.get_possible_moves_sequences(true, vec![]);
let min_score = if opp_seqs.is_empty() {
evaluate(&b1.mirror())
} else {
opp_seqs
.iter()
.map(|(om1, om2)| {
let mut b2 = b1.clone();
let _ = b2.move_checker(&Color::White, *om1);
let _ = b2.move_checker(&Color::White, *om2);
evaluate(&b2.mirror())
})
.fold(f32::INFINITY, f32::min)
};
total += weight * min_score;
}
}
total // proportional to expected score; dividing by 36 doesn't affect move ordering
}
/// Evaluate a board position from White's perspective (call after mirroring for Black).
@ -61,11 +90,19 @@ fn evaluate(board: &Board) -> f32 {
let white_fields = board.get_color_fields(Color::White);
let black_fields = board.get_color_fields(Color::Black);
// Bonus if rest corner filled (tuned: 6.0)
let corner_field = board.get_color_corner(&Color::White);
let (corner_count, _color) = board.get_field_checkers(corner_field).unwrap();
if corner_count > 0 {
score += 6.0;
}
// 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.
// quarter_filled tuned to 5.5 (was 8.0), quarter_progress kept at 0.3.
for &q in &[1usize, 7, 19] {
if board.is_quarter_filled(Color::White, q) {
score += 8.0;
score += 5.5;
} else {
let missing = board.get_quarter_filling_candidate(Color::White);
score += (6 - missing.len().min(6)) as f32 * 0.3;
@ -81,12 +118,8 @@ fn evaluate(board: &Board) -> f32 {
}
}
// 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;
}
}
// Exit zone progress: tuned to 0.0 — mid-game jan-filling dominates.
// (term kept here as a reminder; re-enable when bearing-off phase is reached)
score
}