From 3a34d1922d9674385efd9f06a99ee7d557d5673e Mon Sep 17 00:00:00 2001 From: "Tristan F." Date: Sun, 3 Nov 2024 20:23:54 -0800 Subject: [PATCH] feat: normal/misere marker traits, disjoint games --- crates/game-solver/src/disjoint_game.rs | 91 +++++++++++++++++++++++++ crates/game-solver/src/game.rs | 72 ++++++++++--------- crates/game-solver/src/lib.rs | 1 + crates/games/src/chomp/mod.rs | 9 +-- crates/games/src/domineering/mod.rs | 12 ++-- crates/games/src/nim/mod.rs | 8 +-- crates/games/src/order_and_chaos/mod.rs | 4 +- crates/games/src/reversi/mod.rs | 4 +- crates/games/src/sprouts/mod.rs | 8 +-- crates/games/src/tic_tac_toe/mod.rs | 4 +- 10 files changed, 153 insertions(+), 60 deletions(-) create mode 100644 crates/game-solver/src/disjoint_game.rs diff --git a/crates/game-solver/src/disjoint_game.rs b/crates/game-solver/src/disjoint_game.rs new file mode 100644 index 0000000..fa88126 --- /dev/null +++ b/crates/game-solver/src/disjoint_game.rs @@ -0,0 +1,91 @@ +use itertools::{Interleave, Itertools}; +use thiserror::Error; + +use std::{fmt::Debug, iter::Map}; +use crate::{game::{Game, Normal, NormalImpartial}, player::ImpartialPlayer}; + +/// Represents the disjoint sum of +/// two impartial normal combinatorial games. +/// +/// Since `Game` isn't object safe, we use `dyn Any` internally with downcast safety. +#[derive(Clone)] +pub struct DisjointImpartialNormalGame { + left: L, + right: R +} + +#[derive(Clone)] +pub enum DisjointMove { + LeftMove(L::Move), + RightMove(R::Move) +} + +#[derive(Debug, Error, Clone)] +pub enum DisjointMoveError { + #[error("Could not make the move on left: {0}")] + LeftError(L::MoveError), + #[error("Could not make the move on right: {0}")] + RightError(R::MoveError) +} + +type LeftMoveMap = Box::Move) -> DisjointMove>; +type RightMoveMap = Box::Move) -> DisjointMove>; + +impl Normal for DisjointImpartialNormalGame {} +impl NormalImpartial for DisjointImpartialNormalGame {} +impl Game for DisjointImpartialNormalGame { + type Move = DisjointMove; + type Iter<'a> = Interleave< + Map<::Iter<'a>, LeftMoveMap>, + Map<::Iter<'a>, RightMoveMap> + > where L: 'a, R: 'a, L::Move: 'a, R::Move: 'a; + + type Player = ImpartialPlayer; + type MoveError = DisjointMoveError; + + fn move_count(&self) -> usize { + self.left.move_count() + self.right.move_count() + } + + fn max_moves(&self) -> Option { + self.left.max_moves() + .map( + |l| self.right.max_moves() + .map(|r| l + r) + ).flatten() + } + + fn make_move(&mut self, m: &Self::Move) -> Result<(), Self::MoveError> { + match m { + DisjointMove::LeftMove(l) => + self.left.make_move(l).map_err(|err| DisjointMoveError::LeftError(err)), + DisjointMove::RightMove(r) => + self.right.make_move(r).map_err(|err| DisjointMoveError::RightError(err)) + } + } + + fn possible_moves(&self) -> Self::Iter<'_> { + fn as_left(m: L::Move) -> DisjointMove { + DisjointMove::LeftMove(m) + } + + fn as_right(m: R::Move) -> DisjointMove { + DisjointMove::RightMove(m) + } + + self.left.possible_moves() + .map(Box::new(as_left) as LeftMoveMap) + .interleave( + self.right.possible_moves() + .map(Box::new(as_right) as RightMoveMap) + ) + } + + fn state(&self) -> crate::game::GameState { + ::state(&self) + } + + fn player(&self) -> Self::Player { + ImpartialPlayer::Next + } +} diff --git a/crates/game-solver/src/game.rs b/crates/game-solver/src/game.rs index 9c79b5a..9d6d2c3 100644 --- a/crates/game-solver/src/game.rs +++ b/crates/game-solver/src/game.rs @@ -16,36 +16,49 @@ pub enum GameState { Win(P), } -/// Defines the 'state' the game is in. +/// Marks a game as being 'normal' (a game has the 'normal play' convention). +/// +/// Rather, this means that the game is won by whoever plays last. +/// Under this convention, no ties are possible: there has to exist a strategy +/// for players to be able to force a win. /// -/// 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, +/// Learn more: +pub trait Normal: Game { + fn state(&self) -> GameState { + if self.possible_moves().next().is_none() { + GameState::Win(self.player().previous()) + } else { + GameState::Playable + } + } } -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(), - }) +/// Normal impartial games have the special property of being splittable: i.e., +/// the disjunctive sum of two games is equal to another normal-play game. +pub trait NormalImpartial: Normal { + /// Splits a game into multiple separate games. + /// + /// This function doesn't have to be necessarily optimal, but + /// it makes normal impartial game analysis much quicker, + /// using the technique described in [Nimbers Are Inevitable](https://arxiv.org/abs/1011.5841). + /// + /// Returns `Option::None`` if the game currently can not be split. + fn split(&self) -> Option> { + None + } +} + +/// Marks a game as being 'misere' (a game has the 'misere play' convention). +/// +/// Rather, this means that the game is lost by whoever plays last. +/// Under this convention, no ties are possible: there has to exist a strategy +/// for players to be able to force a win. +/// +/// Learn more: +pub trait Misere: Game { + fn state(&self) -> GameState { + if self.possible_moves().next().is_none() { + GameState::Win(self.player()) } else { GameState::Playable } @@ -72,8 +85,6 @@ pub trait Game: Clone { type Player: Player; - const STATE_TYPE: Option; - /// Returns the amount of moves that have been played fn move_count(&self) -> usize; @@ -131,12 +142,11 @@ pub trait Game: Clone { /// Returns the current state of the game. /// Used for verifying initialization and is 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) + /// ::state(&self) // or Misere if misere. /// } /// ``` fn state(&self) -> GameState; diff --git a/crates/game-solver/src/lib.rs b/crates/game-solver/src/lib.rs index 0d56444..317dcb3 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 disjoint_game; pub mod player; pub mod stats; // TODO: reinforcement diff --git a/crates/games/src/chomp/mod.rs b/crates/games/src/chomp/mod.rs index 1be3392..80a8776 100644 --- a/crates/games/src/chomp/mod.rs +++ b/crates/games/src/chomp/mod.rs @@ -6,7 +6,7 @@ use anyhow::Error; use array2d::Array2D; use clap::Args; use game_solver::{ - game::{Game, GameState, StateType}, + game::{Game, GameState, Normal, NormalImpartial}, player::ImpartialPlayer, }; use serde::{Deserialize, Serialize}; @@ -77,14 +77,15 @@ pub enum ChompMoveError { pub type ChompMove = NaturalMove<2>; +impl Normal for Chomp {} +impl NormalImpartial for Chomp {} + impl Game for Chomp { type Move = ChompMove; type Iter<'a> = std::vec::IntoIter; type Player = ImpartialPlayer; type MoveError = ChompMoveError; - const STATE_TYPE: Option = Some(StateType::Normal); - fn max_moves(&self) -> Option { Some(self.width * self.height) } @@ -124,7 +125,7 @@ impl Game for Chomp { } fn state(&self) -> GameState { - Self::STATE_TYPE.unwrap().state(self) + ::state(&self) } } diff --git a/crates/games/src/domineering/mod.rs b/crates/games/src/domineering/mod.rs index 66e91cd..0c6f976 100644 --- a/crates/games/src/domineering/mod.rs +++ b/crates/games/src/domineering/mod.rs @@ -6,7 +6,7 @@ use anyhow::Error; use array2d::Array2D; use clap::Args; use game_solver::{ - game::{Game, GameState, StateType}, + game::{Game, GameState, Normal}, player::{PartizanPlayer, Player}, }; use serde::{Deserialize, Serialize}; @@ -113,14 +113,14 @@ impl Domineering { } } +impl Normal for Domineering {} + impl Game for Domineering { type Move = DomineeringMove; type Iter<'a> = std::vec::IntoIter; type Player = PartizanPlayer; type MoveError = DomineeringMoveError; - const STATE_TYPE: Option = Some(StateType::Normal); - fn max_moves(&self) -> Option { Some(WIDTH * HEIGHT) } @@ -183,11 +183,7 @@ impl Game for Domineering GameState { - if self.possible_moves().len() == 0 { - GameState::Win(self.player().next()) - } else { - GameState::Playable - } + ::state(&self) } fn player(&self) -> Self::Player { diff --git a/crates/games/src/nim/mod.rs b/crates/games/src/nim/mod.rs index edb11f6..d17401d 100644 --- a/crates/games/src/nim/mod.rs +++ b/crates/games/src/nim/mod.rs @@ -5,7 +5,7 @@ pub mod gui; use anyhow::Error; use clap::Args; use game_solver::{ - game::{Game, GameState, StateType}, + game::{Game, GameState, Normal, NormalImpartial}, player::ImpartialPlayer, }; use serde::{Deserialize, Serialize}; @@ -51,6 +51,8 @@ pub enum NimMoveError { }, } +impl Normal for Nim {} +impl NormalImpartial for Nim {} impl Game for Nim { /// where Move is a tuple of the heap index and the number of objects to remove type Move = NimMove; @@ -60,8 +62,6 @@ impl Game for Nim { type Player = ImpartialPlayer; type MoveError = NimMoveError; - const STATE_TYPE: Option = Some(StateType::Normal); - fn max_moves(&self) -> Option { Some(self.max_moves) } @@ -108,7 +108,7 @@ impl Game for Nim { } fn state(&self) -> GameState { - Self::STATE_TYPE.unwrap().state(self) + ::state(&self) } fn player(&self) -> Self::Player { diff --git a/crates/games/src/order_and_chaos/mod.rs b/crates/games/src/order_and_chaos/mod.rs index 3061439..1750cdd 100644 --- a/crates/games/src/order_and_chaos/mod.rs +++ b/crates/games/src/order_and_chaos/mod.rs @@ -6,7 +6,7 @@ use anyhow::{anyhow, Error}; use array2d::Array2D; use clap::Args; use game_solver::{ - game::{Game, GameState, StateType}, + game::{Game, GameState}, player::PartizanPlayer, }; use serde::{Deserialize, Serialize}; @@ -122,8 +122,6 @@ impl< type Player = PartizanPlayer; type MoveError = OrderAndChaosMoveError; - const STATE_TYPE: Option = None; - fn max_moves(&self) -> Option { Some(WIDTH * HEIGHT) } diff --git a/crates/games/src/reversi/mod.rs b/crates/games/src/reversi/mod.rs index 3761e83..ae61512 100644 --- a/crates/games/src/reversi/mod.rs +++ b/crates/games/src/reversi/mod.rs @@ -7,7 +7,7 @@ use anyhow::Error; use array2d::Array2D; use clap::Args; use game_solver::{ - game::{Game, GameState, StateType}, + game::{Game, GameState}, player::{PartizanPlayer, Player}, }; use serde::{Deserialize, Serialize}; @@ -133,8 +133,6 @@ impl Game for Reversi { type Player = PartizanPlayer; type MoveError = array2d::Error; - const STATE_TYPE: Option = None; - fn max_moves(&self) -> Option { Some(WIDTH * HEIGHT) } diff --git a/crates/games/src/sprouts/mod.rs b/crates/games/src/sprouts/mod.rs index 562a845..2fec225 100644 --- a/crates/games/src/sprouts/mod.rs +++ b/crates/games/src/sprouts/mod.rs @@ -9,7 +9,7 @@ use std::{ use anyhow::Error; use clap::Args; use game_solver::{ - game::{Game, StateType}, + game::{Game, Normal, NormalImpartial}, player::ImpartialPlayer, }; use itertools::Itertools; @@ -91,6 +91,8 @@ pub enum SproutsMoveError { const MAX_SPROUTS: usize = 3; +impl Normal for Sprouts {} +impl NormalImpartial for Sprouts {} impl Game for Sprouts { type Move = SproutsMove; type Iter<'a> = std::vec::IntoIter; @@ -98,8 +100,6 @@ impl Game for Sprouts { type Player = ImpartialPlayer; type MoveError = SproutsMoveError; - const STATE_TYPE: Option = Some(StateType::Normal); - fn max_moves(&self) -> Option { // TODO: i actually want to find what the proper paper is, but // https://en.wikipedia.org/wiki/Sprouts_(game)#Maximum_number_of_moves @@ -215,7 +215,7 @@ impl Game for Sprouts { } fn state(&self) -> game_solver::game::GameState { - Self::STATE_TYPE.unwrap().state(self) + ::state(&self) } } diff --git a/crates/games/src/tic_tac_toe/mod.rs b/crates/games/src/tic_tac_toe/mod.rs index 262cdf6..c8c208c 100644 --- a/crates/games/src/tic_tac_toe/mod.rs +++ b/crates/games/src/tic_tac_toe/mod.rs @@ -5,7 +5,7 @@ pub mod gui; use anyhow::{anyhow, Error}; use clap::Args; use game_solver::{ - game::{Game, GameState, StateType}, + game::{Game, GameState}, player::{PartizanPlayer, Player}, }; use itertools::Itertools; @@ -186,8 +186,6 @@ impl Game for TicTacToe { type Player = PartizanPlayer; type MoveError = TicTacToeMoveError; - const STATE_TYPE: Option = None; - fn max_moves(&self) -> Option { Some(self.size.pow(self.dim as u32)) }