Skip to content

Commit

Permalink
feat: normal/misere marker traits, disjoint games
Browse files Browse the repository at this point in the history
  • Loading branch information
LeoDog896 committed Nov 4, 2024
1 parent 4135443 commit 3a34d19
Show file tree
Hide file tree
Showing 10 changed files with 153 additions and 60 deletions.
91 changes: 91 additions & 0 deletions crates/game-solver/src/disjoint_game.rs
Original file line number Diff line number Diff line change
@@ -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<L: Game, R: Game> {
left: L,
right: R
}

#[derive(Clone)]
pub enum DisjointMove<L: Game, R: Game> {
LeftMove(L::Move),
RightMove(R::Move)
}

#[derive(Debug, Error, Clone)]
pub enum DisjointMoveError<L: Game, R: Game> {
#[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<L, R> = Box<dyn Fn(<L as Game>::Move) -> DisjointMove<L, R>>;
type RightMoveMap<L, R> = Box<dyn Fn(<R as Game>::Move) -> DisjointMove<L, R>>;

impl<L: Game + Debug + 'static, R: Game + Debug + 'static> Normal for DisjointImpartialNormalGame<L, R> {}
impl<L: Game + Debug + 'static, R: Game + Debug + 'static> NormalImpartial for DisjointImpartialNormalGame<L, R> {}
impl<L: Game + Debug + 'static, R: Game + Debug + 'static> Game for DisjointImpartialNormalGame<L, R> {
type Move = DisjointMove<L, R>;
type Iter<'a> = Interleave<
Map<<L as Game>::Iter<'a>, LeftMoveMap<L, R>>,
Map<<R as Game>::Iter<'a>, RightMoveMap<L, R>>
> where L: 'a, R: 'a, L::Move: 'a, R::Move: 'a;

type Player = ImpartialPlayer;
type MoveError = DisjointMoveError<L, R>;

fn move_count(&self) -> usize {
self.left.move_count() + self.right.move_count()
}

fn max_moves(&self) -> Option<usize> {
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<L: Game, R: Game>(m: L::Move) -> DisjointMove<L, R> {
DisjointMove::LeftMove(m)
}

fn as_right<L: Game, R: Game>(m: R::Move) -> DisjointMove<L, R> {
DisjointMove::RightMove(m)
}

self.left.possible_moves()
.map(Box::new(as_left) as LeftMoveMap<L, R>)
.interleave(
self.right.possible_moves()
.map(Box::new(as_right) as RightMoveMap<L, R>)
)
}

fn state(&self) -> crate::game::GameState<Self::Player> {
<Self as Normal>::state(&self)
}

fn player(&self) -> Self::Player {
ImpartialPlayer::Next
}
}
72 changes: 41 additions & 31 deletions crates/game-solver/src/game.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,36 +16,49 @@ pub enum GameState<P: Player> {
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: <https://en.wikipedia.org/wiki/Normal_play_convention>
Normal,
/// If a game is under 'misere play' convention, the last player to move loses.
/// There are no ties in this variant.
///
/// Learn more: <https://en.wikipedia.org/wiki/Mis%C3%A8re#Mis%C3%A8re_game>
Misere,
/// Learn more: <https://en.wikipedia.org/wiki/Normal_play_convention>
pub trait Normal: Game {
fn state(&self) -> GameState<Self::Player> {
if self.possible_moves().next().is_none() {
GameState::Win(self.player().previous())
} else {
GameState::Playable
}
}
}

impl StateType {
pub fn state<T>(&self, game: &T) -> GameState<T::Player>
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<Vec<Self>> {
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: <https://en.wikipedia.org/wiki/Mis%C3%A8re#Mis%C3%A8re_game>
pub trait Misere: Game {
fn state<T>(&self) -> GameState<Self::Player> {
if self.possible_moves().next().is_none() {
GameState::Win(self.player())
} else {
GameState::Playable
}
Expand All @@ -72,8 +85,6 @@ pub trait Game: Clone {

type Player: Player;

const STATE_TYPE: Option<StateType>;

/// Returns the amount of moves that have been played
fn move_count(&self) -> usize;

Expand Down Expand Up @@ -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::Player> {
/// Self::STATE_TYPE.unwrap().state(self)
/// <Self as Normal>::state(&self) // or Misere if misere.
/// }
/// ```
fn state(&self) -> GameState<Self::Player>;
Expand Down
1 change: 1 addition & 0 deletions crates/game-solver/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
//! a great place to start.
pub mod game;
pub mod disjoint_game;
pub mod player;
pub mod stats;
// TODO: reinforcement
Expand Down
9 changes: 5 additions & 4 deletions crates/games/src/chomp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<Self::Move>;
type Player = ImpartialPlayer;
type MoveError = ChompMoveError;

const STATE_TYPE: Option<StateType> = Some(StateType::Normal);

fn max_moves(&self) -> Option<usize> {
Some(self.width * self.height)
}
Expand Down Expand Up @@ -124,7 +125,7 @@ impl Game for Chomp {
}

fn state(&self) -> GameState<Self::Player> {
Self::STATE_TYPE.unwrap().state(self)
<Self as Normal>::state(&self)
}
}

Expand Down
12 changes: 4 additions & 8 deletions crates/games/src/domineering/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},

Check warning on line 10 in crates/games/src/domineering/mod.rs

View workflow job for this annotation

GitHub Actions / deploy

unused import: `Player`
};
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -113,14 +113,14 @@ impl<const WIDTH: usize, const HEIGHT: usize> Domineering<WIDTH, HEIGHT> {
}
}

impl<const WIDTH: usize, const HEIGHT: usize> Normal for Domineering<WIDTH, HEIGHT> {}

impl<const WIDTH: usize, const HEIGHT: usize> Game for Domineering<WIDTH, HEIGHT> {
type Move = DomineeringMove;
type Iter<'a> = std::vec::IntoIter<Self::Move>;
type Player = PartizanPlayer;
type MoveError = DomineeringMoveError;

const STATE_TYPE: Option<StateType> = Some(StateType::Normal);

fn max_moves(&self) -> Option<usize> {
Some(WIDTH * HEIGHT)
}
Expand Down Expand Up @@ -183,11 +183,7 @@ impl<const WIDTH: usize, const HEIGHT: usize> Game for Domineering<WIDTH, HEIGHT
}

fn state(&self) -> GameState<Self::Player> {
if self.possible_moves().len() == 0 {
GameState::Win(self.player().next())
} else {
GameState::Playable
}
<Self as Normal>::state(&self)
}

fn player(&self) -> Self::Player {
Expand Down
8 changes: 4 additions & 4 deletions crates/games/src/nim/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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;
Expand All @@ -60,8 +62,6 @@ impl Game for Nim {
type Player = ImpartialPlayer;
type MoveError = NimMoveError;

const STATE_TYPE: Option<StateType> = Some(StateType::Normal);

fn max_moves(&self) -> Option<usize> {
Some(self.max_moves)
}
Expand Down Expand Up @@ -108,7 +108,7 @@ impl Game for Nim {
}

fn state(&self) -> GameState<Self::Player> {
Self::STATE_TYPE.unwrap().state(self)
<Self as Normal>::state(&self)
}

fn player(&self) -> Self::Player {
Expand Down
4 changes: 1 addition & 3 deletions crates/games/src/order_and_chaos/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -122,8 +122,6 @@ impl<
type Player = PartizanPlayer;
type MoveError = OrderAndChaosMoveError;

const STATE_TYPE: Option<StateType> = None;

fn max_moves(&self) -> Option<usize> {
Some(WIDTH * HEIGHT)
}
Expand Down
4 changes: 1 addition & 3 deletions crates/games/src/reversi/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -133,8 +133,6 @@ impl Game for Reversi {
type Player = PartizanPlayer;
type MoveError = array2d::Error;

const STATE_TYPE: Option<StateType> = None;

fn max_moves(&self) -> Option<usize> {
Some(WIDTH * HEIGHT)
}
Expand Down
8 changes: 4 additions & 4 deletions crates/games/src/sprouts/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -91,15 +91,15 @@ 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<Self::Move>;

type Player = ImpartialPlayer;
type MoveError = SproutsMoveError;

const STATE_TYPE: Option<StateType> = Some(StateType::Normal);

fn max_moves(&self) -> Option<usize> {
// TODO: i actually want to find what the proper paper is, but
// https://en.wikipedia.org/wiki/Sprouts_(game)#Maximum_number_of_moves
Expand Down Expand Up @@ -215,7 +215,7 @@ impl Game for Sprouts {
}

fn state(&self) -> game_solver::game::GameState<Self::Player> {
Self::STATE_TYPE.unwrap().state(self)
<Self as Normal>::state(&self)
}
}

Expand Down
Loading

0 comments on commit 3a34d19

Please sign in to comment.