Skip to content

Commit 0f77f06

Browse files
committed
feat: zener & play
1 parent 2db1909 commit 0f77f06

File tree

21 files changed

+1061
-169
lines changed

21 files changed

+1061
-169
lines changed

Cargo.lock

Lines changed: 338 additions & 70 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/game-solver/Cargo.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ edition = "2021"
1111
[features]
1212
"xxhash" = ["dep:twox-hash"]
1313
"rayon" = ["dep:rayon", "xxhash", "dep:sysinfo", "dep:moka"]
14-
# "reinforcement" = ["dep:rand", "dep:dfdx", "dep:itertools"]
1514
"js" = ["moka/js"]
1615

1716
[dependencies]
@@ -21,7 +20,7 @@ rand = { version = "0.8", optional = true }
2120
rayon = { version = "1.8", optional = true }
2221
sysinfo = { version = "0.30", optional = true }
2322
twox-hash = { version = "1.6", optional = true }
24-
itertools = { version = "0.13", optional = true }
23+
itertools = { version = "0.13" }
2524
futures = "0.3.30"
2625
thiserror = "1.0"
2726
castaway = "0.2.3"

crates/game-solver/src/disjoint_game.rs

Lines changed: 38 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,54 @@
11
use itertools::{Interleave, Itertools};
22
use thiserror::Error;
33

4+
use crate::{
5+
game::{Game, Normal, NormalImpartial},
6+
player::ImpartialPlayer,
7+
};
48
use std::{fmt::Debug, iter::Map};
5-
use crate::{game::{Game, Normal, NormalImpartial}, player::ImpartialPlayer};
69

710
/// Represents the disjoint sum of
811
/// two impartial normal combinatorial games.
9-
///
12+
///
1013
/// Since `Game` isn't object safe, we use `dyn Any` internally with downcast safety.
11-
///
14+
///
1215
/// We restrict games to being normal impartial to force implementation of the marker trait.
1316
#[derive(Clone)]
1417
pub struct DisjointImpartialNormalGame<L: Game + NormalImpartial, R: Game + NormalImpartial> {
1518
left: L,
16-
right: R
19+
right: R,
1720
}
1821

1922
#[derive(Clone)]
2023
pub enum DisjointMove<L: Game, R: Game> {
2124
LeftMove(L::Move),
22-
RightMove(R::Move)
25+
RightMove(R::Move),
2326
}
2427

2528
#[derive(Debug, Error, Clone)]
2629
pub enum DisjointMoveError<L: Game, R: Game> {
2730
#[error("Could not make the move on left: {0}")]
2831
LeftError(L::MoveError),
2932
#[error("Could not make the move on right: {0}")]
30-
RightError(R::MoveError)
33+
RightError(R::MoveError),
3134
}
3235

3336
type LeftMoveMap<L, R> = Box<dyn Fn(<L as Game>::Move) -> DisjointMove<L, R>>;
3437
type RightMoveMap<L, R> = Box<dyn Fn(<R as Game>::Move) -> DisjointMove<L, R>>;
3538

36-
impl<
37-
L: Game + Debug + NormalImpartial + 'static,
38-
R: Game + Debug + NormalImpartial + 'static
39-
> Normal for DisjointImpartialNormalGame<L, R> {}
39+
impl<L: Game + Debug + NormalImpartial + 'static, R: Game + Debug + NormalImpartial + 'static>
40+
Normal for DisjointImpartialNormalGame<L, R>
41+
{
42+
}
4043

41-
impl<
42-
L: Game + Debug + NormalImpartial + 'static,
43-
R: Game + Debug + NormalImpartial + 'static
44-
> NormalImpartial for DisjointImpartialNormalGame<L, R> {}
44+
impl<L: Game + Debug + NormalImpartial + 'static, R: Game + Debug + NormalImpartial + 'static>
45+
NormalImpartial for DisjointImpartialNormalGame<L, R>
46+
{
47+
}
4548

46-
impl<
47-
L: Game + Debug + NormalImpartial + 'static,
48-
R: Game + Debug + NormalImpartial + 'static
49-
> Game for DisjointImpartialNormalGame<L, R> {
49+
impl<L: Game + Debug + NormalImpartial + 'static, R: Game + Debug + NormalImpartial + 'static> Game
50+
for DisjointImpartialNormalGame<L, R>
51+
{
5052
type Move = DisjointMove<L, R>;
5153
type Iter<'a> = Interleave<
5254
Map<<L as Game>::Iter<'a>, LeftMoveMap<L, R>>,
@@ -61,36 +63,41 @@ impl<
6163
}
6264

6365
fn max_moves(&self) -> Option<usize> {
64-
self.left.max_moves()
65-
.map(
66-
|l| self.right.max_moves()
67-
.map(|r| l + r)
68-
).flatten()
66+
self.left
67+
.max_moves()
68+
.map(|l| self.right.max_moves().map(|r| l + r))
69+
.flatten()
6970
}
7071

7172
fn make_move(&mut self, m: &Self::Move) -> Result<(), Self::MoveError> {
7273
match m {
73-
DisjointMove::LeftMove(l) =>
74-
self.left.make_move(l).map_err(|err| DisjointMoveError::LeftError(err)),
75-
DisjointMove::RightMove(r) =>
76-
self.right.make_move(r).map_err(|err| DisjointMoveError::RightError(err))
74+
DisjointMove::LeftMove(l) => self
75+
.left
76+
.make_move(l)
77+
.map_err(|err| DisjointMoveError::LeftError(err)),
78+
DisjointMove::RightMove(r) => self
79+
.right
80+
.make_move(r)
81+
.map_err(|err| DisjointMoveError::RightError(err)),
7782
}
7883
}
7984

8085
fn possible_moves(&self) -> Self::Iter<'_> {
8186
fn as_left<L: Game, R: Game>(m: L::Move) -> DisjointMove<L, R> {
8287
DisjointMove::LeftMove(m)
8388
}
84-
89+
8590
fn as_right<L: Game, R: Game>(m: R::Move) -> DisjointMove<L, R> {
8691
DisjointMove::RightMove(m)
8792
}
8893

89-
self.left.possible_moves()
94+
self.left
95+
.possible_moves()
9096
.map(Box::new(as_left) as LeftMoveMap<L, R>)
9197
.interleave(
92-
self.right.possible_moves()
93-
.map(Box::new(as_right) as RightMoveMap<L, R>)
98+
self.right
99+
.possible_moves()
100+
.map(Box::new(as_right) as RightMoveMap<L, R>),
94101
)
95102
}
96103

crates/game-solver/src/game.rs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ pub enum GameState<P: Player> {
1717
}
1818

1919
/// Marks a game as being 'normal' (a game has the 'normal play' convention).
20-
///
20+
///
2121
/// Rather, this means that the game is won by whoever plays last.
2222
/// Under this convention, no ties are possible: there has to exist a strategy
2323
/// for players to be able to force a win.
@@ -37,19 +37,19 @@ pub trait Normal: Game {
3737
/// the disjunctive sum of two games is equal to another normal-play game.
3838
pub trait NormalImpartial: Normal {
3939
/// Splits a game into multiple separate games.
40-
///
40+
///
4141
/// This function doesn't have to be necessarily optimal, but
4242
/// it makes normal impartial game analysis much quicker,
4343
/// using the technique described in [Nimbers Are Inevitable](https://arxiv.org/abs/1011.5841).
44-
///
44+
///
4545
/// Returns `Option::None`` if the game currently can not be split.
4646
fn split(&self) -> Option<Vec<Self>> {
4747
None
4848
}
4949
}
5050

5151
/// Marks a game as being 'misere' (a game has the 'misere play' convention).
52-
///
52+
///
5353
/// Rather, this means that the game is lost by whoever plays last.
5454
/// Under this convention, no ties are possible: there has to exist a strategy
5555
/// for players to be able to force a win.
@@ -192,7 +192,11 @@ pub trait Game: Clone {
192192
///
193193
/// Note: Despite this returning isize, this function will always be positive.
194194
pub fn upper_bound<T: Game>(game: &T) -> isize {
195-
game.max_moves().map_or(isize::MAX, |m| m as isize)
195+
game.max_moves().map_or(
196+
// TODO(HACKY): theres probably nicer ways of handling upper bounds for
197+
// loopy games
198+
isize::MAX / 2, |m| m as isize
199+
)
196200
}
197201

198202
/// Represents an outcome of a game derived by a score and a valid instance of a game.

crates/game-solver/src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@
44
//! [the book](https://leodog896.github.io/game-solver/book) is
55
//! a great place to start.
66
7-
pub mod game;
87
pub mod disjoint_game;
8+
pub mod game;
99
pub mod player;
1010
pub mod stats;
11+
pub mod loopy;
1112
// TODO: reinforcement
1213
// #[cfg(feature = "reinforcement")]
1314
// pub mod reinforcement;

crates/game-solver/src/loopy.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
use std::fmt::Debug;
2+
use std::hash::Hash;
3+
use std::collections::HashSet;
4+
5+
/// We handle loopy games with a custom struct, `LoopyTracker`, which is a
6+
/// HashSet of some state T. This is used to keep track of the states that
7+
/// have been visited, and if a state has been visited, we can handle it appropriately.
8+
///
9+
/// `LoopyTracker` should be updated at `Game::make_move` and checked in `Game::state`.
10+
11+
#[derive(Debug, Clone)]
12+
pub struct LoopyTracker<T: Eq + Hash> {
13+
visited: HashSet<T>,
14+
}
15+
16+
impl<T: Eq + Hash> LoopyTracker<T> {
17+
/// Create a new `LoopyTracker`.
18+
pub fn new() -> Self {
19+
Self {
20+
visited: HashSet::new(),
21+
}
22+
}
23+
24+
/// Check if a state has been visited.
25+
pub fn has_visited(&self, state: &T) -> bool {
26+
self.visited.contains(state)
27+
}
28+
29+
/// Mark a state as visited.
30+
pub fn mark_visited(&mut self, state: T) {
31+
self.visited.insert(state);
32+
}
33+
34+
/// The number of states visited.
35+
pub fn age(&self) -> usize {
36+
self.visited.len()
37+
}
38+
}
39+
40+
impl<T: Eq + Hash> Default for LoopyTracker<T> {
41+
fn default() -> Self {
42+
Self::new()
43+
}
44+
}
45+
46+
impl<T: Eq + Hash> PartialEq for LoopyTracker<T> {
47+
fn eq(&self, _: &Self) -> bool {
48+
true
49+
}
50+
}
51+
52+
impl<T: Eq + Hash> Eq for LoopyTracker<T> {}
53+
54+
impl<T: Eq + Hash> Hash for LoopyTracker<T> {
55+
fn hash<H: std::hash::Hasher>(&self, hasher: &mut H) {
56+
for item in self.visited.iter() {
57+
item.hash(hasher);
58+
}
59+
}
60+
}

crates/games-cli/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,6 @@ game-solver = { path = "../game-solver", features = ["rayon"] }
99
games = { path = "../games" }
1010
anyhow = "1.0.86"
1111
clap = { version = "4.5.15", features = ["derive"] }
12+
dialoguer = "0.11.0"
13+
clearscreen = "4.0.1"
14+
owo-colors = "4.1.0"

crates/games-cli/src/interactive.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
use std::{fmt::{Debug, Display}, str::FromStr};
2+
3+
use game_solver::{game::Game, player::TwoPlayer};
4+
use games::util::cli::{move_failable, robot::announce_player};
5+
6+
use owo_colors::OwoColorize;
7+
8+
use dialoguer::{theme::ColorfulTheme, Input};
9+
10+
pub fn play_interactive<
11+
T: Game<Player = impl TwoPlayer + Debug + 'static>
12+
+ Display
13+
>(
14+
mut game: T
15+
) where <T as Game>::Move: FromStr + Debug, <<T as Game>::Move as FromStr>::Err: Debug {
16+
loop {
17+
print!("{}", game);
18+
println!();
19+
20+
announce_player(&game);
21+
22+
let game_move: String = Input::with_theme(&ColorfulTheme::default())
23+
.with_prompt("Move")
24+
.interact_text()
25+
.unwrap();
26+
27+
match FromStr::from_str(&game_move) {
28+
Ok(game_move) => {
29+
if let Err(err) = move_failable(&mut game, &game_move) {
30+
clearscreen::clear().expect("failed to clear screen");
31+
println!("{}", format!("Failed to make move {game_move:?}: {err:?}").red());
32+
continue;
33+
}
34+
},
35+
Err(err) => {
36+
clearscreen::clear().expect("failed to clear screen");
37+
println!("{}", format!("Invalid move {game_move}: {err:?}").red());
38+
continue;
39+
}
40+
}
41+
42+
clearscreen::clear().expect("failed to clear screen");
43+
}
44+
}

crates/games-cli/src/main.rs

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,54 @@
1+
mod interactive;
2+
13
use anyhow::Result;
24
use clap::Parser;
35
use games::{
46
chomp::Chomp, domineering::Domineering, naive_nim::Nim, order_and_chaos::OrderAndChaos,
5-
reversi::Reversi, sprouts::Sprouts, tic_tac_toe::TicTacToe, util::cli::play, Games,
7+
reversi::Reversi, sprouts::Sprouts, tic_tac_toe::TicTacToe, util::cli::play, zener::Zener,
8+
Games,
69
};
10+
use interactive::play_interactive;
711

812
/// `game-solver` is a solving utility that helps analyze various combinatorial games.
913
#[derive(Parser)]
1014
#[command(version, about, long_about = None)]
11-
struct Cli {
12-
#[command(subcommand)]
13-
command: Games,
14-
#[arg(short, long)]
15-
plain: bool,
15+
enum Cli {
16+
Solve {
17+
#[command(subcommand)]
18+
command: Games,
19+
#[arg(short, long)]
20+
plain: bool,
21+
},
22+
Play {
23+
#[command(subcommand)]
24+
command: Games
25+
}
1626
}
1727

1828
fn main() -> Result<()> {
1929
let cli = Cli::parse();
2030

21-
match cli.command {
22-
Games::Reversi(args) => play::<Reversi>(args.try_into().unwrap(), cli.plain),
23-
Games::TicTacToe(args) => play::<TicTacToe>(args.try_into().unwrap(), cli.plain),
24-
Games::OrderAndChaos(args) => {
25-
play::<OrderAndChaos<6, 6, 5, 6>>(args.try_into().unwrap(), cli.plain)
31+
match cli {
32+
Cli::Solve { command, plain } => match command {
33+
Games::Reversi(args) => play::<Reversi>(args.try_into().unwrap(), plain),
34+
Games::TicTacToe(args) => play::<TicTacToe>(args.try_into().unwrap(), plain),
35+
Games::OrderAndChaos(args) => play::<OrderAndChaos<6, 6, 5, 6>>(args.try_into().unwrap(), plain),
36+
Games::NaiveNim(args) => play::<Nim>(args.try_into().unwrap(), plain),
37+
Games::Domineering(args) => play::<Domineering<5, 5>>(args.try_into().unwrap(), plain),
38+
Games::Chomp(args) => play::<Chomp>(args.try_into().unwrap(), plain),
39+
Games::Sprouts(args) => play::<Sprouts>(args.try_into().unwrap(), plain),
40+
Games::Zener(args) => play::<Zener>(args.try_into().unwrap(), plain),
41+
},
42+
Cli::Play { command } => match command {
43+
Games::Reversi(args) => play_interactive::<Reversi>(args.try_into().unwrap()),
44+
Games::TicTacToe(args) => play_interactive::<TicTacToe>(args.try_into().unwrap()),
45+
Games::OrderAndChaos(args) => play_interactive::<OrderAndChaos<6, 6, 5, 6>>(args.try_into().unwrap()),
46+
Games::NaiveNim(args) => play_interactive::<Nim>(args.try_into().unwrap()),
47+
Games::Domineering(args) => play_interactive::<Domineering<5, 5>>(args.try_into().unwrap()),
48+
Games::Chomp(args) => play_interactive::<Chomp>(args.try_into().unwrap()),
49+
Games::Sprouts(args) => play_interactive::<Sprouts>(args.try_into().unwrap()),
50+
Games::Zener(args) => play_interactive::<Zener>(args.try_into().unwrap()),
2651
}
27-
Games::NaiveNim(args) => play::<Nim>(args.try_into().unwrap(), cli.plain),
28-
Games::Domineering(args) => play::<Domineering<5, 5>>(args.try_into().unwrap(), cli.plain),
29-
Games::Chomp(args) => play::<Chomp>(args.try_into().unwrap(), cli.plain),
30-
Games::Sprouts(args) => play::<Sprouts>(args.try_into().unwrap(), cli.plain),
3152
};
3253

3354
Ok(())

crates/games/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ thiserror = "1.0.63"
2121
petgraph = { version = "0.6.5", features = ["serde-1"] }
2222
castaway = "0.2.3"
2323
ratatui = "0.28.1"
24-
owo-colors = "4.1.0"
24+
owo-colors = { version = "4.1.0", features = ["supports-colors"] }
2525

2626
[features]
2727
"egui" = ["dep:egui", "dep:egui_commonmark"]

0 commit comments

Comments
 (0)