From e61448b6278db6221449a72350ef721bb3a00dd7 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Sat, 2 May 2026 11:26:55 +0200 Subject: [PATCH] feat(web client): local heuristic bot --- clients/web/src/game/trictrac/bot_local.rs | 63 +++++++++++++++++++--- 1 file changed, 56 insertions(+), 7 deletions(-) diff --git a/clients/web/src/game/trictrac/bot_local.rs b/clients/web/src/game/trictrac/bot_local.rs index 5543b07..6161fe2 100644 --- a/clients/web/src/game/trictrac/bot_local.rs +++ b/clients/web/src/game/trictrac/bot_local.rs @@ -1,5 +1,4 @@ -use rand::prelude::IndexedRandom; -use trictrac_store::{CheckerMove, Color, GameState, MoveRules, Stage, TurnStage}; +use trictrac_store::{Board, CheckerMove, Color, GameState, MoveRules, Stage, TurnStage}; use super::types::{PlayerAction, PreGameRollState}; @@ -29,15 +28,65 @@ pub fn bot_decide(game: &GameState, pgr: Option<&PreGameRollState>) -> Option { let rules = MoveRules::new(&Color::Black, &game.board, game.dice); 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 // 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())) } _ => 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 +}