From 84bc8f096fa70c7ac99a1943b1b2483e6a54a0f9 Mon Sep 17 00:00:00 2001 From: "Tristan F." Date: Wed, 25 Sep 2024 22:12:16 -0700 Subject: [PATCH] feat: more tui! --- crates/game-solver/src/lib.rs | 86 ++++++++++++--- crates/game-solver/src/stats.rs | 2 + crates/games/src/chomp/mod.rs | 14 ++- crates/games/src/domineering/mod.rs | 16 ++- crates/games/src/nim/mod.rs | 11 +- crates/games/src/order_and_chaos/mod.rs | 14 ++- crates/games/src/reversi/mod.rs | 8 +- crates/games/src/tic_tac_toe/mod.rs | 14 ++- crates/games/src/util/cli/human.rs | 141 +++++++++++++++++++----- crates/games/src/util/cli/mod.rs | 13 ++- crates/games/src/util/cli/robot.rs | 15 ++- crates/games/src/util/mod.rs | 2 +- crates/games/src/util/move_score.rs | 10 +- 13 files changed, 274 insertions(+), 72 deletions(-) diff --git a/crates/game-solver/src/lib.rs b/crates/game-solver/src/lib.rs index 522899a..a92f595 100644 --- a/crates/game-solver/src/lib.rs +++ b/crates/game-solver/src/lib.rs @@ -28,11 +28,11 @@ use std::hash::Hash; use thiserror::Error; #[derive(Error, Debug)] -enum GameSolveError { +pub enum GameSolveError { #[error("could not make a move")] MoveError(T::MoveError), #[error("the game was cancelled by the token")] - CancellationTokenError + CancellationTokenError, } /// Runs the two-player minimax variant on a zero-sum game. @@ -43,7 +43,7 @@ fn negamax + Eq + Hash>( mut alpha: isize, mut beta: isize, stats: Option<&Stats>, - cancellation_token: &Option> + cancellation_token: &Option>, ) -> Result> { if let Some(token) = cancellation_token { if token.load(Ordering::Relaxed) { @@ -51,6 +51,11 @@ fn negamax + Eq + Hash>( } } + // TODO: debug-based depth counting + // if let Some(stats) = stats { + // stats.max_depth.fetch_max(depth, Ordering::Relaxed); + // } + // TODO(perf): if find_immediately_resolvable_game satisfies its contract, // we can ignore this at larger depths. match game.state() { @@ -144,14 +149,37 @@ fn negamax + Eq + Hash>( for m in &mut game.possible_moves() { let mut board = game.clone(); - board.make_move(&m).map_err(|err| GameSolveError::MoveError::(err)); + board + .make_move(&m) + .map_err(|err| GameSolveError::MoveError::(err))?; let score = if first_child { - -negamax(&board, transposition_table, -beta, -alpha, stats, &cancellation_token)? + -negamax( + &board, + transposition_table, + -beta, + -alpha, + stats, + &cancellation_token, + )? } else { - let score = -negamax(&board, transposition_table, -alpha - 1, -alpha, stats, &cancellation_token)?; + let score = -negamax( + &board, + transposition_table, + -alpha - 1, + -alpha, + stats, + &cancellation_token, + )?; if score > alpha { - -negamax(&board, transposition_table, -beta, -alpha, stats, &cancellation_token)? + -negamax( + &board, + transposition_table, + -beta, + -alpha, + stats, + &cancellation_token, + )? } else { score } @@ -159,6 +187,9 @@ fn negamax + Eq + Hash>( // alpha-beta pruning - we can return early if score >= beta { + if let Some(stats) = stats { + stats.pruning_cutoffs.fetch_add(1, Ordering::Relaxed); + } transposition_table.insert(game.clone(), Score::LowerBound(score)); return Ok(beta); } @@ -185,7 +216,7 @@ pub fn solve + Eq + Hash>( game: &T, transposition_table: &mut dyn TranspositionTable, stats: Option<&Stats>, - cancellation_token: Option> + cancellation_token: &Option>, ) -> Result> { let mut alpha = -upper_bound(game); let mut beta = upper_bound(game) + 1; @@ -195,8 +226,14 @@ pub fn solve + Eq + Hash>( let med = alpha + (beta - alpha) / 2; // do a [null window search](https://www.chessprogramming.org/Null_Window) - let evaluation = negamax(game, transposition_table, med, med + 1, stats, &cancellation_token) - .map_err(|err| GameSolveError::MoveError(err))?; + let evaluation = negamax( + game, + transposition_table, + med, + med + 1, + stats, + cancellation_token, + )?; if evaluation <= med { beta = evaluation; @@ -220,17 +257,23 @@ pub fn move_scores<'a, T: Game + Eq + Hash>( game: &'a T, transposition_table: &'a mut dyn TranspositionTable, stats: Option<&'a Stats>, -) -> impl Iterator> + 'a { + cancellation_token: &'a Option>, +) -> impl Iterator>> + 'a { game.possible_moves().map(move |m| { let mut board = game.clone(); - board.make_move(&m)?; + board + .make_move(&m) + .map_err(|err| GameSolveError::MoveError(err))?; // We flip the sign of the score because we want the score from the // perspective of the player playing the move, not the player whose turn it is. - Ok((m, -solve(&board, transposition_table, stats)?)) + Ok(( + m, + -solve(&board, transposition_table, stats, cancellation_token)?, + )) }) } -pub type CollectedMoves = Vec::Move, isize), ::MoveError>>; +pub type CollectedMoves = Vec::Move, isize), GameSolveError>>; /// Parallelized version of `move_scores`. (faster by a large margin) /// This requires the `rayon` feature to be enabled. @@ -248,6 +291,7 @@ pub fn par_move_scores_with_hasher< >( game: &T, stats: Option<&Stats>, + cancellation_token: &Option>, ) -> CollectedMoves where T::Move: Sync + Send, @@ -266,11 +310,16 @@ where .par_iter() .map(move |m| { let mut board = game.clone(); - board.make_move(m)?; + board + .make_move(m) + .map_err(|err| GameSolveError::MoveError::(err))?; // We flip the sign of the score because we want the score from the // perspective of the player pla`ying the move, not the player whose turn it is. let mut map = Arc::clone(&hashmap); - Ok(((*m).clone(), -solve(&board, &mut map, stats)?)) + Ok(( + (*m).clone(), + -solve(&board, &mut map, stats, cancellation_token)?, + )) }) .collect::>() } @@ -289,6 +338,7 @@ where pub fn par_move_scores + Eq + Hash + Sync + Send + 'static>( game: &T, stats: Option<&Stats>, + cancellation_token: &Option>, ) -> CollectedMoves where T::Move: Sync + Send, @@ -296,9 +346,9 @@ where { if cfg!(feature = "xxhash") { use twox_hash::RandomXxHashBuilder64; - par_move_scores_with_hasher::(game, stats) + par_move_scores_with_hasher::(game, stats, cancellation_token) } else { use std::collections::hash_map::RandomState; - par_move_scores_with_hasher::(game, stats) + par_move_scores_with_hasher::(game, stats, cancellation_token) } } diff --git a/crates/game-solver/src/stats.rs b/crates/game-solver/src/stats.rs index aa41889..a4a8a29 100644 --- a/crates/game-solver/src/stats.rs +++ b/crates/game-solver/src/stats.rs @@ -22,6 +22,7 @@ pub struct Stats { pub states_explored: AtomicU64, pub max_depth: AtomicUsize, pub cache_hits: AtomicU64, + pub pruning_cutoffs: AtomicU64, pub terminal_ends: TerminalEnds, } @@ -31,6 +32,7 @@ impl Default for 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(), } } diff --git a/crates/games/src/chomp/mod.rs b/crates/games/src/chomp/mod.rs index 4685a31..e018cf6 100644 --- a/crates/games/src/chomp/mod.rs +++ b/crates/games/src/chomp/mod.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use std::{ - fmt::{Display, Formatter}, + fmt::{Debug, Display, Formatter}, hash::Hash, }; @@ -144,6 +144,12 @@ impl Display for Chomp { } } +impl Debug for Chomp { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + ::fmt(&self, f) + } +} + impl TryFrom for Chomp { type Error = Error; @@ -163,15 +169,15 @@ impl TryFrom for Chomp { mod tests { use std::collections::HashMap; - use game_solver::move_scores; + use game_solver::{move_scores, GameSolveError}; use super::*; #[test] fn test_chomp() { let game = Chomp::new(6, 4); - let mut move_scores = move_scores(&game, &mut HashMap::new(), None) - .collect::, ChompMoveError>>() + let mut move_scores = move_scores(&game, &mut HashMap::new(), None, &None) + .collect::, GameSolveError>>() .unwrap(); move_scores.sort(); diff --git a/crates/games/src/domineering/mod.rs b/crates/games/src/domineering/mod.rs index fb3dcdb..bc6b7f1 100644 --- a/crates/games/src/domineering/mod.rs +++ b/crates/games/src/domineering/mod.rs @@ -215,6 +215,12 @@ impl Display for Domineering Debug for Domineering { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + ::fmt(&self, f) + } +} + /// Analyzes Domineering. /// #[doc = include_str!("./README.md")] @@ -249,7 +255,7 @@ impl TryFrom mod tests { use std::collections::HashMap; - use game_solver::move_scores; + use game_solver::{move_scores, GameSolveError}; use super::*; @@ -258,8 +264,8 @@ mod tests { orientation: Orientation, ) -> Option { let game = Domineering::::new_orientation(orientation); - let mut move_scores = move_scores(&game, &mut HashMap::new(), None) - .collect::, DomineeringMoveError>>() + let mut move_scores = move_scores(&game, &mut HashMap::new(), None, &None) + .collect::, GameSolveError>>>() .unwrap(); if move_scores.is_empty() { @@ -302,8 +308,8 @@ mod tests { #[test] fn test_domineering() { let game = Domineering::<5, 5>::new_orientation(Orientation::Horizontal); - let mut move_scores = move_scores(&game, &mut HashMap::new(), None) - .collect::, DomineeringMoveError>>() + let mut move_scores = move_scores(&game, &mut HashMap::new(), None, &None) + .collect::, GameSolveError>>>() .unwrap(); assert_eq!(move_scores.len(), game.possible_moves().len()); diff --git a/crates/games/src/nim/mod.rs b/crates/games/src/nim/mod.rs index 9e9d5be..5484a18 100644 --- a/crates/games/src/nim/mod.rs +++ b/crates/games/src/nim/mod.rs @@ -9,7 +9,10 @@ use game_solver::{ player::ImpartialPlayer, }; use serde::{Deserialize, Serialize}; -use std::{fmt::Display, hash::Hash}; +use std::{ + fmt::{Debug, Display}, + hash::Hash, +}; use thiserror::Error; use crate::util::{cli::move_failable, move_natural::NaturalMove}; @@ -123,6 +126,12 @@ impl Display for Nim { } } +impl Debug for Nim { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + ::fmt(&self, f) + } +} + /// Analyzes Nim. /// #[doc = include_str!("./README.md")] diff --git a/crates/games/src/order_and_chaos/mod.rs b/crates/games/src/order_and_chaos/mod.rs index d94afd2..dff784a 100644 --- a/crates/games/src/order_and_chaos/mod.rs +++ b/crates/games/src/order_and_chaos/mod.rs @@ -11,7 +11,7 @@ use game_solver::{ }; use serde::{Deserialize, Serialize}; use std::{ - fmt::{Display, Formatter}, + fmt::{Debug, Display, Formatter}, hash::Hash, }; use thiserror::Error; @@ -283,6 +283,18 @@ impl< } } +impl< + const WIDTH: usize, + const HEIGHT: usize, + const MIN_WIN_LENGTH: usize, + const MAX_WIN_LENGTH: usize, + > Debug for OrderAndChaos +{ + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + ::fmt(&self, f) + } +} + /// Analyzes Order and Chaos. /// #[doc = include_str!("./README.md")] diff --git a/crates/games/src/reversi/mod.rs b/crates/games/src/reversi/mod.rs index 1938bf7..6f00f73 100644 --- a/crates/games/src/reversi/mod.rs +++ b/crates/games/src/reversi/mod.rs @@ -11,7 +11,7 @@ use game_solver::{ player::{PartizanPlayer, Player}, }; use serde::{Deserialize, Serialize}; -use std::fmt; +use std::fmt::{self, Debug}; use std::hash::Hash; use crate::util::{cli::move_failable, move_natural::NaturalMove}; @@ -234,6 +234,12 @@ impl fmt::Display for Reversi { } } +impl Debug for Reversi { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + ::fmt(&self, f) + } +} + /// Analyzes Reversi. /// #[doc = include_str!("./README.md")] diff --git a/crates/games/src/tic_tac_toe/mod.rs b/crates/games/src/tic_tac_toe/mod.rs index d6e7770..185a637 100644 --- a/crates/games/src/tic_tac_toe/mod.rs +++ b/crates/games/src/tic_tac_toe/mod.rs @@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use std::{ - fmt::{Display, Formatter}, + fmt::{Debug, Display, Formatter}, hash::Hash, iter::FilterMap, }; @@ -292,15 +292,21 @@ impl Display for TicTacToe { } } +impl Debug for TicTacToe { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + ::fmt(&self, f) + } +} + #[cfg(test)] mod tests { use super::*; - use game_solver::move_scores; + use game_solver::{move_scores, GameSolveError}; use std::collections::HashMap; fn move_scores_unwrapped(game: &TicTacToe) -> Vec<(TicTacToeMove, isize)> { - move_scores(game, &mut HashMap::new(), None) - .collect::, TicTacToeMoveError>>() + move_scores(game, &mut HashMap::new(), None, &None) + .collect::, GameSolveError>>() .unwrap() } diff --git a/crates/games/src/util/cli/human.rs b/crates/games/src/util/cli/human.rs index 41becbe..eceb6c5 100644 --- a/crates/games/src/util/cli/human.rs +++ b/crates/games/src/util/cli/human.rs @@ -1,29 +1,48 @@ -use std::{fmt::Display, sync::{atomic::{AtomicBool, Ordering}, Arc}, thread}; +use std::{ + fmt::Display, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + thread, time::Duration, +}; -use ratatui::{buffer::Buffer, crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}, layout::{Alignment, Rect}, style::Stylize, symbols::border, text::{Line, Text}, widgets::{block::{Position, Title}, Block, Paragraph, Widget}, DefaultTerminal, Frame}; +use anyhow::Result; +use core::hash::Hash; use game_solver::{ game::{score_to_outcome, Game, GameScoreOutcome, GameState}, par_move_scores, player::{ImpartialPlayer, TwoPlayer}, stats::Stats, }; +use ratatui::{ + buffer::Buffer, + crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}, + layout::{Alignment, Rect}, + style::Stylize, + symbols::border, + text::{Line, Text}, + widgets::{ + block::{Position, Title}, + Block, Paragraph, Widget, + }, + DefaultTerminal, Frame, +}; use std::fmt::Debug; -use core::hash::Hash; -use anyhow::Result; use crate::util::move_score::normalize_move_scores; #[derive(Debug)] struct App { exit: Arc, - stats: Arc + exit_ui: Arc, + stats: Arc, } 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::Release) { + while !self.exit.load(Ordering::SeqCst) || !self.exit_ui.load(Ordering::SeqCst) { terminal.draw(|frame| self.draw(frame))?; self.handle_events()?; } @@ -35,14 +54,16 @@ impl App { } fn handle_events(&mut self) -> Result<()> { - match event::read()? { - // it's important to check that the event is a key press event as - // crossterm also emits key release and repeat events on Windows. - Event::Key(key_event) if key_event.kind == KeyEventKind::Press => { - self.handle_key_event(key_event) - } - _ => {} - }; + if event::poll(Duration::from_millis(100))? { + match event::read()? { + // it's important to check that the event is a key press event as + // crossterm also emits key release and repeat events on Windows. + Event::Key(key_event) if key_event.kind == KeyEventKind::Press => { + self.handle_key_event(key_event) + } + _ => {} + }; + } Ok(()) } @@ -54,13 +75,13 @@ impl App { } fn exit(&mut self) { - self.exit.store(true, Ordering::Acquire); + self.exit.store(true, Ordering::SeqCst); } } impl Widget for &App { fn render(self, area: Rect, buf: &mut Buffer) { - let title = Title::from(" Counter App Tutorial ".bold()); + let title = Title::from(" game-solver ".bold().green()); let instructions = Title::from(Line::from(vec![ " Decrement ".into(), "".blue().bold(), @@ -78,12 +99,67 @@ impl Widget for &App { ) .border_set(border::THICK); - let counter_text = Text::from(vec![Line::from(vec![ - "Cache Hits: ".into(), - self.stats.cache_hits.load(Ordering::Relaxed).to_string().yellow(), - ])]); + let cache_text = Text::from(vec![ + Line::from(vec![ + "States Explored: ".into(), + self.stats + .states_explored + .load(Ordering::Relaxed) + .to_string() + .yellow(), + ]), + Line::from(vec![ + "Cache Hits: ".into(), + self.stats + .cache_hits + .load(Ordering::Relaxed) + .to_string() + .yellow(), + ]), + Line::from(vec![ + "Pruning Cutoffs: ".into(), + self.stats + .pruning_cutoffs + .load(Ordering::Relaxed) + .to_string() + .yellow(), + ]), + Line::from(vec![ + "Terminal Nodes: (winning: ".into(), + self.stats + .terminal_ends + .winning + .load(Ordering::Relaxed) + .to_string() + .yellow(), + ", tie: ".into(), + self.stats + .terminal_ends + .tie + .load(Ordering::Relaxed) + .to_string() + .yellow(), + ", losing: ".into(), + self.stats + .terminal_ends + .losing + .load(Ordering::Relaxed) + .to_string() + .yellow(), + ")".into() + ]), + // TODO: depth + // Line::from(vec![ + // "Max Depth: ".into(), + // self.stats + // .max_depth + // .load(Ordering::Relaxed) + // .to_string() + // .yellow(), + // ]) + ]); - Paragraph::new(counter_text) + Paragraph::new(cache_text) .centered() .block(block) .render(area, buf); @@ -91,26 +167,39 @@ impl Widget for &App { } pub fn human_output< - T: Game + Eq + Hash + Sync + Send + Display + 'static, + T: Game + + Eq + + Hash + + Sync + + Send + + Display + + Debug + + 'static, >( game: T, -) -> Result<()> where +) -> Result<()> +where T::Move: Sync + Send + Display, T::MoveError: Sync + Send + Debug, { let mut terminal = ratatui::init(); + let stats = Arc::new(Stats::default()); + let exit = Arc::new(AtomicBool::new(false)); + let exit_ui = Arc::new(AtomicBool::new(false)); + let mut app = App { exit: exit.clone(), stats: stats.clone(), + exit_ui: exit_ui.clone() }; let game_thread = thread::spawn(move || { - let move_scores = par_move_scores(&game, Some(stats.as_ref())); + let move_scores = par_move_scores(&game, Some(stats.as_ref()), &Some(exit.clone())); let move_scores = normalize_move_scores::(move_scores).unwrap(); }); - + app.run(&mut terminal)?; game_thread.join().unwrap(); ratatui::restore(); diff --git a/crates/games/src/util/cli/mod.rs b/crates/games/src/util/cli/mod.rs index df649ed..d6ea292 100644 --- a/crates/games/src/util/cli/mod.rs +++ b/crates/games/src/util/cli/mod.rs @@ -4,18 +4,25 @@ mod robot; use anyhow::{anyhow, Result}; use game_solver::{ game::{Game, GameState}, - player::{ImpartialPlayer, TwoPlayer} + player::{ImpartialPlayer, TwoPlayer}, }; use human::human_output; use robot::robotic_output; use std::{ any::TypeId, fmt::{Debug, Display}, - hash::Hash + hash::Hash, }; pub fn play< - T: Game + Eq + Hash + Sync + Send + Display + 'static, + T: Game + + Eq + + Hash + + Sync + + Send + + Display + + Debug + + 'static, >( game: T, plain: bool, diff --git a/crates/games/src/util/cli/robot.rs b/crates/games/src/util/cli/robot.rs index fe82007..bbb5a72 100644 --- a/crates/games/src/util/cli/robot.rs +++ b/crates/games/src/util/cli/robot.rs @@ -1,18 +1,25 @@ use game_solver::{ game::{score_to_outcome, Game, GameScoreOutcome}, par_move_scores, - player::{ImpartialPlayer, TwoPlayer} + player::{ImpartialPlayer, TwoPlayer}, }; use std::{ any::TypeId, fmt::{Debug, Display}, - hash::Hash + hash::Hash, }; use crate::util::move_score::normalize_move_scores; pub fn robotic_output< - T: Game + Eq + Hash + Sync + Send + Display + 'static, + T: Game + + Eq + + Hash + + Sync + + Send + + Display + + Debug + + 'static, >( game: T, ) where @@ -29,7 +36,7 @@ pub fn robotic_output< println!("Impartial game; Next player is moving."); } - let move_scores = normalize_move_scores::(par_move_scores(&game, None)).unwrap(); + let move_scores = normalize_move_scores::(par_move_scores(&game, None, &None)).unwrap(); let mut current_move_score = None; for (game_move, score) in move_scores { diff --git a/crates/games/src/util/mod.rs b/crates/games/src/util/mod.rs index 8b421cf..dd9b397 100644 --- a/crates/games/src/util/mod.rs +++ b/crates/games/src/util/mod.rs @@ -2,4 +2,4 @@ pub mod cli; #[cfg(feature = "egui")] pub mod gui; pub mod move_natural; -pub mod move_score; \ No newline at end of file +pub mod move_score; diff --git a/crates/games/src/util/move_score.rs b/crates/games/src/util/move_score.rs index 10cfc9d..345437f 100644 --- a/crates/games/src/util/move_score.rs +++ b/crates/games/src/util/move_score.rs @@ -1,12 +1,14 @@ -use game_solver::{game::Game, CollectedMoves}; +use game_solver::{game::Game, CollectedMoves, GameSolveError}; -pub fn normalize_move_scores(move_scores: CollectedMoves) -> Result, T::MoveError> { +pub fn normalize_move_scores( + move_scores: CollectedMoves, +) -> Result, GameSolveError> { let mut move_scores = move_scores .into_iter() - .collect::, T::MoveError>>()?; + .collect::, GameSolveError>>()?; move_scores.sort_by_key(|m| m.1); move_scores.reverse(); Ok(move_scores) -} \ No newline at end of file +}