From 806f31f42fe49f8ab6920630a114649800c0a21e Mon Sep 17 00:00:00 2001 From: "Tristan F." Date: Thu, 26 Sep 2024 09:57:20 -0700 Subject: [PATCH] feat: pretty output, better exiting tui --- Cargo.lock | 8 +++ crates/game-solver/Cargo.toml | 1 + crates/game-solver/src/lib.rs | 67 +++++++++++++------- crates/game-solver/src/player.rs | 14 +++- crates/game-solver/src/stats.rs | 17 ++--- crates/games/Cargo.toml | 1 + crates/games/src/tic_tac_toe/mod.rs | 74 +++++++++++++++------- crates/games/src/util/cli/human.rs | 48 +++++++++----- crates/games/src/util/cli/mod.rs | 3 +- crates/games/src/util/cli/report/mod.rs | 2 + crates/games/src/util/cli/report/scores.rs | 36 +++++++++++ crates/games/src/util/cli/report/stats.rs | 20 ++++++ crates/games/src/util/cli/robot.rs | 30 ++------- 13 files changed, 218 insertions(+), 103 deletions(-) create mode 100644 crates/games/src/util/cli/report/mod.rs create mode 100644 crates/games/src/util/cli/report/scores.rs create mode 100644 crates/games/src/util/cli/report/stats.rs diff --git a/Cargo.lock b/Cargo.lock index 5abdcaa..b8bc297 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1573,6 +1573,7 @@ dependencies = [ name = "game-solver" version = "0.1.0" dependencies = [ + "castaway", "futures", "itertools", "moka", @@ -1598,6 +1599,7 @@ dependencies = [ "ndarray", "once_cell", "ordinal", + "owo-colors", "petgraph", "ratatui", "serde", @@ -2451,6 +2453,12 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "owo-colors" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb37767f6569cd834a413442455e0f066d0d522de8630436e2a1761d9726ba56" + [[package]] name = "parking" version = "2.2.0" diff --git a/crates/game-solver/Cargo.toml b/crates/game-solver/Cargo.toml index 2c9d894..d049bd0 100644 --- a/crates/game-solver/Cargo.toml +++ b/crates/game-solver/Cargo.toml @@ -24,6 +24,7 @@ twox-hash = { version = "1.6", optional = true } itertools = { version = "0.13", optional = true } futures = "0.3.30" thiserror = "1.0" +castaway = "0.2.3" [package.metadata.docs.rs] all-features = true diff --git a/crates/game-solver/src/lib.rs b/crates/game-solver/src/lib.rs index 175ed41..6635b3c 100644 --- a/crates/game-solver/src/lib.rs +++ b/crates/game-solver/src/lib.rs @@ -19,7 +19,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use game::{upper_bound, GameState}; -use player::TwoPlayer; +use player::{ImpartialPlayer, TwoPlayer}; use stats::Stats; use crate::game::Game; @@ -37,12 +37,12 @@ pub enum GameSolveError { /// Runs the two-player minimax variant on a zero-sum game. /// Since it uses alpha-beta pruning, you can specify an alpha beta window. -fn negamax + Eq + Hash>( +fn negamax + Eq + Hash>( game: &T, transposition_table: &mut dyn TranspositionTable, mut alpha: isize, mut beta: isize, - stats: Option<&Stats>, + stats: Option<&Stats>, cancellation_token: &Option>, ) -> Result> { if let Some(token) = cancellation_token { @@ -67,12 +67,26 @@ fn negamax + Eq + Hash>( return Ok(0); } GameState::Win(winning_player) => { + // TODO: can we not duplicate this + if let Some(stats) = stats { + if let Ok(player) = castaway::cast!(winning_player, ImpartialPlayer) { + if ImpartialPlayer::from_move_count(stats.original_move_count, game.move_count()) == player { + stats.terminal_ends.winning.fetch_add(1, Ordering::Relaxed); + } else { + stats.terminal_ends.losing.fetch_add(1, Ordering::Relaxed); + } + } else { + if stats.original_player == winning_player { + stats.terminal_ends.winning.fetch_add(1, Ordering::Relaxed); + } else { + stats.terminal_ends.losing.fetch_add(1, Ordering::Relaxed); + } + } + } + // if the next player is the winning player, // the score should be positive. if game.player() == winning_player { - if let Some(stats) = stats { - stats.terminal_ends.winning.fetch_add(1, Ordering::Relaxed); - } // we add one to make sure games that use up every move // aren't represented by ties. // @@ -82,9 +96,6 @@ fn negamax + Eq + Hash>( // but we reserve 0 for ties. return Ok(upper_bound(game) - game.move_count() as isize + 1); } else { - if let Some(stats) = stats { - stats.terminal_ends.losing.fetch_add(1, Ordering::Relaxed); - } return Ok(-(upper_bound(game) - game.move_count() as isize + 1)); } } @@ -101,15 +112,25 @@ fn negamax + Eq + Hash>( return Ok(0); } GameState::Win(winning_player) => { - if game.player().turn() == winning_player { - if let Some(stats) = stats { - stats.terminal_ends.winning.fetch_add(1, Ordering::Relaxed); + if let Some(stats) = stats { + if let Ok(player) = castaway::cast!(winning_player, ImpartialPlayer) { + if ImpartialPlayer::from_move_count(stats.original_move_count, game.move_count()) == player { + stats.terminal_ends.winning.fetch_add(1, Ordering::Relaxed); + } else { + stats.terminal_ends.losing.fetch_add(1, Ordering::Relaxed); + } + } else { + if stats.original_player == winning_player { + stats.terminal_ends.winning.fetch_add(1, Ordering::Relaxed); + } else { + stats.terminal_ends.losing.fetch_add(1, Ordering::Relaxed); + } } + } + + if game.player().turn() == winning_player { return Ok(upper_bound(&board) - board.move_count() as isize + 1); } else { - if let Some(stats) = stats { - stats.terminal_ends.losing.fetch_add(1, Ordering::Relaxed); - } return Ok(-(upper_bound(&board) - board.move_count() as isize + 1)); } } @@ -220,10 +241,10 @@ fn negamax + Eq + Hash>( /// In 2 player games, if a score > 0, then the player whose turn it is has a winning strategy. /// If a score < 0, then the player whose turn it is has a losing strategy. /// Else, the game is a draw (score = 0). -pub fn solve + Eq + Hash>( +pub fn solve + Eq + Hash>( game: &T, transposition_table: &mut dyn TranspositionTable, - stats: Option<&Stats>, + stats: Option<&Stats>, cancellation_token: &Option>, ) -> Result> { let mut alpha = -upper_bound(game); @@ -261,10 +282,10 @@ pub fn solve + Eq + Hash>( /// # Returns /// /// An iterator of tuples of the form `(move, score)`. -pub fn move_scores<'a, T: Game + Eq + Hash>( +pub fn move_scores<'a, T: Game + Eq + Hash>( game: &'a T, transposition_table: &'a mut dyn TranspositionTable, - stats: Option<&'a Stats>, + stats: Option<&'a Stats>, cancellation_token: &'a Option>, ) -> impl Iterator>> + 'a { game.possible_moves().map(move |m| { @@ -294,11 +315,11 @@ pub type CollectedMoves = Vec::Move, isize), GameSolveErr /// A vector of tuples of the form `(move, score)`. #[cfg(feature = "rayon")] pub fn par_move_scores_with_hasher< - T: Game + Eq + Hash + Sync + Send + 'static, + T: Game + Eq + Hash + Sync + Send + 'static, S, >( game: &T, - stats: Option<&Stats>, + stats: Option<&Stats>, cancellation_token: &Option>, ) -> CollectedMoves where @@ -343,9 +364,9 @@ where /// /// A vector of tuples of the form `(move, score)`. #[cfg(feature = "rayon")] -pub fn par_move_scores + Eq + Hash + Sync + Send + 'static>( +pub fn par_move_scores + Eq + Hash + Sync + Send + 'static>( game: &T, - stats: Option<&Stats>, + stats: Option<&Stats>, cancellation_token: &Option>, ) -> CollectedMoves where diff --git a/crates/game-solver/src/player.rs b/crates/game-solver/src/player.rs index 031d1d2..7545673 100644 --- a/crates/game-solver/src/player.rs +++ b/crates/game-solver/src/player.rs @@ -23,7 +23,9 @@ pub trait Player: Sized + Eq { } /// Represents a two player player. -pub trait TwoPlayer: Player { +/// +/// This player should always be representable by a byte. +pub trait TwoPlayer: Player + Copy { /// Gets the other player #[must_use] fn other(self) -> Self { @@ -85,6 +87,16 @@ pub enum ImpartialPlayer { Previous, } +impl ImpartialPlayer { + pub fn from_move_count(initial_move_count: usize, final_move_count: usize) -> ImpartialPlayer { + if (final_move_count - initial_move_count) % 2 == 0 { + ImpartialPlayer::Next + } else { + ImpartialPlayer::Previous + } + } +} + impl Player for ImpartialPlayer { fn count() -> usize { 2 diff --git a/crates/game-solver/src/stats.rs b/crates/game-solver/src/stats.rs index a4a8a29..cd92b38 100644 --- a/crates/game-solver/src/stats.rs +++ b/crates/game-solver/src/stats.rs @@ -1,5 +1,7 @@ use std::sync::atomic::{AtomicU64, AtomicUsize}; +use crate::player::Player; + #[derive(Debug)] pub struct TerminalEnds { pub winning: AtomicU64, @@ -18,22 +20,13 @@ impl Default for TerminalEnds { } #[derive(Debug)] -pub struct Stats { +pub struct Stats { pub states_explored: AtomicU64, pub max_depth: AtomicUsize, pub cache_hits: AtomicU64, pub pruning_cutoffs: AtomicU64, pub terminal_ends: TerminalEnds, + pub original_player: P, + pub original_move_count: usize } -impl Default for Stats { - fn default() -> Self { - Self { - states_explored: AtomicU64::new(0), - max_depth: AtomicUsize::new(0), - cache_hits: AtomicU64::new(0), - pruning_cutoffs: AtomicU64::new(0), - terminal_ends: TerminalEnds::default(), - } - } -} diff --git a/crates/games/Cargo.toml b/crates/games/Cargo.toml index 1fe4c4c..4c3a167 100644 --- a/crates/games/Cargo.toml +++ b/crates/games/Cargo.toml @@ -21,6 +21,7 @@ thiserror = "1.0.63" petgraph = "0.6.5" castaway = "0.2.3" ratatui = "0.28.1" +owo-colors = "4.1.0" [features] "egui" = ["dep:egui", "dep:egui_commonmark"] diff --git a/crates/games/src/tic_tac_toe/mod.rs b/crates/games/src/tic_tac_toe/mod.rs index 185a637..32312fd 100644 --- a/crates/games/src/tic_tac_toe/mod.rs +++ b/crates/games/src/tic_tac_toe/mod.rs @@ -23,17 +23,32 @@ use crate::util::cli::move_failable; #[derive(Clone, Copy, Hash, Eq, PartialEq, Debug)] pub enum Square { - Empty, X, O, } +impl Square { + fn to_player(self) -> PartizanPlayer { + match self { + Self::X => PartizanPlayer::Left, + Self::O => PartizanPlayer::Right + } + } + + fn from_player(player: PartizanPlayer) -> Square { + match player { + PartizanPlayer::Left => Self::X, + PartizanPlayer::Right => Self::O + } + } +} + #[derive(Clone, Hash, Eq, PartialEq)] pub struct TicTacToe { dim: usize, size: usize, /// True represents a square that has not been eaten - board: ArrayD, + board: ArrayD>, move_count: usize, } @@ -112,7 +127,7 @@ fn add_checked(a: Dim, b: Vec) -> Option> { impl TicTacToe { fn new(dim: usize, size: usize) -> Self { // we want [SIZE; dim] but dim isn't a const - we have to get the slice from a vec - let board = ArrayD::from_elem(IxDyn(&vec![size; dim]), Square::Empty); + let board = ArrayD::from_elem(IxDyn(&vec![size; dim]), None); Self { dim, @@ -122,11 +137,12 @@ impl TicTacToe { } } - fn winning_line(&self, point: &Dim, offset: &[i32]) -> bool { + /// Returns the square on this winning line. + fn winning_line(&self, point: &Dim, offset: &[i32]) -> Option { let square = self.board.get(point).unwrap(); - if *square == Square::Empty { - return false; + if *square == None { + return None; } let mut n = 1; @@ -153,15 +169,19 @@ impl TicTacToe { } } - n >= self.size + if n >= self.size { + *square + } else { + None + } } } impl Game for TicTacToe { type Move = TicTacToeMove; type Iter<'a> = FilterMap< - IndexedIter<'a, Square, Dim>, - fn((Dim, &Square)) -> Option, + IndexedIter<'a, Option, Dim>, + fn((Dim, &Option)) -> Option, >; type Player = PartizanPlayer; type MoveError = TicTacToeMoveError; @@ -184,14 +204,14 @@ impl Game for TicTacToe { // check every move for (index, square) in self.board.indexed_iter() { - if square == &Square::Empty { + if square == &None { continue; } let point = index.into_dimension(); for offset in offsets(&point, self.size) { - if self.winning_line(&point, &offset) { - return GameState::Win(self.player().next()); + if let Some(square) = self.winning_line(&point, &offset) { + return GameState::Win(square.to_player()); } } } @@ -200,14 +220,10 @@ impl Game for TicTacToe { } fn make_move(&mut self, m: &Self::Move) -> Result<(), Self::MoveError> { - if *self.board.get(m.0.clone()).unwrap() == Square::Empty { - let square = if self.player() == PartizanPlayer::Left { - Square::X - } else { - Square::O - }; + if *self.board.get(m.0.clone()).unwrap() == None { + let square = Square::from_player(self.player()); - *self.board.get_mut(m.0.clone()).unwrap() = square; + *self.board.get_mut(m.0.clone()).unwrap() = Some(square); self.move_count += 1; Ok(()) } else { @@ -219,7 +235,7 @@ impl Game for TicTacToe { self.board .indexed_iter() .filter_map(move |(index, square)| { - if square == &Square::Empty { + if square == &None { Some(TicTacToeMove(index)) } else { None @@ -234,15 +250,25 @@ impl Game for TicTacToe { return Ok(None); } + let mut best_non_winning_game: Option = None; + for m in &mut self.possible_moves() { let mut new_self = self.clone(); new_self.make_move(&m)?; - if let GameState::Win(_) = new_self.state() { - return Ok(Some(new_self)); - } + match new_self.state() { + GameState::Playable => continue, + GameState::Tie => best_non_winning_game = Some(new_self), + GameState::Win(winning_player) => { + if winning_player == self.player().turn() { + return Ok(Some(new_self)); + } else if best_non_winning_game.is_none() { + best_non_winning_game = Some(new_self) + } + } + }; } - Ok(None) + Ok(best_non_winning_game) } fn player(&self) -> Self::Player { diff --git a/crates/games/src/util/cli/human.rs b/crates/games/src/util/cli/human.rs index a4b07b8..251be7e 100644 --- a/crates/games/src/util/cli/human.rs +++ b/crates/games/src/util/cli/human.rs @@ -1,7 +1,7 @@ use std::{ fmt::Display, sync::{ - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, Arc, }, thread, @@ -11,10 +11,10 @@ use std::{ use anyhow::Result; use core::hash::Hash; use game_solver::{ - game::{score_to_outcome, Game, GameScoreOutcome, GameState}, + game::Game, par_move_scores, - player::{ImpartialPlayer, TwoPlayer}, - stats::Stats, + player::TwoPlayer, + stats::{Stats, TerminalEnds}, }; use ratatui::{ buffer::Buffer, @@ -31,19 +31,19 @@ use ratatui::{ }; use std::fmt::Debug; -use crate::util::move_score::normalize_move_scores; +use super::report::{scores::show_scores, stats::show_stats}; #[derive(Debug)] -struct App { +struct App { exit: Arc, exit_ui: Arc, - stats: Arc, + stats: Arc>, } -impl App { +impl App { /// runs the application's main loop until the user quits pub fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> { - while !self.exit.load(Ordering::SeqCst) || !self.exit_ui.load(Ordering::SeqCst) { + while !self.exit.load(Ordering::SeqCst) && !self.exit_ui.load(Ordering::SeqCst) { terminal.draw(|frame| self.draw(frame))?; self.handle_events()?; } @@ -80,7 +80,7 @@ impl App { } } -impl Widget for &App { +impl Widget for &App { fn render(self, area: Rect, buf: &mut Buffer) { let title = Title::from(" game-solver ".bold().green()); let instructions = Title::from(Line::from(vec![ @@ -168,7 +168,7 @@ impl Widget for &App { } pub fn human_output< - T: Game + T: Game + Eq + Hash + Sync @@ -185,25 +185,41 @@ where { let mut terminal = ratatui::init(); - let stats = Arc::new(Stats::default()); + let stats = Arc::new(Stats { + states_explored: AtomicU64::new(0), + max_depth: AtomicUsize::new(0), + cache_hits: AtomicU64::new(0), + pruning_cutoffs: AtomicU64::new(0), + terminal_ends: TerminalEnds::default(), + original_player: game.player(), + original_move_count: game.move_count() + }); let exit = Arc::new(AtomicBool::new(false)); let exit_ui = Arc::new(AtomicBool::new(false)); - let mut app = App { + let mut app: App = App { exit: exit.clone(), stats: stats.clone(), exit_ui: exit_ui.clone(), }; + let internal_game = game.clone(); + let internal_stats = stats.clone(); let game_thread = thread::spawn(move || { - let move_scores = par_move_scores(&game, Some(stats.as_ref()), &Some(exit.clone())); - let move_scores = normalize_move_scores::(move_scores).unwrap(); + let move_scores = par_move_scores(&internal_game, Some(internal_stats.as_ref()), &Some(exit.clone())); + + exit_ui.store(true, Ordering::SeqCst); + + move_scores }); app.run(&mut terminal)?; - game_thread.join().unwrap(); ratatui::restore(); + let move_scores = game_thread.join().unwrap(); + + show_stats::(&stats); + show_scores(&game, move_scores); Ok(()) } diff --git a/crates/games/src/util/cli/mod.rs b/crates/games/src/util/cli/mod.rs index d6ea292..08667ff 100644 --- a/crates/games/src/util/cli/mod.rs +++ b/crates/games/src/util/cli/mod.rs @@ -1,5 +1,6 @@ mod human; mod robot; +mod report; use anyhow::{anyhow, Result}; use game_solver::{ @@ -15,7 +16,7 @@ use std::{ }; pub fn play< - T: Game + T: Game + Eq + Hash + Sync diff --git a/crates/games/src/util/cli/report/mod.rs b/crates/games/src/util/cli/report/mod.rs new file mode 100644 index 0000000..a5ea274 --- /dev/null +++ b/crates/games/src/util/cli/report/mod.rs @@ -0,0 +1,2 @@ +pub mod scores; +pub mod stats; \ No newline at end of file diff --git a/crates/games/src/util/cli/report/scores.rs b/crates/games/src/util/cli/report/scores.rs new file mode 100644 index 0000000..02d7b93 --- /dev/null +++ b/crates/games/src/util/cli/report/scores.rs @@ -0,0 +1,36 @@ +use std::fmt::{Debug, Display}; + +use game_solver::{game::{score_to_outcome, Game, GameScoreOutcome}, CollectedMoves}; + +use crate::util::move_score::normalize_move_scores; + +pub fn show_scores( + game: &T, + move_scores: CollectedMoves +) where T::Move: Display { + let move_scores = normalize_move_scores::(move_scores).unwrap(); + + let mut current_move_score = None; + for (game_move, score) in move_scores { + if current_move_score != Some(score) { + match score_to_outcome(game, score) { + GameScoreOutcome::Win(moves) => println!( + "\n\nWin in {} move{} (score {}):", + moves, + if moves == 1 { "" } else { "s" }, + score + ), + GameScoreOutcome::Loss(moves) => println!( + "\n\nLose in {} move{} (score {}):", + moves, + if moves == 1 { "" } else { "s" }, + score + ), + GameScoreOutcome::Tie => println!("\n\nTie with the following moves:"), + } + current_move_score = Some(score); + } + print!("{}, ", &game_move); + } + println!(); +} \ No newline at end of file diff --git a/crates/games/src/util/cli/report/stats.rs b/crates/games/src/util/cli/report/stats.rs new file mode 100644 index 0000000..613c9a0 --- /dev/null +++ b/crates/games/src/util/cli/report/stats.rs @@ -0,0 +1,20 @@ +use std::sync::atomic::Ordering; + +use game_solver::{game::Game, stats::Stats}; + +pub fn show_stats( + stats: &Stats, + // plain: bool +) { + println!("Stats: "); + println!(); + println!("States explored: {}", stats.states_explored.load(Ordering::SeqCst)); + println!("Max depth: {}", stats.max_depth.load(Ordering::SeqCst)); + println!("Cache hits: {}", stats.cache_hits.load(Ordering::SeqCst)); + println!("Pruning cutoffs: {}", stats.pruning_cutoffs.load(Ordering::SeqCst)); + println!("End nodes:"); + println!("\tWinning: {}", stats.terminal_ends.winning.load(Ordering::SeqCst)); + println!("\tLosing: {}", stats.terminal_ends.losing.load(Ordering::SeqCst)); + println!("\tTies: {}", stats.terminal_ends.tie.load(Ordering::SeqCst)); + println!(); +} diff --git a/crates/games/src/util/cli/robot.rs b/crates/games/src/util/cli/robot.rs index bbb5a72..768fcd6 100644 --- a/crates/games/src/util/cli/robot.rs +++ b/crates/games/src/util/cli/robot.rs @@ -9,10 +9,10 @@ use std::{ hash::Hash, }; -use crate::util::move_score::normalize_move_scores; +use crate::util::{cli::report::scores::show_scores, move_score::normalize_move_scores}; pub fn robotic_output< - T: Game + T: Game + Eq + Hash + Sync @@ -36,29 +36,7 @@ pub fn robotic_output< println!("Impartial game; Next player is moving."); } - let move_scores = normalize_move_scores::(par_move_scores(&game, None, &None)).unwrap(); + let move_scores = par_move_scores(&game, None, &None); - let mut current_move_score = None; - for (game_move, score) in move_scores { - if current_move_score != Some(score) { - match score_to_outcome(&game, score) { - GameScoreOutcome::Win(moves) => println!( - "\n\nWin in {} move{} (score {}):", - moves, - if moves == 1 { "" } else { "s" }, - score - ), - GameScoreOutcome::Loss(moves) => println!( - "\n\nLose in {} move{} (score {}):", - moves, - if moves == 1 { "" } else { "s" }, - score - ), - GameScoreOutcome::Tie => println!("\n\nTie with the following moves:"), - } - current_move_score = Some(score); - } - print!("{}, ", &game_move); - } - println!(); + show_scores(&game, move_scores); }