diff --git a/Cargo.lock b/Cargo.lock index dcf85d1..8bd9aab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -663,6 +663,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "castaway" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.0.98" @@ -1340,6 +1349,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "flate2" version = "1.0.30" @@ -1522,6 +1537,7 @@ version = "0.1.0" dependencies = [ "anyhow", "array2d", + "castaway", "clap", "egui", "egui_commonmark", @@ -1530,6 +1546,7 @@ dependencies = [ "ndarray", "once_cell", "ordinal", + "petgraph", "serde", "serde-big-array", "thiserror", @@ -2386,6 +2403,16 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap", +] + [[package]] name = "pin-project-lite" version = "0.2.13" @@ -2747,6 +2774,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustversion" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" + [[package]] name = "ryu" version = "1.0.15" diff --git a/crates/game-solver/src/game.rs b/crates/game-solver/src/game.rs index 0657e6d..82c40de 100644 --- a/crates/game-solver/src/game.rs +++ b/crates/game-solver/src/game.rs @@ -1,17 +1,6 @@ //! Game trait and related types. -/// Represents a player. -pub trait Player { - /// The max player count. - #[must_use] - fn count() -> usize; - /// The current index of this player starting at 0. - #[must_use] - fn idx(&self) -> usize; - /// The next player to play - #[must_use] - fn next(self) -> Self; -} +use crate::player::Player; /// Represents a move outcome #[derive(PartialEq, Eq, Debug, Clone, Copy, Hash)] @@ -25,68 +14,48 @@ pub enum GameState { Win(P), } -/// Represents a player in a zero-sum (2-player) game. -/// -/// Allows for usage of `negamax` instead of minimax. -#[derive(PartialEq, Eq, Debug, Clone, Copy, Hash)] -pub enum ZeroSumPlayer { - /// The first player. - One, - /// The second player. - Two, +/// Defines the 'state' the game is in. +/// +/// Generally used by a game solver for better optimizations. +/// +/// This is usually wrapped in an Option, as there are many games that do not classify +/// as being under 'Normal' or 'Misere.' (i.e. tic-tac-toe) +#[non_exhaustive] +pub enum StateType { + /// If a game is under 'normal play' convention, the last player to move wins. + /// There are no ties in this variant. + /// + /// Learn more: + Normal, + /// If a game is under 'misere play' convention, the last player to move loses. + /// There are no ties in this variant. + /// + /// Learn more: + Misere } -impl Player for ZeroSumPlayer { - fn count() -> usize { - 2 - } - - fn idx(&self) -> usize { - match self { - Self::One => 0, - Self::Two => 1, - } - } - - fn next(self) -> Self { - match self { - ZeroSumPlayer::One => ZeroSumPlayer::Two, - ZeroSumPlayer::Two => ZeroSumPlayer::One, +impl StateType { + pub fn state(&self, game: &T) -> GameState + where T: Game + { + if game.possible_moves().next().is_none() { + GameState::Win(match self { + Self::Misere => game.player(), + Self::Normal => game.player().previous() + }) + } else { + GameState::Playable } } } -/// Represents a player in an N-player game. -pub struct NPlayerConst(usize); - -impl NPlayerConst { - pub fn new(index: usize) -> NPlayerConst { - assert!(index < N, "Player index {index} >= max player count {N}"); - Self(index) - } - - pub fn new_unchecked(index: usize) -> NPlayerConst { - debug_assert!(index < N, "Player index {index} >= max player count {N}"); - Self(index) - } -} - -impl Player for NPlayerConst { - fn count() -> usize { - N - } - - fn idx(&self) -> usize { - self.0 - } - - fn next(self) -> Self { - // This will always make index < N. - Self::new_unchecked((self.0 + 1) % N) - } -} - /// Represents a combinatorial game. +/// +/// A game has three distinct variants per game: +/// +/// - Game play type: Normal, Misere, Other +/// - Game partiality type: Impartial, Partizan +/// - Game player count: >0 pub trait Game: Clone { /// The type of move this game uses. type Move: Clone; @@ -96,17 +65,56 @@ pub trait Game: Clone { where Self: 'a; - /// The type of player this game uses. - /// There are two types of players: - /// - /// - [`ZeroSumPlayer`] for two-player zero-sum games. - /// - [`NPlayer`] for N-player games. - /// - /// If your game is a two-player zero-sum game, using [`ZeroSumPlayer`] - /// allows `negamax` to be used instead of minimax. + type MoveError; + type Player: Player; - type MoveError; + const STATE_TYPE: Option; + + /// Returns the amount of moves that have been played + fn move_count(&self) -> usize; + + /// Get the max number of moves in a game, if any. + fn max_moves(&self) -> Option; + + /// Makes a move. + fn make_move(&mut self, m: &Self::Move) -> Result<(), Self::MoveError>; + + /// Returns an iterator of all possible moves. + /// + /// If possible, this function should "guess" what the best moves are first. + /// For example, if this is for tic tac toe, it should give the middle move first. + /// Since "better" moves would be found first, this permits more alpha/beta cutoffs. + fn possible_moves(&self) -> Self::Iter<'_>; + + /// Returns a reachable game in one move. + /// + /// Rather, this function asks if there exists some game in the possible games set + /// which has a resolvable, positive or negative, outcome. + fn find_immediately_resolvable_game(&self) -> Result, Self::MoveError> { + 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)); + } + } + + Ok(None) + } + + /// Returns the current state of the game. + /// Used for verifying initialization and isn't commonly called. + /// + /// If `Self::STATE_TYPE` isn't None, + /// the following implementation can be used: + /// + /// ```ignore + /// fn state(&self) -> GameState { + /// Self::STATE_TYPE.unwrap().state(self) + /// } + /// ``` + fn state(&self) -> GameState; /// Returns the player whose turn it is. /// The implementation of this should be @@ -139,39 +147,6 @@ pub trait Game: Clone { /// However, no implementation is provided /// because this does not keep track of the move count. fn player(&self) -> Self::Player; - - // TODO: (move_count/max_moves) allow custom evaluation - - /// Returns the amount of moves that have been played - fn move_count(&self) -> usize; - - /// Get the max number of moves in a game, if any. - fn max_moves(&self) -> Option; - - /// Makes a move. - fn make_move(&mut self, m: &Self::Move) -> Result<(), Self::MoveError>; - - /// Returns an iterator of all possible moves. - /// - /// If possible, this function should "guess" what the best moves are first. - /// For example, if this is for tic tac toe, it should give the middle move first. - /// Since "better" moves would be found first, this permits more alpha/beta cutoffs. - fn possible_moves(&self) -> Self::Iter<'_>; - - // TODO: fn is_immediately_resolvable instead - better optimization for unwinnable games - /// Returns the next state given a move. - /// - /// This has a default implementation and is mainly useful for optimization - - /// this is used at every tree check and should be fast. - fn next_state(&self, m: &Self::Move) -> Result, Self::MoveError> { - let mut new_self = self.clone(); - new_self.make_move(m)?; - Ok(new_self.state()) - } - - /// Returns the current state of the game. - /// Used for verifying initialization and isn't commonly called. - fn state(&self) -> GameState; } /// Utility function to get the upper bound of a game. diff --git a/crates/game-solver/src/lib.rs b/crates/game-solver/src/lib.rs index 2fc66d0..e8b4b96 100644 --- a/crates/game-solver/src/lib.rs +++ b/crates/game-solver/src/lib.rs @@ -5,6 +5,7 @@ //! a great place to start. pub mod game; +pub mod player; // TODO: reinforcement // #[cfg(feature = "reinforcement")] // pub mod reinforcement; @@ -14,14 +15,15 @@ pub mod transposition; use std::hash::BuildHasher; use game::{upper_bound, GameState}; +use player::TwoPlayer; -use crate::game::{Game, ZeroSumPlayer}; +use crate::game::Game; use crate::transposition::{Score, TranspositionTable}; use std::hash::Hash; /// 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, @@ -34,14 +36,8 @@ fn negamax + Eq + Hash>( }; // check if this is a winning configuration - // TODO: allow overloading of this - some kind of game.can_win_next() - for m in &mut game.possible_moves() { - // TODO: ties? - if let GameState::Win(_) = game.next_state(&m)? { - let mut board = game.clone(); - board.make_move(&m)?; - return Ok(upper_bound(&board) - game.move_count() as isize - 1); - } + if let Ok(Some(board)) = game.find_immediately_resolvable_game() { + return Ok(upper_bound(&board) - board.move_count() as isize - 1); } // fetch values from the transposition table @@ -112,7 +108,7 @@ 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, ) -> Result { @@ -144,7 +140,7 @@ 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, ) -> impl Iterator> + 'a { @@ -169,9 +165,8 @@ type CollectedMoves = Vec::Move, isize), ::Mov /// /// A vector of tuples of the form `(move, score)`. #[cfg(feature = "rayon")] -pub fn par_move_scores_with_hasher(game: &T) -> CollectedMoves +pub fn par_move_scores_with_hasher + Eq + Hash + Sync + Send + 'static, S>(game: &T) -> CollectedMoves where - T: Game + Eq + Hash + Sync + Send + 'static, T::Move: Sync + Send, T::MoveError: Sync + Send, S: BuildHasher + Default + Sync + Send + Clone + 'static, @@ -208,9 +203,8 @@ where /// /// A vector of tuples of the form `(move, score)`. #[cfg(feature = "rayon")] -pub fn par_move_scores(game: &T) -> CollectedMoves +pub fn par_move_scores + Eq + Hash + Sync + Send + 'static>(game: &T) -> CollectedMoves where - T: Game + Eq + Hash + Sync + Send + 'static, T::Move: Sync + Send, T::MoveError: Sync + Send, { diff --git a/crates/game-solver/src/player.rs b/crates/game-solver/src/player.rs new file mode 100644 index 0000000..15984be --- /dev/null +++ b/crates/game-solver/src/player.rs @@ -0,0 +1,138 @@ +/// Represents a player. +pub trait Player: Sized { + /// The max player count. + #[must_use] + fn count() -> usize; + /// The current index of this player starting at 0. + #[must_use] + fn idx(&self) -> usize; + /// The next player to play + #[must_use] + fn next(self) -> Self; + /// The previous player to play + #[must_use] + fn previous(self) -> Self; +} + +/// Represents a two player player. +pub trait TwoPlayer: Player { + /// Gets the other player + #[must_use] + fn other(self) -> Self { + self.next() + } +} + +/// Represents a player in a zero-sum (2-player) game, +/// where the game is partizan. That is, +/// a player can affect the `Game::possible_moves` function, +/// or players have different winning outcomes. +#[derive(PartialEq, Eq, Debug, Clone, Copy, Hash)] +pub enum PartizanPlayer { + /// The first player. + Left, + /// The second player. + Right, +} + +impl Player for PartizanPlayer { + fn count() -> usize { + 2 + } + + fn idx(&self) -> usize { + match self { + Self::Left => 0, + Self::Right => 1, + } + } + + fn next(self) -> Self { + match self { + Self::Left => Self::Right, + Self::Right => Self::Left, + } + } + + fn previous(self) -> Self { + self.next() + } +} + +impl TwoPlayer for PartizanPlayer {} + +/// Represents a player in a zero-sum (2-player) game, +/// where the game is impartial. That is, +/// the only difference between players is who goes first. +#[derive(PartialEq, Eq, Debug, Clone, Copy, Hash)] +pub enum ImpartialPlayer { + /// The player that will play on the current game state, + Next, + /// The player that has played previous to this game state + /// (or will play after Next). + Previous +} + +impl Player for ImpartialPlayer { + fn count() -> usize { + 2 + } + + fn idx(&self) -> usize { + match self { + Self::Next => 0, + Self::Previous => 1, + } + } + + fn next(self) -> Self { + match self { + Self::Next => Self::Previous, + Self::Previous => Self::Next, + } + } + + fn previous(self) -> Self { + self.next() + } +} + +impl TwoPlayer for ImpartialPlayer {} + +/// Represents a player in an N-player game. +pub struct NPlayerConst(usize); + +impl NPlayerConst { + pub fn new(index: usize) -> NPlayerConst { + assert!(index < N, "Player index {index} >= max player count {N}"); + Self(index) + } + + pub fn new_unchecked(index: usize) -> NPlayerConst { + debug_assert!(index < N, "Player index {index} >= max player count {N}"); + Self(index) + } +} + +impl Player for NPlayerConst { + fn count() -> usize { + N + } + + fn idx(&self) -> usize { + self.0 + } + + fn next(self) -> Self { + // This will always make index < N. + Self::new_unchecked((self.0 + 1) % N) + } + + fn previous(self) -> Self { + if self.0 == 0 { + Self::new_unchecked(N - 1) + } else { + Self::new_unchecked(self.0 - 1) + } + } +} diff --git a/crates/games/Cargo.toml b/crates/games/Cargo.toml index c6b1690..fce22ea 100644 --- a/crates/games/Cargo.toml +++ b/crates/games/Cargo.toml @@ -18,6 +18,8 @@ once_cell = "1.19.0" egui = { version = "0.28", optional = true } egui_commonmark = { version = "0.17.0", optional = true, features = ["macros"] } thiserror = "1.0.63" +petgraph = "0.6.5" +castaway = "0.2.3" [features] "egui" = ["dep:egui", "dep:egui_commonmark"] diff --git a/crates/games/src/chomp/mod.rs b/crates/games/src/chomp/mod.rs index 4ccbbf9..d7171d3 100644 --- a/crates/games/src/chomp/mod.rs +++ b/crates/games/src/chomp/mod.rs @@ -5,7 +5,7 @@ pub mod gui; use anyhow::Error; use array2d::Array2D; use clap::Args; -use game_solver::game::{Game, GameState, ZeroSumPlayer}; +use game_solver::{game::{Game, GameState, StateType}, player::ImpartialPlayer}; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -77,21 +77,15 @@ pub type ChompMove = NaturalMove<2>; impl Game for Chomp { type Move = ChompMove; type Iter<'a> = std::vec::IntoIter; - type Player = ZeroSumPlayer; + type Player = ImpartialPlayer; type MoveError = ChompMoveError; + const STATE_TYPE: Option = Some(StateType::Misere); + fn max_moves(&self) -> Option { Some(self.width * self.height) } - fn player(&self) -> Self::Player { - if self.move_count % 2 == 0 { - ZeroSumPlayer::One - } else { - ZeroSumPlayer::Two - } - } - fn move_count(&self) -> usize { self.move_count } @@ -122,12 +116,12 @@ impl Game for Chomp { moves.into_iter() } + fn player(&self) -> Self::Player { + ImpartialPlayer::Next + } + fn state(&self) -> GameState { - if self.possible_moves().len() == 0 { - GameState::Win(self.player()) - } else { - GameState::Playable - } + Self::STATE_TYPE.unwrap().state(self) } } diff --git a/crates/games/src/domineering/mod.rs b/crates/games/src/domineering/mod.rs index 6fcab75..08feed1 100644 --- a/crates/games/src/domineering/mod.rs +++ b/crates/games/src/domineering/mod.rs @@ -5,7 +5,7 @@ pub mod gui; use anyhow::Error; use array2d::Array2D; use clap::Args; -use game_solver::game::{Game, GameState, Player, ZeroSumPlayer}; +use game_solver::{game::{Game, GameState, StateType}, player::{PartizanPlayer, Player}}; use serde::{Deserialize, Serialize}; use std::{ fmt::{Debug, Display, Formatter}, @@ -63,9 +63,9 @@ impl Domineering { #[derive(Error, Debug, Clone)] pub enum DomineeringMoveError { #[error("While no domino is present at {0}, player {1:?} can not move at {0} because a domino is in way of placement.")] - BlockingAdjacent(DomineeringMove, ZeroSumPlayer), + BlockingAdjacent(DomineeringMove, PartizanPlayer), #[error("Player {1:?} can not move at {0} because a domino is already at {0}.")] - BlockingCurrent(DomineeringMove, ZeroSumPlayer), + BlockingCurrent(DomineeringMove, PartizanPlayer), } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] @@ -109,28 +109,22 @@ impl Domineering { impl Game for Domineering { type Move = DomineeringMove; type Iter<'a> = std::vec::IntoIter; - type Player = ZeroSumPlayer; + type Player = PartizanPlayer; type MoveError = DomineeringMoveError; + const STATE_TYPE: Option = Some(StateType::Normal); + fn max_moves(&self) -> Option { Some(WIDTH * HEIGHT) } - fn player(&self) -> Self::Player { - if self.move_count % 2 == 0 { - ZeroSumPlayer::One - } else { - ZeroSumPlayer::Two - } - } - fn move_count(&self) -> usize { self.move_count } fn make_move(&mut self, m: &Self::Move) -> Result<(), Self::MoveError> { if *self.board.get(m.0, m.1).unwrap() { - self.place(m, if self.player() == ZeroSumPlayer::One { + self.place(m, if self.player() == PartizanPlayer::Left { self.primary_orientation } else { self.primary_orientation.turn() @@ -148,7 +142,7 @@ impl Game for Domineering Self::Iter<'_> { let mut moves = Vec::new(); - let orientation = if self.player() == ZeroSumPlayer::One { + let orientation = if self.player() == PartizanPlayer::Left { self.primary_orientation } else { self.primary_orientation.turn() @@ -185,6 +179,14 @@ impl Game for Domineering Self::Player { + if self.move_count % 2 == 0 { + PartizanPlayer::Left + } else { + PartizanPlayer::Right + } + } } impl Display for Domineering { @@ -242,7 +244,7 @@ mod tests { use super::*; /// Get the winner of a generic configuration of domineering - fn winner(orientation: Orientation) -> Option { + fn winner(orientation: Orientation) -> Option { let game = Domineering::::new_orientation(orientation); let mut move_scores = move_scores(&game, &mut HashMap::new()) .collect::, DomineeringMoveError>>() @@ -254,20 +256,20 @@ mod tests { move_scores.sort_by_key(|m| m.1); move_scores.reverse(); if move_scores[0].1 > 0 { - Some(ZeroSumPlayer::One) + Some(PartizanPlayer::Left) } else { - Some(ZeroSumPlayer::Two) + Some(PartizanPlayer::Right) } } } #[test] fn test_wins() { - assert_eq!(winner::<5, 5>(Orientation::Horizontal), Some(ZeroSumPlayer::Two)); - assert_eq!(winner::<4, 4>(Orientation::Horizontal), Some(ZeroSumPlayer::One)); - assert_eq!(winner::<3, 3>(Orientation::Horizontal), Some(ZeroSumPlayer::One)); - assert_eq!(winner::<13, 2>(Orientation::Horizontal), Some(ZeroSumPlayer::Two)); - assert_eq!(winner::<11, 2>(Orientation::Horizontal), Some(ZeroSumPlayer::One)); + assert_eq!(winner::<5, 5>(Orientation::Horizontal), Some(PartizanPlayer::Right)); + assert_eq!(winner::<4, 4>(Orientation::Horizontal), Some(PartizanPlayer::Left)); + assert_eq!(winner::<3, 3>(Orientation::Horizontal), Some(PartizanPlayer::Left)); + assert_eq!(winner::<13, 2>(Orientation::Horizontal), Some(PartizanPlayer::Right)); + assert_eq!(winner::<11, 2>(Orientation::Horizontal), Some(PartizanPlayer::Left)); } #[test] diff --git a/crates/games/src/lib.rs b/crates/games/src/lib.rs index 757adbf..6f71eca 100644 --- a/crates/games/src/lib.rs +++ b/crates/games/src/lib.rs @@ -6,6 +6,7 @@ pub mod nim; pub mod order_and_chaos; pub mod reversi; pub mod tic_tac_toe; +pub mod sprouts; use crate::{ chomp::ChompArgs, domineering::DomineeringArgs, nim::NimArgs, diff --git a/crates/games/src/nim/mod.rs b/crates/games/src/nim/mod.rs index a584769..6bb5e2a 100644 --- a/crates/games/src/nim/mod.rs +++ b/crates/games/src/nim/mod.rs @@ -4,7 +4,7 @@ pub mod gui; use anyhow::Error; use clap::Args; -use game_solver::game::{Game, GameState, Player, ZeroSumPlayer}; +use game_solver::{game::{Game, GameState, StateType}, player::ImpartialPlayer}; use serde::{Deserialize, Serialize}; use std::{fmt::Display, hash::Hash}; use thiserror::Error; @@ -50,21 +50,15 @@ impl Game for Nim { type Move = NimMove; type Iter<'a> = std::vec::IntoIter; /// Define Nimbers as a zero-sum game - type Player = ZeroSumPlayer; + type Player = ImpartialPlayer; type MoveError = NimMoveError; + const STATE_TYPE: Option = Some(StateType::Normal); + fn max_moves(&self) -> Option { Some(self.max_score) } - fn player(&self) -> Self::Player { - if self.move_count % 2 == 0 { - ZeroSumPlayer::One - } else { - ZeroSumPlayer::Two - } - } - fn move_count(&self) -> usize { self.move_count } @@ -107,11 +101,11 @@ impl Game for Nim { } fn state(&self) -> GameState { - if self.possible_moves().len() == 0 { - GameState::Win(self.player().next()) - } else { - GameState::Playable - } + Self::STATE_TYPE.unwrap().state(self) + } + + fn player(&self) -> Self::Player { + ImpartialPlayer::Next } } diff --git a/crates/games/src/order_and_chaos/mod.rs b/crates/games/src/order_and_chaos/mod.rs index b85452c..17d7016 100644 --- a/crates/games/src/order_and_chaos/mod.rs +++ b/crates/games/src/order_and_chaos/mod.rs @@ -5,7 +5,7 @@ pub mod gui; use anyhow::{anyhow, Error}; use array2d::Array2D; use clap::Args; -use game_solver::game::{Game, GameState, ZeroSumPlayer}; +use game_solver::{game::{Game, GameState, StateType}, player::PartizanPlayer}; use serde::{Deserialize, Serialize}; use std::{ fmt::{Display, Formatter}, @@ -88,21 +88,15 @@ impl Game for OrderAndChaos { type Move = OrderAndChaosMove; type Iter<'a> = std::vec::IntoIter; /// Define Nimbers as a zero-sum game - type Player = ZeroSumPlayer; + type Player = PartizanPlayer; type MoveError = OrderAndChaosMoveError; + const STATE_TYPE: Option = None; + fn max_moves(&self) -> Option { Some(WIDTH * HEIGHT) } - fn player(&self) -> ZeroSumPlayer { - if self.move_count % 2 == 0 { - ZeroSumPlayer::One - } else { - ZeroSumPlayer::Two - } - } - fn move_count(&self) -> usize { self.move_count } @@ -147,58 +141,18 @@ impl Game for OrderAndChaos { // a move is winning if the next player // has no possible moves to make (normal play for Nim) - fn next_state(&self, m: &Self::Move) -> Result, Self::MoveError> { - let mut board = self.clone(); - board.make_move(m)?; - let found = 'found: { - let ((row, column), square) = m.0; - - // check for horizontal win - let mut count = 0; - let mut mistakes = 0; - 'horiz: for i in 0..WIDTH { - if board.board[(row, i)] == Some(square) { - count += 1; - if count == WIN_LENGTH { - break 'found true; - } - } else { - count = 0; - mistakes += 1; - if mistakes > WIDTH - WIN_LENGTH { - break 'horiz; - } - } - } - - // check for vertical win - let mut count = 0; - let mut mistakes = 0; - 'vert: for i in 0..HEIGHT { - if board.board[(i, column)] == Some(square) { - count += 1; - if count == WIN_LENGTH { - break 'found true; - } - } else { - count = 0; - mistakes += 1; - if mistakes > HEIGHT - WIN_LENGTH { - break 'vert; - } - } - } - - // check for diagonal win - top left to bottom right - let mut count = 0; - let mut mistakes = 0; - let origins = [(0, 0), (1, 0), (0, 1)]; - - 'diag: for (row, column) in &origins { - let mut row = *row; - let mut column = *column; - while row < HEIGHT && column < WIDTH { - if board.board[(row, column)] == Some(square) { + fn find_immediately_resolvable_game(&self) -> Result, Self::MoveError> { + for m in &mut self.possible_moves() { + let mut board = self.clone(); + board.make_move(&m)?; + let found = 'found: { + let ((row, column), square) = m.0; + + // check for horizontal win + let mut count = 0; + let mut mistakes = 0; + 'horiz: for i in 0..WIDTH { + if board.board[(row, i)] == Some(square) { count += 1; if count == WIN_LENGTH { break 'found true; @@ -206,25 +160,17 @@ impl Game for OrderAndChaos { } else { count = 0; mistakes += 1; - if mistakes > HEIGHT - WIN_LENGTH { - break 'diag; + if mistakes > WIDTH - WIN_LENGTH { + break 'horiz; } } - row += 1; - column += 1; } - } - // check for diagonal win - top right to bottom left - let mut count = 0; - let mut mistakes = 0; - let origins = [(0, WIDTH - 1), (1, WIDTH - 1), (0, WIDTH - 2)]; - - 'diag: for (row, column) in &origins { - let mut row = *row; - let mut column = *column; - while row < HEIGHT { - if board.board[(row, column)] == Some(square) { + // check for vertical win + let mut count = 0; + let mut mistakes = 0; + 'vert: for i in 0..HEIGHT { + if board.board[(i, column)] == Some(square) { count += 1; if count == WIN_LENGTH { break 'found true; @@ -233,32 +179,90 @@ impl Game for OrderAndChaos { count = 0; mistakes += 1; if mistakes > HEIGHT - WIN_LENGTH { - break 'diag; + break 'vert; } } - row += 1; - if column == 0 { - break; + } + + // check for diagonal win - top left to bottom right + let mut count = 0; + let mut mistakes = 0; + let origins = [(0, 0), (1, 0), (0, 1)]; + + 'diag: for (row, column) in &origins { + let mut row = *row; + let mut column = *column; + while row < HEIGHT && column < WIDTH { + if board.board[(row, column)] == Some(square) { + count += 1; + if count == WIN_LENGTH { + break 'found true; + } + } else { + count = 0; + mistakes += 1; + if mistakes > HEIGHT - WIN_LENGTH { + break 'diag; + } + } + row += 1; + column += 1; } - column -= 1; } - } - false - }; + // check for diagonal win - top right to bottom left + let mut count = 0; + let mut mistakes = 0; + let origins = [(0, WIDTH - 1), (1, WIDTH - 1), (0, WIDTH - 2)]; + + 'diag: for (row, column) in &origins { + let mut row = *row; + let mut column = *column; + while row < HEIGHT { + if board.board[(row, column)] == Some(square) { + count += 1; + if count == WIN_LENGTH { + break 'found true; + } + } else { + count = 0; + mistakes += 1; + if mistakes > HEIGHT - WIN_LENGTH { + break 'diag; + } + } + row += 1; + if column == 0 { + break; + } + column -= 1; + } + } - Ok(if found { - GameState::Win(ZeroSumPlayer::One) - } else if board.possible_moves().next().is_none() { - GameState::Win(ZeroSumPlayer::Two) - } else { - GameState::Playable - }) + false + }; + + if found { + return Ok(Some(board)); + } else if board.possible_moves().next().is_none() { + return Ok(Some(board)); + } + } + + Ok(None) } fn state(&self) -> GameState { unimplemented!() } + + fn player(&self) -> PartizanPlayer { + if self.move_count % 2 == 0 { + PartizanPlayer::Left + } else { + PartizanPlayer::Right + } + } } impl Display for OrderAndChaos { diff --git a/crates/games/src/reversi/mod.rs b/crates/games/src/reversi/mod.rs index 2a0c3f3..f39e075 100644 --- a/crates/games/src/reversi/mod.rs +++ b/crates/games/src/reversi/mod.rs @@ -6,7 +6,7 @@ pub mod gui; use anyhow::Error; use array2d::Array2D; use clap::Args; -use game_solver::game::{Game, GameState, Player, ZeroSumPlayer}; +use game_solver::{game::{Game, GameState, StateType}, player::{PartizanPlayer, Player}}; use serde::{Deserialize, Serialize}; use std::fmt; use std::hash::Hash; @@ -21,7 +21,7 @@ pub type ReversiMove = NaturalMove<2>; #[derive(Clone, Hash, Eq, PartialEq)] pub struct Reversi { /// None if empty, Some(Player) if occupied - board: Array2D>, + board: Array2D>, move_count: usize, } @@ -31,16 +31,16 @@ impl Reversi { // set middle squares to occupied: board - .set(WIDTH / 2 - 1, HEIGHT / 2 - 1, Some(ZeroSumPlayer::One)) + .set(WIDTH / 2 - 1, HEIGHT / 2 - 1, Some(PartizanPlayer::Left)) .unwrap(); board - .set(WIDTH / 2, HEIGHT / 2, Some(ZeroSumPlayer::One)) + .set(WIDTH / 2, HEIGHT / 2, Some(PartizanPlayer::Left)) .unwrap(); board - .set(WIDTH / 2 - 1, HEIGHT / 2, Some(ZeroSumPlayer::Two)) + .set(WIDTH / 2 - 1, HEIGHT / 2, Some(PartizanPlayer::Right)) .unwrap(); board - .set(WIDTH / 2, HEIGHT / 2 - 1, Some(ZeroSumPlayer::Two)) + .set(WIDTH / 2, HEIGHT / 2 - 1, Some(PartizanPlayer::Right)) .unwrap(); Self { @@ -127,9 +127,11 @@ impl Reversi { impl Game for Reversi { type Move = ReversiMove; type Iter<'a> = std::vec::IntoIter; - type Player = ZeroSumPlayer; + type Player = PartizanPlayer; type MoveError = array2d::Error; + const STATE_TYPE: Option = None; + fn max_moves(&self) -> Option { Some(WIDTH * HEIGHT) } @@ -138,14 +140,6 @@ impl Game for Reversi { self.move_count } - fn player(&self) -> ZeroSumPlayer { - if self.move_count % 2 == 0 { - ZeroSumPlayer::One - } else { - ZeroSumPlayer::Two - } - } - fn make_move(&mut self, m: &Self::Move) -> Result<(), Self::MoveError> { let move_set = self.is_valid_move(m).unwrap(); @@ -183,25 +177,33 @@ impl Game for Reversi { for x in 0..WIDTH { for y in 0..HEIGHT { match *self.board.get(x, y).unwrap() { - Some(ZeroSumPlayer::One) => player_one_count += 1, - Some(ZeroSumPlayer::Two) => player_two_count += 1, + Some(PartizanPlayer::Left) => player_one_count += 1, + Some(PartizanPlayer::Right) => player_two_count += 1, None => (), } } } match player_one_count.cmp(&player_two_count) { - std::cmp::Ordering::Greater => GameState::Win(ZeroSumPlayer::One), - std::cmp::Ordering::Less => GameState::Win(ZeroSumPlayer::Two), + std::cmp::Ordering::Greater => GameState::Win(PartizanPlayer::Left), + std::cmp::Ordering::Less => GameState::Win(PartizanPlayer::Right), std::cmp::Ordering::Equal => GameState::Tie, } } + + fn player(&self) -> PartizanPlayer { + if self.move_count % 2 == 0 { + PartizanPlayer::Left + } else { + PartizanPlayer::Right + } + } } -fn player_to_char(player: Option) -> char { +fn player_to_char(player: Option) -> char { match player { - Some(ZeroSumPlayer::One) => 'X', - Some(ZeroSumPlayer::Two) => 'O', + Some(PartizanPlayer::Left) => 'X', + Some(PartizanPlayer::Right) => 'O', None => '-', } } diff --git a/crates/games/src/sprouts/README.md b/crates/games/src/sprouts/README.md new file mode 100644 index 0000000..9200025 --- /dev/null +++ b/crates/games/src/sprouts/README.md @@ -0,0 +1,6 @@ +Sprouts is a two-player impartial game that is represented by an unlabeled, undirected graph. + +We introduce a generalization of sprouts (n, k), where n is the number of starting sprouts, +and k is the max amount of connections per sprout. The default variant is (n, 3). + +More information: diff --git a/crates/games/src/sprouts/mod.rs b/crates/games/src/sprouts/mod.rs new file mode 100644 index 0000000..91aedfb --- /dev/null +++ b/crates/games/src/sprouts/mod.rs @@ -0,0 +1,6 @@ +#![doc = include_str!("./README.md")] + +use petgraph::matrix_graph::MatrixGraph; + +#[derive(Clone)] +pub struct Sprouts(MatrixGraph<(), ()>); diff --git a/crates/games/src/tic_tac_toe/mod.rs b/crates/games/src/tic_tac_toe/mod.rs index ea8b61b..a30704a 100644 --- a/crates/games/src/tic_tac_toe/mod.rs +++ b/crates/games/src/tic_tac_toe/mod.rs @@ -4,7 +4,7 @@ pub mod gui; use anyhow::{anyhow, Error}; use clap::Args; -use game_solver::game::{Game, GameState, Player, ZeroSumPlayer}; +use game_solver::{game::{Game, GameState, StateType}, player::{PartizanPlayer, Player}}; use itertools::Itertools; use ndarray::{iter::IndexedIter, ArrayD, Dim, Dimension, IntoDimension, IxDyn, IxDynImpl}; use serde::{Deserialize, Serialize}; @@ -160,9 +160,11 @@ impl Game for TicTacToe { IndexedIter<'a, Square, Dim>, fn((Dim, &Square)) -> Option, >; - type Player = ZeroSumPlayer; + type Player = PartizanPlayer; type MoveError = TicTacToeMoveError; + const STATE_TYPE: Option = None; + fn max_moves(&self) -> Option { Some(self.size.pow(self.dim as u32)) } @@ -171,14 +173,6 @@ impl Game for TicTacToe { self.move_count } - fn player(&self) -> Self::Player { - if self.move_count % 2 == 0 { - ZeroSumPlayer::One - } else { - ZeroSumPlayer::Two - } - } - fn state(&self) -> GameState { // check if tie if Some(self.move_count()) == self.max_moves() { @@ -204,7 +198,7 @@ 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() == ZeroSumPlayer::One { + let square = if self.player() == PartizanPlayer::Left { Square::X } else { Square::O @@ -230,16 +224,30 @@ impl Game for TicTacToe { }) } - fn next_state(&self, m: &Self::Move) -> Result, Self::MoveError> { + fn find_immediately_resolvable_game(&self) -> Result, Self::MoveError> { // check if the amount of moves is less than (size * 2) - 1 // if it is, then it's impossible to win if self.move_count + 1 < self.size * 2 - 1 { - return Ok(GameState::Playable); + return Ok(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)); + } } - let mut board = self.clone(); - board.make_move(m)?; - Ok(board.state()) + Ok(None) + } + + fn player(&self) -> Self::Player { + if self.move_count % 2 == 0 { + PartizanPlayer::Left + } else { + PartizanPlayer::Right + } } } @@ -335,7 +343,7 @@ mod tests { game.make_move(&TicTacToeMove(vec![2, 0].into_dimension())) .unwrap(); // X - assert!(game.state() == GameState::Win(ZeroSumPlayer::One)); + assert!(game.state() == GameState::Win(PartizanPlayer::Left)); } #[test] @@ -373,7 +381,7 @@ mod tests { game.make_move(&TicTacToeMove(vec![0, 2, 0].into_dimension())) .unwrap(); // X - assert!(game.state() == GameState::Win(ZeroSumPlayer::One)); + assert!(game.state() == GameState::Win(PartizanPlayer::Left)); } #[test] diff --git a/crates/games/src/util/cli/mod.rs b/crates/games/src/util/cli/mod.rs index d37bb80..6d49b5a 100644 --- a/crates/games/src/util/cli/mod.rs +++ b/crates/games/src/util/cli/mod.rs @@ -1,14 +1,13 @@ use anyhow::{anyhow, Result}; use core::hash::Hash; use game_solver::{ - game::{Game, GameState, ZeroSumPlayer}, - par_move_scores, + game::{Game, GameState}, + par_move_scores, player::TwoPlayer, }; use std::fmt::{Debug, Display}; -pub fn play(game: T) +pub fn play + Eq + Hash + Sync + Send + Display + 'static>(game: T) where - T: Game + Eq + Hash + Sync + Send + Display + 'static, T::Move: Sync + Send + Display, T::MoveError: Sync + Send + Debug, { diff --git a/rust-toolchain b/rust-toolchain new file mode 100644 index 0000000..414f779 --- /dev/null +++ b/rust-toolchain @@ -0,0 +1,9 @@ +# If you see this, run "rustup self update" to get rustup 1.23 or newer. + +# NOTE: above comment is for older `rustup` (before TOML support was added), +# which will treat the first line as the toolchain name, and therefore show it +# to the user in the error, instead of "error: invalid channel name '[toolchain]'". + +[toolchain] +channel = "1.81" +components = [ "rustfmt", "clippy" ]