diff --git a/crates/game-solver/src/lib.rs b/crates/game-solver/src/lib.rs index a92f595..175ed41 100644 --- a/crates/game-solver/src/lib.rs +++ b/crates/game-solver/src/lib.rs @@ -67,17 +67,25 @@ fn negamax + Eq + Hash>( return Ok(0); } GameState::Win(winning_player) => { - // The next player is the winning player - the score should be positive. + // if the next player is the winning player, + // the score should be positive. if game.player() == winning_player { if let Some(stats) = stats { stats.terminal_ends.winning.fetch_add(1, Ordering::Relaxed); } - return Ok(upper_bound(game) - game.move_count() as isize); + // we add one to make sure games that use up every move + // aren't represented by ties. + // + // take the 2 heap game where each heap has one object in Nim, for example + // player 2 will always win since 2 moves will always be used, + // but since the upper bound is 2, 2 - 2 = 0, + // but we reserve 0 for ties. + return Ok(upper_bound(game) - game.move_count() as isize + 1); } else { if let Some(stats) = stats { stats.terminal_ends.losing.fetch_add(1, Ordering::Relaxed); } - return Ok(-(upper_bound(game) - game.move_count() as isize)); + return Ok(-(upper_bound(game) - game.move_count() as isize + 1)); } } }; @@ -97,12 +105,12 @@ fn negamax + Eq + Hash>( if let Some(stats) = stats { stats.terminal_ends.winning.fetch_add(1, Ordering::Relaxed); } - return Ok(upper_bound(&board) - board.move_count() as isize); + return Ok(upper_bound(&board) - board.move_count() as isize + 1); } else { if let Some(stats) = stats { stats.terminal_ends.losing.fetch_add(1, Ordering::Relaxed); } - return Ok(-(upper_bound(&board) - board.move_count() as isize)); + return Ok(-(upper_bound(&board) - board.move_count() as isize + 1)); } } } diff --git a/crates/games/src/chomp/mod.rs b/crates/games/src/chomp/mod.rs index e018cf6..860c5c6 100644 --- a/crates/games/src/chomp/mod.rs +++ b/crates/games/src/chomp/mod.rs @@ -182,29 +182,29 @@ mod tests { move_scores.sort(); let mut new_scores = vec![ - (NaturalMove([2, 2]), 13), - (NaturalMove([5, 0]), -12), - (NaturalMove([4, 0]), -12), - (NaturalMove([3, 0]), -12), - (NaturalMove([2, 0]), -12), - (NaturalMove([0, 0]), -12), - (NaturalMove([5, 1]), -12), - (NaturalMove([4, 1]), -12), - (NaturalMove([3, 1]), -12), - (NaturalMove([2, 1]), -12), - (NaturalMove([0, 1]), -12), - (NaturalMove([5, 2]), -12), - (NaturalMove([4, 2]), -12), - (NaturalMove([3, 2]), -12), - (NaturalMove([5, 3]), -12), - (NaturalMove([1, 0]), -16), - (NaturalMove([1, 1]), -16), - (NaturalMove([1, 2]), -16), - (NaturalMove([4, 3]), -16), - (NaturalMove([3, 3]), -16), - (NaturalMove([2, 3]), -16), - (NaturalMove([0, 2]), -22), - (NaturalMove([1, 3]), -22), + (NaturalMove([2, 2]), 14), + (NaturalMove([5, 0]), -13), + (NaturalMove([4, 0]), -13), + (NaturalMove([3, 0]), -13), + (NaturalMove([2, 0]), -13), + (NaturalMove([0, 0]), -13), + (NaturalMove([5, 1]), -13), + (NaturalMove([4, 1]), -13), + (NaturalMove([3, 1]), -13), + (NaturalMove([2, 1]), -13), + (NaturalMove([0, 1]), -13), + (NaturalMove([5, 2]), -13), + (NaturalMove([4, 2]), -13), + (NaturalMove([3, 2]), -13), + (NaturalMove([5, 3]), -13), + (NaturalMove([1, 0]), -17), + (NaturalMove([1, 1]), -17), + (NaturalMove([1, 2]), -17), + (NaturalMove([4, 3]), -17), + (NaturalMove([3, 3]), -17), + (NaturalMove([2, 3]), -17), + (NaturalMove([0, 2]), -23), + (NaturalMove([1, 3]), -23), ]; new_scores.sort(); diff --git a/crates/games/src/domineering/mod.rs b/crates/games/src/domineering/mod.rs index bc6b7f1..4850dfe 100644 --- a/crates/games/src/domineering/mod.rs +++ b/crates/games/src/domineering/mod.rs @@ -317,26 +317,26 @@ mod tests { move_scores.sort(); let mut current_scores = vec![ - (DomineeringMove(3, 4), -13), - (DomineeringMove(0, 4), -13), - (DomineeringMove(3, 3), -13), - (DomineeringMove(2, 3), -13), - (DomineeringMove(1, 3), -13), - (DomineeringMove(0, 3), -13), - (DomineeringMove(3, 2), -13), - (DomineeringMove(0, 2), -13), - (DomineeringMove(3, 1), -13), - (DomineeringMove(2, 1), -13), - (DomineeringMove(1, 1), -13), - (DomineeringMove(0, 1), -13), - (DomineeringMove(3, 0), -13), - (DomineeringMove(0, 0), -13), - (DomineeringMove(2, 4), -15), - (DomineeringMove(1, 4), -15), - (DomineeringMove(2, 2), -15), - (DomineeringMove(1, 2), -15), - (DomineeringMove(2, 0), -15), - (DomineeringMove(1, 0), -15), + (DomineeringMove(3, 4), -14), + (DomineeringMove(0, 4), -14), + (DomineeringMove(3, 3), -14), + (DomineeringMove(2, 3), -14), + (DomineeringMove(1, 3), -14), + (DomineeringMove(0, 3), -14), + (DomineeringMove(3, 2), -14), + (DomineeringMove(0, 2), -14), + (DomineeringMove(3, 1), -14), + (DomineeringMove(2, 1), -14), + (DomineeringMove(1, 1), -14), + (DomineeringMove(0, 1), -14), + (DomineeringMove(3, 0), -14), + (DomineeringMove(0, 0), -14), + (DomineeringMove(2, 4), -16), + (DomineeringMove(1, 4), -16), + (DomineeringMove(2, 2), -16), + (DomineeringMove(1, 2), -16), + (DomineeringMove(2, 0), -16), + (DomineeringMove(1, 0), -16), ]; current_scores.sort(); diff --git a/crates/games/src/nim/mod.rs b/crates/games/src/nim/mod.rs index 5484a18..7ffdda2 100644 --- a/crates/games/src/nim/mod.rs +++ b/crates/games/src/nim/mod.rs @@ -181,10 +181,53 @@ impl TryFrom for Nim { #[cfg(test)] mod tests { + use std::collections::HashMap; + + use game_solver::{move_scores, CollectedMoves}; + use itertools::Itertools; + + use crate::util::move_score::best_move_score_testing; + use super::*; + fn play(nim: Nim) -> CollectedMoves { + move_scores(&nim, &mut HashMap::new(), None, &None).collect_vec() + } + #[test] fn max_moves_is_heap_sum() { assert_eq!(Nim::new(vec![3, 5, 7]).max_moves(), Some(3 + 5 + 7)); + assert_eq!(Nim::new(vec![0, 2, 2]).max_moves(), Some(0 + 2 + 2)); + } + + #[test] + fn single_heap() { + // p1 always wins for single-heap stacks + // the score is equivalent to the stack, as we can make 1 move to win, + // and score is always (max moves - moves made (+ 1 to account for ties)) + assert_eq!(best_move_score_testing(play(Nim::new(vec![7]))).1, 7); + assert_eq!(best_move_score_testing(play(Nim::new(vec![4]))).1, 4); + assert_eq!(best_move_score_testing(play(Nim::new(vec![20]))).1, 20); + } + + #[test] + fn empty_heap() { + // unless the heaps have nothing, in which we cant play + assert!(play(Nim::new(vec![0])).is_empty()); + assert!(play(Nim::new(vec![0, 0])).is_empty()); + } + + #[test] + fn symmetrical_nim_wins() { + // a loss in 4 moves: take 1, other player takes from other, take 1, other player takes from other + assert_eq!(best_move_score_testing(play(Nim::new(vec![2, 2]))).1, -1); + + // generalize this for more cases: + assert_eq!(best_move_score_testing(play(Nim::new(vec![6, 6]))).1, -1); + assert_eq!( + best_move_score_testing(play(Nim::new(vec![5, 5, 3, 3]))).1, + -1 + ); + assert_eq!(best_move_score_testing(play(Nim::new(vec![7, 7]))).1, -1); } } diff --git a/crates/games/src/util/cli/human.rs b/crates/games/src/util/cli/human.rs index eceb6c5..a4b07b8 100644 --- a/crates/games/src/util/cli/human.rs +++ b/crates/games/src/util/cli/human.rs @@ -4,7 +4,8 @@ use std::{ atomic::{AtomicBool, Ordering}, Arc, }, - thread, time::Duration, + thread, + time::Duration, }; use anyhow::Result; @@ -146,7 +147,7 @@ impl Widget for &App { .load(Ordering::Relaxed) .to_string() .yellow(), - ")".into() + ")".into(), ]), // TODO: depth // Line::from(vec![ @@ -192,7 +193,7 @@ where let mut app = App { exit: exit.clone(), stats: stats.clone(), - exit_ui: exit_ui.clone() + exit_ui: exit_ui.clone(), }; let game_thread = thread::spawn(move || { diff --git a/crates/games/src/util/move_score.rs b/crates/games/src/util/move_score.rs index 345437f..6ad1c4c 100644 --- a/crates/games/src/util/move_score.rs +++ b/crates/games/src/util/move_score.rs @@ -1,5 +1,7 @@ use game_solver::{game::Game, CollectedMoves, GameSolveError}; +/// Takes a move list and returns a sorted list, from positive to negative, of +/// the scores. pub fn normalize_move_scores( move_scores: CollectedMoves, ) -> Result, GameSolveError> { @@ -12,3 +14,20 @@ pub fn normalize_move_scores( Ok(move_scores) } + +pub fn best_move_score( + move_scores: CollectedMoves, +) -> Result, GameSolveError> { + let move_scores = move_scores + .into_iter() + .collect::, GameSolveError>>()?; + + Ok(move_scores.iter().max_by_key(|x| x.1).cloned()) +} + +#[cfg(test)] +pub fn best_move_score_testing( + move_scores: CollectedMoves, +) -> (T::Move, isize) { + best_move_score(move_scores).unwrap().unwrap() +}