Skip to content

Commit

Permalink
feat: domineering orientation support, docs cleanup
Browse files Browse the repository at this point in the history
  • Loading branch information
LeoDog896 committed Aug 20, 2024
1 parent a9c07ab commit 9bb5d86
Show file tree
Hide file tree
Showing 7 changed files with 105 additions and 54 deletions.
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,17 @@ More in-depth information can be found in [the book](https://leodog896.github.io

[Game Theory](https://en.wikipedia.org/wiki/Game_theory) is a general study of games. Many of these games are solved without rigirous computation (for example, where [impartial](https://en.wikipedia.org/wiki/Impartial_game) [combinatorial games](https://en.wikipedia.org/wiki/Combinatorial_game_theory) are solved by generalizing the game to Nim).

However, in order to apply game theory to more complex games, computation is required. This is where `game-solver` comes in.
However, computation is required to strongly solve to more complex games. This is where the `game-solver` comes in.

## Contribute

Rust nightly is required to compile the examples (as `game-solver` uses benches for examples)
Rust nightly is required.

If you want to contribute, new game implementations would be greately appreciated!
The more examples of games that are provided, the more examples that can be used
for benchmarks, analysis, and further optimization.

Any new visual representations for games that don't exist on the [app](https://leodog896.github.io/game-solver/app/)
would also be great!
Any new visual representations for games that don't exist on the [app](https://leodog896.github.io/game-solver/app/) would also be great!

### Profiling

Expand Down
17 changes: 10 additions & 7 deletions crates/game-solver/src/game.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,21 +57,21 @@ impl Player for ZeroSumPlayer {
}

/// Represents a player in an N-player game.
pub struct NPlayer<const N: usize>(usize);
pub struct NPlayerConst<const N: usize>(usize);

impl<const N: usize> NPlayer<N> {
pub fn new(index: usize) -> NPlayer<N> {
impl<const N: usize> NPlayerConst<N> {
pub fn new(index: usize) -> NPlayerConst<N> {
assert!(index < N, "Player index {index} >= max player count {N}");
Self(index)
}

pub fn new_unchecked(index: usize) -> NPlayer<N> {
pub fn new_unchecked(index: usize) -> NPlayerConst<N> {
debug_assert!(index < N, "Player index {index} >= max player count {N}");
Self(index)
}
}

impl<const N: usize> Player for NPlayer<N> {
impl<const N: usize> Player for NPlayerConst<N> {
fn count() -> usize {
N
}
Expand Down Expand Up @@ -140,6 +140,8 @@ pub trait Game: Clone {
/// 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;

Expand All @@ -149,13 +151,14 @@ pub trait Game: Clone {
/// Makes a move.
fn make_move(&mut self, m: &Self::Move) -> Result<(), Self::MoveError>;

/// Returns a vector of all possible moves.
/// 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.
/// This allows alpha-beta pruning to move faster.
/// 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 -
Expand Down
8 changes: 5 additions & 3 deletions crates/game-solver/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,12 @@ use crate::transposition::{Score, TranspositionTable};
use std::hash::Hash;

/// Runs the two-player minimax variant on a zero-sum game.
/// It uses alpha beta pruning (e.g. you can specify \[-1, 1\] to get only win/loss/draw moves).
/// Since it uses alpha-beta pruning, you can specify an alpha beta window.
fn negamax<T: Game<Player = ZeroSumPlayer> + Eq + Hash>(
game: &T,
transposition_table: &mut dyn TranspositionTable<T>,
mut alpha: isize,
mut beta: isize,
mut beta: isize
) -> Result<isize, T::MoveError> {
match game.state() {
GameState::Playable => (),
Expand All @@ -34,7 +34,9 @@ fn negamax<T: Game<Player = ZeroSumPlayer> + 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)?;
Expand Down Expand Up @@ -121,7 +123,7 @@ pub fn solve<T: Game<Player = ZeroSumPlayer> + Eq + Hash>(
while alpha < beta {
let med = alpha + (beta - alpha) / 2;

// do a null window search
// do a [null window search](https://www.chessprogramming.org/Null_Window)
let evaluation = negamax(game, transposition_table, med, med + 1)?;

if evaluation <= med {
Expand Down
1 change: 0 additions & 1 deletion crates/game-solver/src/transposition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@ impl<
pub fn new() -> Self {
let score_size = std::mem::size_of::<Score>() as u64;

// FIXME: this is bad to hardcode and should instead be configurable
Self::with_capacity(
// get three fourths of the memory, and divide that by the size of a score
// to get the number of scores that can fit in the cache
Expand Down
1 change: 1 addition & 0 deletions crates/games-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use games::{
reversi::Reversi, tic_tac_toe::TicTacToe, util::cli::play, Games,
};

/// `game-solver` is a solving utility that helps analyze various combinatorial games.
#[derive(Parser)]
#[command(version, about, long_about = None)]
struct Cli {
Expand Down
2 changes: 1 addition & 1 deletion crates/games-ui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ fn main() {
.start(
"the_canvas_id", // hardcode it
web_options,
Box::new(|cc| Box::new(games_ui::TemplateApp::new(cc))),
Box::new(|cc| Ok(Box::new(games_ui::TemplateApp::new(cc)))),
)
.await
.expect("failed to start eframe");
Expand Down
123 changes: 85 additions & 38 deletions crates/games/src/domineering/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,29 @@ use thiserror::Error;

use crate::util::cli::move_failable;

#[derive(Clone, Hash, Eq, PartialEq, Debug, Copy)]
pub enum Orientation {
Horizontal,
Vertical
}

impl Orientation {
fn turn(&self) -> Orientation {
match *self {
Orientation::Horizontal => Orientation::Vertical,
Orientation::Vertical => Orientation::Horizontal
}
}
}

#[derive(Clone, Hash, Eq, PartialEq)]
pub struct Domineering<const WIDTH: usize, const HEIGHT: usize> {
/// True represents a square - true if empty, false otherwise
// TODO: bit array 2d
/// True represents a square - true if empty, false otherwise
board: Array2D<bool>,
move_count: usize,
/// The orientation the first player will play as.
primary_orientation: Orientation,
}

impl<const WIDTH: usize, const HEIGHT: usize> Default for Domineering<WIDTH, HEIGHT> {
Expand All @@ -31,9 +48,14 @@ impl<const WIDTH: usize, const HEIGHT: usize> Default for Domineering<WIDTH, HEI

impl<const WIDTH: usize, const HEIGHT: usize> Domineering<WIDTH, HEIGHT> {
pub fn new() -> Self {
Self::new_orientation(Orientation::Vertical)
}

pub fn new_orientation(orientation: Orientation) -> Self {
Self {
board: Array2D::filled_with(true, WIDTH, HEIGHT),
move_count: 0,
primary_orientation: orientation
}
}
}
Expand All @@ -55,6 +77,35 @@ impl Display for DomineeringMove {
}
}

impl<const WIDTH: usize, const HEIGHT: usize> Domineering<WIDTH, HEIGHT> {
fn place(&mut self, m: &DomineeringMove, orientation: Orientation) -> Result<(), DomineeringMoveError> {
match orientation {
Orientation::Horizontal => {
if m.0 == WIDTH - 1 {
return Err(DomineeringMoveError::BlockingAdjacent(
m.clone(),
self.player(),
));
}
self.board.set(m.0, m.1, false).unwrap();
self.board.set(m.0 + 1, m.1, false).unwrap();
},
Orientation::Vertical => {
if m.1 == HEIGHT - 1 {
return Err(DomineeringMoveError::BlockingAdjacent(
m.clone(),
self.player(),
));
}
self.board.set(m.0, m.1, false).unwrap();
self.board.set(m.0, m.1 + 1, false).unwrap();
}
};

Ok(())
}
}

impl<const WIDTH: usize, const HEIGHT: usize> Game for Domineering<WIDTH, HEIGHT> {
type Move = DomineeringMove;
type Iter<'a> = std::vec::IntoIter<Self::Move>;
Expand All @@ -79,25 +130,11 @@ impl<const WIDTH: usize, const HEIGHT: usize> Game for Domineering<WIDTH, HEIGHT

fn make_move(&mut self, m: &Self::Move) -> Result<(), Self::MoveError> {
if *self.board.get(m.0, m.1).unwrap() {
if self.player() == ZeroSumPlayer::One {
if m.0 == WIDTH - 1 {
return Err(DomineeringMoveError::BlockingAdjacent(
m.clone(),
self.player(),
));
}
self.board.set(m.0, m.1, false).unwrap();
self.board.set(m.0 + 1, m.1, false).unwrap();
self.place(m, if self.player() == ZeroSumPlayer::One {
self.primary_orientation
} else {
if m.1 == HEIGHT - 1 {
return Err(DomineeringMoveError::BlockingAdjacent(
m.clone(),
self.player(),
));
}
self.board.set(m.0, m.1, false).unwrap();
self.board.set(m.0, m.1 + 1, false).unwrap();
}
self.primary_orientation.turn()
})?;

self.move_count += 1;
Ok(())
Expand All @@ -111,23 +148,33 @@ impl<const WIDTH: usize, const HEIGHT: usize> Game for Domineering<WIDTH, HEIGHT

fn possible_moves(&self) -> Self::Iter<'_> {
let mut moves = Vec::new();
if self.player() == ZeroSumPlayer::One {
for i in 0..HEIGHT {
for j in 0..WIDTH - 1 {
if *self.board.get(j, i).unwrap() && *self.board.get(j + 1, i).unwrap() {
moves.push(DomineeringMove(j, i));
let orientation = if self.player() == ZeroSumPlayer::One {
self.primary_orientation
} else {
self.primary_orientation.turn()
};

match orientation {
Orientation::Horizontal => {
for i in 0..HEIGHT {
for j in 0..WIDTH - 1 {
if *self.board.get(j, i).unwrap() && *self.board.get(j + 1, i).unwrap() {
moves.push(DomineeringMove(j, i));
}
}
}
}
} else {
for i in 0..HEIGHT - 1 {
for j in 0..WIDTH {
if *self.board.get(j, i).unwrap() && *self.board.get(j, i + 1).unwrap() {
moves.push(DomineeringMove(j, i));
},
Orientation::Vertical => {
for i in 0..HEIGHT - 1 {
for j in 0..WIDTH {
if *self.board.get(j, i).unwrap() && *self.board.get(j, i + 1).unwrap() {
moves.push(DomineeringMove(j, i));
}
}
}
}
}

moves.into_iter()
}

Expand Down Expand Up @@ -195,8 +242,8 @@ mod tests {
use super::*;

/// Get the winner of a generic configuration of domineering
fn winner<const WIDTH: usize, const HEIGHT: usize>() -> Option<ZeroSumPlayer> {
let game = Domineering::<WIDTH, HEIGHT>::new();
fn winner<const WIDTH: usize, const HEIGHT: usize>(orientation: Orientation) -> Option<ZeroSumPlayer> {
let game = Domineering::<WIDTH, HEIGHT>::new_orientation(orientation);
let mut move_scores = move_scores(&game, &mut HashMap::new())
.collect::<Result<Vec<_>, DomineeringMoveError>>()
.unwrap();
Expand All @@ -216,16 +263,16 @@ mod tests {

#[test]
fn test_wins() {
assert_eq!(winner::<5, 5>(), Some(ZeroSumPlayer::Two));
assert_eq!(winner::<4, 4>(), Some(ZeroSumPlayer::One));
assert_eq!(winner::<3, 3>(), Some(ZeroSumPlayer::One));
assert_eq!(winner::<13, 2>(), Some(ZeroSumPlayer::Two));
assert_eq!(winner::<11, 2>(), Some(ZeroSumPlayer::One));
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));
}

#[test]
fn test_domineering() {
let game = Domineering::<5, 5>::new();
let game = Domineering::<5, 5>::new_orientation(Orientation::Horizontal);
let mut move_scores = move_scores(&game, &mut HashMap::new())
.collect::<Result<Vec<_>, DomineeringMoveError>>()
.unwrap();
Expand Down

0 comments on commit 9bb5d86

Please sign in to comment.