Skip to content

Commit

Permalink
feat: better cli output, correct move scoring
Browse files Browse the repository at this point in the history
  • Loading branch information
LeoDog896 committed Sep 25, 2024
1 parent d3a54eb commit 50aac7c
Show file tree
Hide file tree
Showing 9 changed files with 148 additions and 30 deletions.
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
"Cargo.toml"
],
"rust-analyzer.check.features": "all",
"rust-analyzer.cargo.features": "all"
"rust-analyzer.cargo.features": "all",
"typos.config": "_typos.toml"
}
3 changes: 3 additions & 0 deletions _typos.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[default.extend-words]
nd = "nd"
misere = "misere"
58 changes: 52 additions & 6 deletions crates/game-solver/src/game.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
//! Game trait and related types.
use std::cmp::Ordering;

use crate::player::Player;

/// Represents a move outcome
Expand Down Expand Up @@ -92,20 +94,40 @@ pub trait Game: Clone {
///
/// Rather, this function asks if there exists some game in the possible games set
/// which has a resolvable, positive or negative, outcome.
///
/// This function must act in the Next player's best interest.
/// Positive games should have highest priority, then tied games, then lost games.
/// Exact order of what game is returned doesn't matter past its outcome equivalency,
/// as the score is dependent on move count.
///
/// (If this function returns a losing game when a positive game exists
/// in the set of immediately resolvable games, that is a violation of this
/// function's contract).
///
/// This function's default implementation is quite slow,
/// and it's encouraged to use a custom implementation.
fn find_immediately_resolvable_game(&self) -> Result<Option<Self>, Self::MoveError> {
let mut best_non_winning_game: Option<Self> = None;

for m in &mut self.possible_moves() {
let mut new_self = self.clone();
new_self.make_move(&m)?;
if let GameState::Win(_) = new_self.state() {
return Ok(Some(new_self));
}
match new_self.state() {
GameState::Playable => continue,
GameState::Tie => best_non_winning_game = Some(new_self),
GameState::Win(winning_player) => if winning_player == self.player().turn() {
return Ok(Some(new_self))
} else if best_non_winning_game.is_none() {
best_non_winning_game = Some(new_self)
}
};
}

Ok(None)
Ok(best_non_winning_game)
}

/// Returns the current state of the game.
/// Used for verifying initialization and isn't commonly called.
/// Used for verifying initialization and is commonly called.
///
/// If `Self::STATE_TYPE` isn't None,
/// the following implementation can be used:
Expand Down Expand Up @@ -150,7 +172,31 @@ pub trait Game: Clone {
fn player(&self) -> Self::Player;
}

/// Utility function to get the upper bound of a game.
/// Utility function to get the upper score bound of a game.
///
/// Essentially, score computation generally gives some max (usually max moves),
/// and penalizes the score by the amount of moves that have been made, as we're
/// trying to encourage winning in the shortest amount of time - God's algorithm.
///
/// Note: Despite this returning isize, this function will always be positive.
pub fn upper_bound<T: Game>(game: &T) -> isize {
game.max_moves().map_or(isize::MAX, |m| m as isize)
}

/// Represents an outcome of a game derived by a score and a valid instance of a game.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum GameScoreOutcome {
Win(usize),
Loss(usize),
Tie
}

/// Utility function to convert a score to the
/// amount of moves to a win or loss, or a tie.
pub fn score_to_outcome<T: Game>(game: &T, score: isize) -> GameScoreOutcome {
match score.cmp(&0) {
Ordering::Greater => GameScoreOutcome::Win((-score + upper_bound(game)) as usize),
Ordering::Equal => GameScoreOutcome::Tie,
Ordering::Less => GameScoreOutcome::Loss((score + upper_bound(game)) as usize)
}
}
22 changes: 20 additions & 2 deletions crates/game-solver/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub mod player;
// pub mod reinforcement;
pub mod transposition;

use core::panic;
#[cfg(feature = "rayon")]
use std::hash::BuildHasher;

Expand All @@ -29,15 +30,32 @@ fn negamax<T: Game<Player = impl TwoPlayer> + Eq + Hash>(
mut alpha: isize,
mut beta: isize,
) -> Result<isize, T::MoveError> {
// TODO(perf): if find_immediately_resolvable_game satisfies its contract,
// we can ignore this at larger depths.
match game.state() {
GameState::Playable => (),
GameState::Tie => return Ok(0),
GameState::Win(_) => return Ok(0),
GameState::Win(winning_player) => {
// The next player is the winning player - the score should be positive.
if game.player() == winning_player {
return Ok(upper_bound(game) - game.move_count() as isize)
} else {
return Ok(-(upper_bound(game) - game.move_count() as isize))
}
},
};

// check if this is a winning configuration
if let Ok(Some(board)) = game.find_immediately_resolvable_game() {
return Ok(upper_bound(&board) - board.move_count() as isize - 1);
match board.state() {
GameState::Playable => panic!("A resolvable game should not be playable."),
GameState::Tie => return Ok(0),
GameState::Win(winning_player) => if game.player().turn() == winning_player {
return Ok(upper_bound(&board) - board.move_count() as isize);
} else {
return Ok(-(upper_bound(&board) - board.move_count() as isize));
}
}
}

// fetch values from the transposition table
Expand Down
33 changes: 27 additions & 6 deletions crates/game-solver/src/player.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/// Represents a player.
pub trait Player: Sized {
pub trait Player: Sized + Eq {
/// The max player count.
#[must_use]
fn count() -> usize;
Expand All @@ -12,6 +12,14 @@ pub trait Player: Sized {
/// The previous player to play
#[must_use]
fn previous(self) -> Self;
/// How the player instance 'changes' on the next move.
///
/// For partizan games, the player doesn't change:
/// Left stays left; right stays right.
///
/// For impartial games, the player does change:
/// Next turns into previous, and previous turns into next
fn turn(self) -> Self;
}

/// Represents a two player player.
Expand Down Expand Up @@ -57,6 +65,10 @@ impl Player for PartizanPlayer {
fn previous(self) -> Self {
self.next()
}

fn turn(self) -> Self {
self
}
}

impl TwoPlayer for PartizanPlayer {}
Expand Down Expand Up @@ -95,26 +107,31 @@ impl Player for ImpartialPlayer {
fn previous(self) -> Self {
self.next()
}

fn turn(self) -> Self {
self.next()
}
}

impl TwoPlayer for ImpartialPlayer {}

/// Represents a player in an N-player game.
pub struct NPlayerConst<const N: usize>(usize);
#[derive(PartialEq, Eq, PartialOrd, Ord)]
pub struct NPlayerPartizanConst<const N: usize>(usize);

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

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

impl<const N: usize> Player for NPlayerConst<N> {
impl<const N: usize> Player for NPlayerPartizanConst<N> {
fn count() -> usize {
N
}
Expand All @@ -135,4 +152,8 @@ impl<const N: usize> Player for NPlayerConst<N> {
Self::new_unchecked(self.0 - 1)
}
}

fn turn(self) -> Self {
self
}
}
23 changes: 17 additions & 6 deletions crates/games/src/nim/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use crate::util::{cli::move_failable, move_natural::NaturalMove};
pub struct Nim {
heaps: Vec<usize>,
move_count: usize,
max_score: usize,
max_moves: usize,
}

type NimMove = NaturalMove<2>;
Expand All @@ -31,7 +31,7 @@ impl Nim {
heaps: heaps.clone(),
move_count: 0,
// sum of all the heaps is the upper bound for the amount of moves
max_score: heaps.iter().sum::<usize>(),
max_moves: heaps.iter().sum::<usize>(),
}
}
}
Expand All @@ -52,14 +52,15 @@ impl Game for Nim {
/// where Move is a tuple of the heap index and the number of objects to remove
type Move = NimMove;
type Iter<'a> = std::vec::IntoIter<Self::Move>;
/// Define Nimbers as a zero-sum game

/// Define Nim as a zero-sum impartial game
type Player = ImpartialPlayer;
type MoveError = NimMoveError;

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

fn max_moves(&self) -> Option<usize> {
Some(self.max_score)
Some(self.max_moves)
}

fn move_count(&self) -> usize {
Expand Down Expand Up @@ -115,7 +116,7 @@ impl Game for Nim {
impl Display for Nim {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for (i, heap) in self.heaps.iter().enumerate() {
write!(f, "heap {i}: {heap}")?;
writeln!(f, "Heap {i}: {heap}")?;
}

Ok(())
Expand All @@ -128,7 +129,7 @@ impl Display for Nim {
#[derive(Args, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone)]
pub struct NimArgs {
/// The configuration of the game. For example, 3,5,7
/// creates a Nimbers game that has three heaps, where each
/// creates a Nim game that has three heaps, where each
/// heap has 3, 5, and 7 objects respectively
configuration: String,
/// Nim moves, ordered as x1-y1 x2-y2 ...
Expand Down Expand Up @@ -168,3 +169,13 @@ impl TryFrom<NimArgs> for Nim {
Ok(game)
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn max_moves_is_heap_sum() {
assert_eq!(Nim::new(vec![3, 5, 7]).max_moves(), Some(3 + 5 + 7));
}
}
5 changes: 5 additions & 0 deletions crates/games/src/sprouts/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
#![doc = include_str!("./README.md")]

use game_solver::game::Game;

Check warning on line 3 in crates/games/src/sprouts/mod.rs

View workflow job for this annotation

GitHub Actions / deploy

unused import: `game_solver::game::Game`

Check warning on line 3 in crates/games/src/sprouts/mod.rs

View workflow job for this annotation

GitHub Actions / deploy

unused import: `game_solver::game::Game`
use petgraph::matrix_graph::MatrixGraph;

#[derive(Clone)]
pub struct Sprouts(MatrixGraph<(), ()>);

Check warning on line 7 in crates/games/src/sprouts/mod.rs

View workflow job for this annotation

GitHub Actions / deploy

field `0` is never read

Check warning on line 7 in crates/games/src/sprouts/mod.rs

View workflow job for this annotation

GitHub Actions / deploy

field `0` is never read

// impl Game for Sprouts {

// }
29 changes: 22 additions & 7 deletions crates/games/src/util/cli/mod.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
use anyhow::{anyhow, Result};
use core::hash::Hash;
use game_solver::{
game::{Game, GameState},
game::{score_to_outcome, Game, GameScoreOutcome, GameState},
par_move_scores,
player::TwoPlayer,
player::{ImpartialPlayer, TwoPlayer},
};
use std::fmt::{Debug, Display};
use std::{any::TypeId, fmt::{Debug, Display}};

pub fn play<
T: Game<Player = impl TwoPlayer + Debug> + Eq + Hash + Sync + Send + Display + 'static,
T: Game<Player = impl TwoPlayer + Debug + 'static> + Eq + Hash + Sync + Send + Display + 'static,
>(
game: T,
) where
Expand All @@ -20,7 +20,12 @@ pub fn play<

match game.state() {
GameState::Playable => {
println!("Player {:?} to move", game.player());
if TypeId::of::<T::Player>() != TypeId::of::<ImpartialPlayer>() {
println!("Player {:?} to move", game.player());
} else {
// TODO: can we assert that game.player() is the next player?
println!("Impartial game; Next player is moving.");
}

let move_scores = par_move_scores(&game);
let mut move_scores = move_scores
Expand All @@ -34,15 +39,25 @@ pub fn play<
let mut current_move_score = None;
for (game_move, score) in move_scores {
if current_move_score != Some(score) {
println!("\n\nBest moves @ score {}:", score);
match score_to_outcome(&game, score) {
GameScoreOutcome::Win(moves) => println!("\n\nWin in {} move{} (score {}):", moves, if moves == 1 { "" } else { "s" }, score),
GameScoreOutcome::Loss(moves) => println!("\n\nLose in {} move{} (score {}):", moves, if moves == 1 { "" } else { "s" }, score),
GameScoreOutcome::Tie => println!("\n\nTie with the following moves:")
}
current_move_score = Some(score);
}
print!("{}, ", &game_move);
}
println!();
}
GameState::Tie => println!("No moves left! Game tied!"),
GameState::Win(player) => println!("Player {player:?} won!"),
GameState::Win(player) => {
if TypeId::of::<T::Player>() != TypeId::of::<ImpartialPlayer>() {
println!("The {player:?} player won!");
} else {
println!("Player {player:?} won!");
}
}
}
}

Expand Down
2 changes: 0 additions & 2 deletions typos.toml

This file was deleted.

0 comments on commit 50aac7c

Please sign in to comment.