diff --git a/store/Cargo.toml b/store/Cargo.toml index a9234ff..935a2a0 100644 --- a/store/Cargo.toml +++ b/store/Cargo.toml @@ -25,5 +25,9 @@ rand = "0.9" serde = { version = "1.0", features = ["derive"] } transpose = "0.2.2" +[[bin]] +name = "random_game" +path = "src/bin/random_game.rs" + [build-dependencies] cxx-build = "1.0" diff --git a/store/src/bin/random_game.rs b/store/src/bin/random_game.rs new file mode 100644 index 0000000..6da3b9c --- /dev/null +++ b/store/src/bin/random_game.rs @@ -0,0 +1,262 @@ +//! Run one or many games of trictrac between two random players. +//! In single-game mode, prints play-by-play like OpenSpiel's `example.cc`. +//! In multi-game mode, runs silently and reports throughput at the end. +//! +//! Usage: +//! cargo run --bin random_game -- [--seed ] [--games ] [--max-steps ] [--verbose] + +use std::borrow::Cow; +use std::env; +use std::time::Instant; + +use trictrac_store::{ + training_common::sample_valid_action, + Dice, DiceRoller, GameEvent, GameState, Stage, TurnStage, +}; + +// ── CLI args ────────────────────────────────────────────────────────────────── + +struct Args { + seed: Option, + games: usize, + max_steps: usize, + verbose: bool, +} + +fn parse_args() -> Args { + let args: Vec = env::args().collect(); + let mut seed = None; + let mut games = 1; + let mut max_steps = 10_000; + let mut verbose = false; + + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--seed" => { + i += 1; + seed = args.get(i).and_then(|s| s.parse().ok()); + } + "--games" => { + i += 1; + if let Some(v) = args.get(i).and_then(|s| s.parse().ok()) { + games = v; + } + } + "--max-steps" => { + i += 1; + if let Some(v) = args.get(i).and_then(|s| s.parse().ok()) { + max_steps = v; + } + } + "--verbose" => verbose = true, + _ => {} + } + i += 1; + } + + Args { + seed, + games, + max_steps, + verbose, + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn player_label(id: u64) -> &'static str { + if id == 1 { "White" } else { "Black" } +} + +/// Apply a `Roll` + `RollResult` in one logical step, returning the dice. +/// This collapses the two-step dice phase into a single "chance node" action, +/// matching how the OpenSpiel layer exposes it. +fn apply_dice_roll(state: &mut GameState, roller: &mut DiceRoller) -> Result { + // RollDice → RollWaiting + state + .consume(&GameEvent::Roll { player_id: state.active_player_id }) + .map_err(|e| format!("Roll event failed: {e}"))?; + + // RollWaiting → Move / HoldOrGoChoice (or Stage::Ended if 13th hole) + let dice = roller.roll(); + state + .consume(&GameEvent::RollResult { player_id: state.active_player_id, dice }) + .map_err(|e| format!("RollResult event failed: {e}"))?; + + Ok(dice) +} + +/// Sample a random action and apply it to `state`, handling the Black-mirror +/// transform exactly as `cxxengine.rs::apply_action` does: +/// +/// 1. For Black, build a mirrored view of the state so that `sample_valid_action` +/// and `to_event` always reason from White's perspective. +/// 2. Mirror the resulting event back to the original coordinate frame before +/// calling `state.consume`. +/// +/// Returns the chosen action (in the view's coordinate frame) for display. +fn apply_player_action(state: &mut GameState) -> Result<(), String> { + let needs_mirror = state.active_player_id == 2; + + // Build a White-perspective view: borrowed for White, owned mirror for Black. + let view: Cow = if needs_mirror { + Cow::Owned(state.mirror()) + } else { + Cow::Borrowed(state) + }; + + let action = sample_valid_action(&view) + .ok_or_else(|| format!("no valid action in stage {:?}", state.turn_stage))?; + + let event = action + .to_event(&view) + .ok_or_else(|| format!("could not convert {action:?} to event"))?; + + // Translate the event from the view's frame back to the game's frame. + let event = if needs_mirror { event.get_mirror(false) } else { event }; + + state + .consume(&event) + .map_err(|e| format!("consume({action:?}): {e}"))?; + + Ok(()) +} + +// ── Single game ──────────────────────────────────────────────────────────────── + +/// Run one full game, optionally printing play-by-play. +/// Returns `(steps, truncated)`. +fn run_game(roller: &mut DiceRoller, max_steps: usize, quiet: bool, verbose: bool) -> (usize, bool) { + let mut state = GameState::new_with_players("White", "Black"); + let mut step = 0usize; + + if !quiet { + println!("{state}"); + } + + while state.stage != Stage::Ended { + step += 1; + if step > max_steps { + return (step - 1, true); + } + + match state.turn_stage { + TurnStage::RollDice => { + let player = state.active_player_id; + match apply_dice_roll(&mut state, roller) { + Ok(dice) => { + if !quiet { + println!( + "[step {step:4}] {} rolls: {} & {}", + player_label(player), + dice.values.0, + dice.values.1 + ); + } + } + Err(e) => { + eprintln!("Error during dice roll: {e}"); + eprintln!("State:\n{state}"); + return (step, true); + } + } + } + stage => { + let player = state.active_player_id; + match apply_player_action(&mut state) { + Ok(()) => { + if !quiet { + println!( + "[step {step:4}] {} ({stage:?})", + player_label(player) + ); + if verbose { + println!("{state}"); + } + } + } + Err(e) => { + eprintln!("Error: {e}"); + eprintln!("State:\n{state}"); + return (step, true); + } + } + } + } + } + + if !quiet { + println!("\n=== Game over after {step} steps ===\n"); + println!("{state}"); + + let white = state.players.get(&1); + let black = state.players.get(&2); + + match (white, black) { + (Some(w), Some(b)) => { + println!("White — holes: {:2}, points: {:2}", w.holes, w.points); + println!("Black — holes: {:2}, points: {:2}", b.holes, b.points); + println!(); + + let white_score = w.holes as i32 * 12 + w.points as i32; + let black_score = b.holes as i32 * 12 + b.points as i32; + + if white_score > black_score { + println!("Winner: White (+{})", white_score - black_score); + } else if black_score > white_score { + println!("Winner: Black (+{})", black_score - white_score); + } else { + println!("Draw"); + } + } + _ => eprintln!("Could not read final player scores."), + } + } + + (step, false) +} + +// ── Main ────────────────────────────────────────────────────────────────────── + +fn main() { + let args = parse_args(); + let mut roller = DiceRoller::new(args.seed); + + if args.games == 1 { + println!("=== Trictrac — random game ==="); + if let Some(s) = args.seed { + println!("seed: {s}"); + } + println!(); + run_game(&mut roller, args.max_steps, false, args.verbose); + } else { + println!("=== Trictrac — {} games ===", args.games); + if let Some(s) = args.seed { + println!("seed: {s}"); + } + println!(); + + let mut total_steps = 0u64; + let mut truncated = 0usize; + + let t0 = Instant::now(); + for _ in 0..args.games { + let (steps, trunc) = run_game(&mut roller, args.max_steps, !args.verbose, args.verbose); + total_steps += steps as u64; + if trunc { + truncated += 1; + } + } + let elapsed = t0.elapsed(); + + let secs = elapsed.as_secs_f64(); + println!("Games : {}", args.games); + println!("Truncated : {truncated}"); + println!("Total steps: {total_steps}"); + println!("Avg steps : {:.1}", total_steps as f64 / args.games as f64); + println!("Elapsed : {:.3} s", secs); + println!("Throughput : {:.1} games/s", args.games as f64 / secs); + println!(" {:.0} steps/s", total_steps as f64 / secs); + } +} diff --git a/store/src/game_rules_moves.rs b/store/src/game_rules_moves.rs index 41221f2..955ab3c 100644 --- a/store/src/game_rules_moves.rs +++ b/store/src/game_rules_moves.rs @@ -361,17 +361,24 @@ impl MoveRules { let _ = board_to_check.move_checker(&Color::White, moves.0); let farthest_on_move2 = Self::get_board_exit_farthest(&board_to_check); - let (is_move1_exedant, is_move2_exedant) = self.move_excedants(moves); - if (is_move1_exedant && moves.0.get_from() != farthest_on_move1) - || (is_move2_exedant && moves.1.get_from() != farthest_on_move2) - { + // dice normal order + let (is_move1_exedant, is_move2_exedant) = self.move_excedants(moves, true); + let is_not_farthest1 = (is_move1_exedant && moves.0.get_from() != farthest_on_move1) + || (is_move2_exedant && moves.1.get_from() != farthest_on_move2); + + // dice reversed order + let (is_move1_exedant, is_move2_exedant) = self.move_excedants(moves, false); + let is_not_farthest2 = (is_move1_exedant && moves.0.get_from() != farthest_on_move1) + || (is_move2_exedant && moves.1.get_from() != farthest_on_move2); + + if is_not_farthest1 && is_not_farthest2 { return Err(MoveError::ExitNotFarthest); } Ok(()) } - fn move_excedants(&self, moves: &(CheckerMove, CheckerMove)) -> (bool, bool) { + fn move_excedants(&self, moves: &(CheckerMove, CheckerMove), dice_order: bool) -> (bool, bool) { let move1to = if moves.0.get_to() == 0 { 25 } else { @@ -386,20 +393,16 @@ impl MoveRules { }; let dist2 = move2to - moves.1.get_from(); - let dist_min = cmp::min(dist1, dist2); - let dist_max = cmp::max(dist1, dist2); - - let dice_min = cmp::min(self.dice.values.0, self.dice.values.1) as usize; - let dice_max = cmp::max(self.dice.values.0, self.dice.values.1) as usize; - - let min_excedant = dist_min != 0 && dist_min < dice_min; - let max_excedant = dist_max != 0 && dist_max < dice_max; - - if dist_min == dist1 { - (min_excedant, max_excedant) + let (dice1, dice2) = if dice_order { + self.dice.values } else { - (max_excedant, min_excedant) - } + (self.dice.values.1, self.dice.values.0) + }; + + ( + dist1 != 0 && dist1 < dice1 as usize, + dist2 != 0 && dist2 < dice2 as usize, + ) } fn get_board_exit_farthest(board: &Board) -> Field { @@ -1587,6 +1590,19 @@ mod tests { CheckerMove::new(22, 0).unwrap(), ); assert!(state.check_exit_rules(&moves).is_ok()); + + state.dice.values = (6, 4); + state.board.set_positions( + &crate::Color::White, + [ + -4, -1, -2, -1, 0, 0, 0, -1, 0, 0, 0, 0, -5, -1, 0, 0, 0, 0, 2, 3, 2, 2, 5, 1, + ], + ); + let moves = ( + CheckerMove::new(20, 24).unwrap(), + CheckerMove::new(23, 0).unwrap(), + ); + assert!(state.check_exit_rules(&moves).is_ok()); } #[test]