diff --git a/.vscode/settings.json b/.vscode/settings.json index 1f7f967..40b76f6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,5 +3,6 @@ "Cargo.toml" ], "rust-analyzer.check.features": "all", - "rust-analyzer.cargo.features": "all" + "rust-analyzer.cargo.features": "all", + "typos.config": "_typos.toml" } \ No newline at end of file diff --git a/_typos.toml b/_typos.toml new file mode 100644 index 0000000..450ea88 --- /dev/null +++ b/_typos.toml @@ -0,0 +1,3 @@ +[default.extend-words] +nd = "nd" +misere = "misere" \ No newline at end of file diff --git a/crates/game-solver/src/game.rs b/crates/game-solver/src/game.rs index 8ffe7e3..3733a8c 100644 --- a/crates/game-solver/src/game.rs +++ b/crates/game-solver/src/game.rs @@ -1,5 +1,7 @@ //! Game trait and related types. +use std::cmp::Ordering; + use crate::player::Player; /// Represents a move outcome @@ -92,20 +94,40 @@ pub trait Game: Clone { /// /// Rather, this function asks if there exists some game in the possible games set /// which has a resolvable, positive or negative, outcome. + /// + /// This function must act in the Next player's best interest. + /// Positive games should have highest priority, then tied games, then lost games. + /// Exact order of what game is returned doesn't matter past its outcome equivalency, + /// as the score is dependent on move count. + /// + /// (If this function returns a losing game when a positive game exists + /// in the set of immediately resolvable games, that is a violation of this + /// function's contract). + /// + /// This function's default implementation is quite slow, + /// and it's encouraged to use a custom implementation. fn find_immediately_resolvable_game(&self) -> Result, Self::MoveError> { + 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) } /// Returns the current state of the game. - /// Used for verifying initialization and isn't commonly called. + /// Used for verifying initialization and is commonly called. /// /// If `Self::STATE_TYPE` isn't None, /// the following implementation can be used: @@ -150,7 +172,31 @@ pub trait Game: Clone { fn player(&self) -> Self::Player; } -/// Utility function to get the upper bound of a game. +/// Utility function to get the upper score bound of a game. +/// +/// Essentially, score computation generally gives some max (usually max moves), +/// and penalizes the score by the amount of moves that have been made, as we're +/// trying to encourage winning in the shortest amount of time - God's algorithm. +/// +/// Note: Despite this returning isize, this function will always be positive. pub fn upper_bound(game: &T) -> isize { game.max_moves().map_or(isize::MAX, |m| m as isize) } + +/// Represents an outcome of a game derived by a score and a valid instance of a game. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum GameScoreOutcome { + Win(usize), + Loss(usize), + Tie +} + +/// Utility function to convert a score to the +/// amount of moves to a win or loss, or a tie. +pub fn score_to_outcome(game: &T, score: isize) -> GameScoreOutcome { + match score.cmp(&0) { + Ordering::Greater => GameScoreOutcome::Win((-score + upper_bound(game)) as usize), + Ordering::Equal => GameScoreOutcome::Tie, + Ordering::Less => GameScoreOutcome::Loss((score + upper_bound(game)) as usize) + } +} diff --git a/crates/game-solver/src/lib.rs b/crates/game-solver/src/lib.rs index 0064947..4c22b3b 100644 --- a/crates/game-solver/src/lib.rs +++ b/crates/game-solver/src/lib.rs @@ -11,6 +11,7 @@ pub mod player; // pub mod reinforcement; pub mod transposition; +use core::panic; #[cfg(feature = "rayon")] use std::hash::BuildHasher; @@ -29,15 +30,32 @@ fn negamax + Eq + Hash>( mut alpha: isize, mut beta: isize, ) -> Result { + // TODO(perf): if find_immediately_resolvable_game satisfies its contract, + // we can ignore this at larger depths. match game.state() { GameState::Playable => (), GameState::Tie => return Ok(0), - GameState::Win(_) => return Ok(0), + GameState::Win(winning_player) => { + // The next player is the winning player - the score should be positive. + if game.player() == winning_player { + return Ok(upper_bound(game) - game.move_count() as isize) + } else { + return Ok(-(upper_bound(game) - game.move_count() as isize)) + } + }, }; // check if this is a winning configuration if let Ok(Some(board)) = game.find_immediately_resolvable_game() { - return Ok(upper_bound(&board) - board.move_count() as isize - 1); + match board.state() { + GameState::Playable => panic!("A resolvable game should not be playable."), + GameState::Tie => return Ok(0), + GameState::Win(winning_player) => if game.player().turn() == winning_player { + return Ok(upper_bound(&board) - board.move_count() as isize); + } else { + return Ok(-(upper_bound(&board) - board.move_count() as isize)); + } + } } // fetch values from the transposition table diff --git a/crates/game-solver/src/player.rs b/crates/game-solver/src/player.rs index d7e2dbd..ae372e5 100644 --- a/crates/game-solver/src/player.rs +++ b/crates/game-solver/src/player.rs @@ -1,5 +1,5 @@ /// Represents a player. -pub trait Player: Sized { +pub trait Player: Sized + Eq { /// The max player count. #[must_use] fn count() -> usize; @@ -12,6 +12,14 @@ pub trait Player: Sized { /// The previous player to play #[must_use] fn previous(self) -> Self; + /// How the player instance 'changes' on the next move. + /// + /// For partizan games, the player doesn't change: + /// Left stays left; right stays right. + /// + /// For impartial games, the player does change: + /// Next turns into previous, and previous turns into next + fn turn(self) -> Self; } /// Represents a two player player. @@ -57,6 +65,10 @@ impl Player for PartizanPlayer { fn previous(self) -> Self { self.next() } + + fn turn(self) -> Self { + self + } } impl TwoPlayer for PartizanPlayer {} @@ -95,26 +107,31 @@ impl Player for ImpartialPlayer { fn previous(self) -> Self { self.next() } + + fn turn(self) -> Self { + self.next() + } } impl TwoPlayer for ImpartialPlayer {} /// Represents a player in an N-player game. -pub struct NPlayerConst(usize); +#[derive(PartialEq, Eq, PartialOrd, Ord)] +pub struct NPlayerPartizanConst(usize); -impl NPlayerConst { - pub fn new(index: usize) -> NPlayerConst { +impl NPlayerPartizanConst { + pub fn new(index: usize) -> NPlayerPartizanConst { assert!(index < N, "Player index {index} >= max player count {N}"); Self(index) } - pub fn new_unchecked(index: usize) -> NPlayerConst { + pub fn new_unchecked(index: usize) -> NPlayerPartizanConst { debug_assert!(index < N, "Player index {index} >= max player count {N}"); Self(index) } } -impl Player for NPlayerConst { +impl Player for NPlayerPartizanConst { fn count() -> usize { N } @@ -135,4 +152,8 @@ impl Player for NPlayerConst { Self::new_unchecked(self.0 - 1) } } + + fn turn(self) -> Self { + self + } } diff --git a/crates/games/src/nim/mod.rs b/crates/games/src/nim/mod.rs index da3f53c..be2c8b6 100644 --- a/crates/games/src/nim/mod.rs +++ b/crates/games/src/nim/mod.rs @@ -18,7 +18,7 @@ use crate::util::{cli::move_failable, move_natural::NaturalMove}; pub struct Nim { heaps: Vec, move_count: usize, - max_score: usize, + max_moves: usize, } type NimMove = NaturalMove<2>; @@ -31,7 +31,7 @@ impl Nim { heaps: heaps.clone(), move_count: 0, // sum of all the heaps is the upper bound for the amount of moves - max_score: heaps.iter().sum::(), + max_moves: heaps.iter().sum::(), } } } @@ -52,14 +52,15 @@ impl Game for Nim { /// where Move is a tuple of the heap index and the number of objects to remove type Move = NimMove; type Iter<'a> = std::vec::IntoIter; - /// Define Nimbers as a zero-sum game + + /// Define Nim as a zero-sum impartial game type Player = ImpartialPlayer; type MoveError = NimMoveError; const STATE_TYPE: Option = Some(StateType::Normal); fn max_moves(&self) -> Option { - Some(self.max_score) + Some(self.max_moves) } fn move_count(&self) -> usize { @@ -115,7 +116,7 @@ impl Game for Nim { impl Display for Nim { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { for (i, heap) in self.heaps.iter().enumerate() { - write!(f, "heap {i}: {heap}")?; + writeln!(f, "Heap {i}: {heap}")?; } Ok(()) @@ -128,7 +129,7 @@ impl Display for Nim { #[derive(Args, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone)] pub struct NimArgs { /// The configuration of the game. For example, 3,5,7 - /// creates a Nimbers game that has three heaps, where each + /// creates a Nim game that has three heaps, where each /// heap has 3, 5, and 7 objects respectively configuration: String, /// Nim moves, ordered as x1-y1 x2-y2 ... @@ -168,3 +169,13 @@ impl TryFrom for Nim { Ok(game) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn max_moves_is_heap_sum() { + assert_eq!(Nim::new(vec![3, 5, 7]).max_moves(), Some(3 + 5 + 7)); + } +} diff --git a/crates/games/src/sprouts/mod.rs b/crates/games/src/sprouts/mod.rs index 91aedfb..374255b 100644 --- a/crates/games/src/sprouts/mod.rs +++ b/crates/games/src/sprouts/mod.rs @@ -1,6 +1,11 @@ #![doc = include_str!("./README.md")] +use game_solver::game::Game; use petgraph::matrix_graph::MatrixGraph; #[derive(Clone)] pub struct Sprouts(MatrixGraph<(), ()>); + +// impl Game for Sprouts { + +// } diff --git a/crates/games/src/util/cli/mod.rs b/crates/games/src/util/cli/mod.rs index fd5caaa..2bddc8b 100644 --- a/crates/games/src/util/cli/mod.rs +++ b/crates/games/src/util/cli/mod.rs @@ -1,14 +1,14 @@ use anyhow::{anyhow, Result}; use core::hash::Hash; use game_solver::{ - game::{Game, GameState}, + game::{score_to_outcome, Game, GameScoreOutcome, GameState}, par_move_scores, - player::TwoPlayer, + player::{ImpartialPlayer, TwoPlayer}, }; -use std::fmt::{Debug, Display}; +use std::{any::TypeId, fmt::{Debug, Display}}; pub fn play< - T: Game + Eq + Hash + Sync + Send + Display + 'static, + T: Game + Eq + Hash + Sync + Send + Display + 'static, >( game: T, ) where @@ -20,7 +20,12 @@ pub fn play< match game.state() { GameState::Playable => { - println!("Player {:?} to move", game.player()); + if TypeId::of::() != TypeId::of::() { + println!("Player {:?} to move", game.player()); + } else { + // TODO: can we assert that game.player() is the next player? + println!("Impartial game; Next player is moving."); + } let move_scores = par_move_scores(&game); let mut move_scores = move_scores @@ -34,7 +39,11 @@ pub fn play< let mut current_move_score = None; for (game_move, score) in move_scores { if current_move_score != Some(score) { - println!("\n\nBest moves @ score {}:", 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); @@ -42,7 +51,13 @@ pub fn play< println!(); } GameState::Tie => println!("No moves left! Game tied!"), - GameState::Win(player) => println!("Player {player:?} won!"), + GameState::Win(player) => { + if TypeId::of::() != TypeId::of::() { + println!("The {player:?} player won!"); + } else { + println!("Player {player:?} won!"); + } + } } } diff --git a/typos.toml b/typos.toml deleted file mode 100644 index 6a371c5..0000000 --- a/typos.toml +++ /dev/null @@ -1,2 +0,0 @@ -[default.extend-words] -nd = "nd" \ No newline at end of file