diff --git a/crates/game-solver/src/game.rs b/crates/game-solver/src/game.rs index 0657e6d..48127cd 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,67 +14,6 @@ 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, -} - -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, - } - } -} - -/// 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. pub trait Game: Clone { /// The type of move this game uses. @@ -97,10 +25,10 @@ pub trait Game: Clone { Self: 'a; /// The type of player this game uses. - /// There are two types of players: + /// There are three types of players: /// /// - [`ZeroSumPlayer`] for two-player zero-sum games. - /// - [`NPlayer`] for N-player games. + /// - [`NPlayer`] for N-player games where N > 2. /// /// If your game is a two-player zero-sum game, using [`ZeroSumPlayer`] /// allows `negamax` to be used instead of minimax. @@ -108,6 +36,39 @@ pub trait Game: Clone { type MoveError; + /// 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; +} + +pub trait PartizanGame: Game { /// Returns the player whose turn it is. /// The implementation of this should be /// similar to either @@ -139,39 +100,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..35502fd 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; @@ -15,7 +16,8 @@ use std::hash::BuildHasher; use game::{upper_bound, GameState}; -use crate::game::{Game, ZeroSumPlayer}; +use crate::game::Game; +use crate::player::ZeroSumPlayer; use crate::transposition::{Score, TranspositionTable}; use std::hash::Hash; diff --git a/crates/game-solver/src/player.rs b/crates/game-solver/src/player.rs new file mode 100644 index 0000000..5adc5cc --- /dev/null +++ b/crates/game-solver/src/player.rs @@ -0,0 +1,105 @@ +/// 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; +} + +/// 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. + Left, + /// The second player. + Right, +} + +impl Player for ZeroSumPlayer { + 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, + } + } +} + +/// Represents a player in a zero-sum (2-player) game, +/// where the game is impartial. That is, +/// a player does not affect the `Game::possible_moves` function. +#[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, + } + } +} + +/// 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) + } +} \ No newline at end of file diff --git a/crates/games/src/chomp/mod.rs b/crates/games/src/chomp/mod.rs index 4ccbbf9..dd72a3a 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, PartizanGame}, player::ZeroSumPlayer}; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -74,6 +74,16 @@ pub enum ChompMoveError { pub type ChompMove = NaturalMove<2>; +impl PartizanGame for Chomp { + fn player(&self) -> Self::Player { + if self.move_count % 2 == 0 { + ZeroSumPlayer::Left + } else { + ZeroSumPlayer::Right + } + } +} + impl Game for Chomp { type Move = ChompMove; type Iter<'a> = std::vec::IntoIter; @@ -84,14 +94,6 @@ impl Game for Chomp { 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 } diff --git a/crates/games/src/domineering/mod.rs b/crates/games/src/domineering/mod.rs index 6fcab75..7404421 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, PartizanGame}, player::{Player, ZeroSumPlayer}}; use serde::{Deserialize, Serialize}; use std::{ fmt::{Debug, Display, Formatter}, @@ -106,6 +106,16 @@ impl Domineering { } } +impl PartizanGame for Domineering { + fn player(&self) -> Self::Player { + if self.move_count % 2 == 0 { + ZeroSumPlayer::Left + } else { + ZeroSumPlayer::Right + } + } +} + impl Game for Domineering { type Move = DomineeringMove; type Iter<'a> = std::vec::IntoIter; @@ -116,21 +126,13 @@ impl Game for Domineering 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() == ZeroSumPlayer::Left { self.primary_orientation } else { self.primary_orientation.turn() @@ -148,7 +150,7 @@ impl Game for Domineering Self::Iter<'_> { let mut moves = Vec::new(); - let orientation = if self.player() == ZeroSumPlayer::One { + let orientation = if self.player() == ZeroSumPlayer::Left { self.primary_orientation } else { self.primary_orientation.turn() @@ -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(ZeroSumPlayer::Left) } else { - Some(ZeroSumPlayer::Two) + Some(ZeroSumPlayer::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(ZeroSumPlayer::Right)); + assert_eq!(winner::<4, 4>(Orientation::Horizontal), Some(ZeroSumPlayer::Left)); + assert_eq!(winner::<3, 3>(Orientation::Horizontal), Some(ZeroSumPlayer::Left)); + assert_eq!(winner::<13, 2>(Orientation::Horizontal), Some(ZeroSumPlayer::Right)); + assert_eq!(winner::<11, 2>(Orientation::Horizontal), Some(ZeroSumPlayer::Left)); } #[test] diff --git a/crates/games/src/nim/mod.rs b/crates/games/src/nim/mod.rs index a584769..4d3a381 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}, player::ImpartialPlayer}; use serde::{Deserialize, Serialize}; use std::{fmt::Display, hash::Hash}; use thiserror::Error; @@ -50,21 +50,13 @@ 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; 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 } @@ -108,7 +100,7 @@ impl Game for Nim { fn state(&self) -> GameState { if self.possible_moves().len() == 0 { - GameState::Win(self.player().next()) + GameState::Win(ImpartialPlayer::Previous) } else { GameState::Playable } diff --git a/crates/games/src/order_and_chaos/mod.rs b/crates/games/src/order_and_chaos/mod.rs index b85452c..57ac279 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, PartizanGame}, player::ZeroSumPlayer}; use serde::{Deserialize, Serialize}; use std::{ fmt::{Display, Formatter}, @@ -95,14 +95,6 @@ impl Game for OrderAndChaos { 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 } @@ -248,9 +240,9 @@ impl Game for OrderAndChaos { }; Ok(if found { - GameState::Win(ZeroSumPlayer::One) + GameState::Win(ZeroSumPlayer::Left) } else if board.possible_moves().next().is_none() { - GameState::Win(ZeroSumPlayer::Two) + GameState::Win(ZeroSumPlayer::Right) } else { GameState::Playable }) @@ -261,6 +253,16 @@ impl Game for OrderAndChaos { } } +impl PartizanGame for OrderAndChaos { + fn player(&self) -> ZeroSumPlayer { + if self.move_count % 2 == 0 { + ZeroSumPlayer::Left + } else { + ZeroSumPlayer::Right + } + } +} + impl Display for OrderAndChaos { fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> { for row in 0..HEIGHT { diff --git a/crates/games/src/reversi/mod.rs b/crates/games/src/reversi/mod.rs index 2a0c3f3..244837e 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, PartizanGame}, player::{Player, ZeroSumPlayer}}; use serde::{Deserialize, Serialize}; use std::fmt; use std::hash::Hash; @@ -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(ZeroSumPlayer::Left)) .unwrap(); board - .set(WIDTH / 2, HEIGHT / 2, Some(ZeroSumPlayer::One)) + .set(WIDTH / 2, HEIGHT / 2, Some(ZeroSumPlayer::Left)) .unwrap(); board - .set(WIDTH / 2 - 1, HEIGHT / 2, Some(ZeroSumPlayer::Two)) + .set(WIDTH / 2 - 1, HEIGHT / 2, Some(ZeroSumPlayer::Right)) .unwrap(); board - .set(WIDTH / 2, HEIGHT / 2 - 1, Some(ZeroSumPlayer::Two)) + .set(WIDTH / 2, HEIGHT / 2 - 1, Some(ZeroSumPlayer::Right)) .unwrap(); Self { @@ -138,14 +138,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 +175,35 @@ 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(ZeroSumPlayer::Left) => player_one_count += 1, + Some(ZeroSumPlayer::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(ZeroSumPlayer::Left), + std::cmp::Ordering::Less => GameState::Win(ZeroSumPlayer::Right), std::cmp::Ordering::Equal => GameState::Tie, } } } +impl PartizanGame for Reversi { + fn player(&self) -> ZeroSumPlayer { + if self.move_count % 2 == 0 { + ZeroSumPlayer::Left + } else { + ZeroSumPlayer::Right + } + } +} + fn player_to_char(player: Option) -> char { match player { - Some(ZeroSumPlayer::One) => 'X', - Some(ZeroSumPlayer::Two) => 'O', + Some(ZeroSumPlayer::Left) => 'X', + Some(ZeroSumPlayer::Right) => 'O', None => '-', } } diff --git a/crates/games/src/tic_tac_toe/mod.rs b/crates/games/src/tic_tac_toe/mod.rs index ea8b61b..a472a5d 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, PartizanGame}, player::{Player, ZeroSumPlayer}}; use itertools::Itertools; use ndarray::{iter::IndexedIter, ArrayD, Dim, Dimension, IntoDimension, IxDyn, IxDynImpl}; use serde::{Deserialize, Serialize}; @@ -171,14 +171,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 +196,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() == ZeroSumPlayer::Left { Square::X } else { Square::O @@ -243,6 +235,16 @@ impl Game for TicTacToe { } } +impl PartizanGame for TicTacToe { + fn player(&self) -> Self::Player { + if self.move_count % 2 == 0 { + ZeroSumPlayer::Left + } else { + ZeroSumPlayer::Right + } + } +} + fn offsets(dim: &Dim, size: usize) -> Vec> { let values = (-1i32..=1).collect::>(); // every offset let permutations = itertools::repeat_n(values.iter(), dim.ndim()).multi_cartesian_product(); @@ -335,7 +337,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(ZeroSumPlayer::Left)); } #[test] @@ -373,7 +375,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(ZeroSumPlayer::Left)); } #[test] diff --git a/crates/games/src/util/cli/mod.rs b/crates/games/src/util/cli/mod.rs index d37bb80..6370801 100644 --- a/crates/games/src/util/cli/mod.rs +++ b/crates/games/src/util/cli/mod.rs @@ -1,7 +1,7 @@ use anyhow::{anyhow, Result}; use core::hash::Hash; use game_solver::{ - game::{Game, GameState, ZeroSumPlayer}, + game::{Game, GameState}, par_move_scores, }; use std::fmt::{Debug, Display};