use rand::seq::SliceRandom;
use rand::Rng;
use std::collections::HashMap;
use std::collections::HashSet;
use std::hash::BuildHasher;
use crate::game;
#[derive(Debug, Clone, PartialEq, Hash)]
pub struct Opponent {
difficulty: Difficulty,
}
impl Opponent {
pub fn new(difficulty: Difficulty) -> Self {
Self { difficulty }
}
pub fn get_move(&self, game: &game::Game) -> Option<game::Position> {
let outcomes = self.evaluate_game(game);
best_position(&outcomes)
}
pub fn evaluate_game(&self, game: &game::Game) -> HashMap<game::Position, Outcome> {
if let Some(outcomes) = self.get_cached_outcomes(&game) {
outcomes
} else {
let mut outcomes = HashMap::new();
let ai_player = AiPlayer::from_game_state(game.state());
for position in game.free_positions() {
let outcome = self.evaluate_position(&game, position, ai_player, 0);
outcomes.insert(position, outcome);
}
outcomes
}
}
fn evaluate_position(
&self,
game: &game::Game,
position: game::Position,
ai_player: AiPlayer,
depth: i32,
) -> Outcome {
const MAX_RECURSION_DEPTH: i32 = 20;
assert!(
depth <= MAX_RECURSION_DEPTH,
"The AI algorithm has reached the maximum recursion limit of {} and \
cannot continue to evaluate the game. This condition is the result \
of a bug in the open_ttt_lib used by this application.",
depth
);
debug_assert!(
game.can_move(position),
"Cannot move into the provided position, {:?}. Thus, the position \
cannot be evaluated. Ensure the game is not over and the position \
is free. This condition is the result of a bug in the open_ttt_lib \
used by this application.",
position
);
if !self.difficulty.should_evaluate_node(depth) {
return Outcome::Unknown;
}
let is_my_turn = ai_player == AiPlayer::from_game_state(game.state());
let mut game = game.clone();
let state = game.do_move(position).unwrap();
if state.is_game_over() {
return Outcome::from_game_state(state, ai_player);
}
let mut outcomes = HashSet::new();
for free_position in game.free_positions() {
let outcome = self.evaluate_position(&game, free_position, ai_player, depth + 1);
if is_worst_outcome(outcome, is_my_turn) {
return outcome;
}
outcomes.insert(outcome);
}
worst_outcome(&outcomes, is_my_turn)
}
fn get_cached_outcomes(&self, game: &game::Game) -> Option<HashMap<game::Position, Outcome>> {
if game.state().is_game_over() {
Some(HashMap::new())
} else if is_new_game(&game) {
let outcomes =
initialize_free_position_outcomes(game.free_positions(), Outcome::CatsGame);
Some(outcomes)
} else {
None
}
}
}
#[derive(Debug, Copy, Clone, PartialEq, Hash)]
pub enum Difficulty {
None,
Easy,
Medium,
Hard,
Unbeatable,
Custom(fn(depth: i32) -> bool),
}
impl Difficulty {
fn should_evaluate_node(&self, depth: i32) -> bool {
match self {
Self::None => Difficulty::none_should_evaluate_node(),
Self::Easy => Difficulty::easy_should_evaluate_node(depth),
Self::Medium => Difficulty::medium_should_evaluate_node(depth),
Self::Hard => Difficulty::hard_should_evaluate_node(depth),
Self::Unbeatable => Difficulty::unbeatable_should_evaluate_node(),
Self::Custom(custom_should_evaluate_node) => custom_should_evaluate_node(depth),
}
}
fn none_should_evaluate_node() -> bool {
false
}
fn easy_should_evaluate_node(depth: i32) -> bool {
if depth == 0 {
rand::thread_rng().gen_bool(0.5)
} else {
false
}
}
fn medium_should_evaluate_node(depth: i32) -> bool {
if depth == 0 {
rand::thread_rng().gen_bool(0.9)
} else {
rand::thread_rng().gen_bool(0.75)
}
}
fn hard_should_evaluate_node(depth: i32) -> bool {
if depth <= 1 {
true
} else {
rand::thread_rng().gen_bool(0.97)
}
}
fn unbeatable_should_evaluate_node() -> bool {
true
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
pub enum Outcome {
Win,
Loss,
CatsGame,
Unknown,
}
impl Outcome {
fn from_game_state(state: game::State, ai_player: AiPlayer) -> Self {
match state {
game::State::CatsGame => Outcome::CatsGame,
game::State::PlayerXWin(_) => match ai_player {
AiPlayer::PlayerX => Outcome::Win,
AiPlayer::PlayerO => Outcome::Loss,
},
game::State::PlayerOWin(_) => match ai_player {
AiPlayer::PlayerX => Outcome::Loss,
AiPlayer::PlayerO => Outcome::Win,
},
_ => panic!(
"Cannot determine the AI outcome since the game is not over. \
This condition is the result of a bug in the \
open_ttt_lib used by this application."
),
}
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
enum AiPlayer {
PlayerX,
PlayerO,
}
impl AiPlayer {
fn from_game_state(state: game::State) -> Self {
match state {
game::State::PlayerXMove => Self::PlayerX,
game::State::PlayerOMove => Self::PlayerO,
_ => panic!(
"Cannot determine the AI player since the game is over. \
This condition is the result of a bug in the \
open_ttt_lib used by this application."
),
}
}
}
pub fn best_position<S: BuildHasher>(
outcomes: &HashMap<game::Position, Outcome, S>,
) -> Option<game::Position> {
let mut outcome_to_position_map = HashMap::new();
for (position, outcome) in outcomes {
if !outcome_to_position_map.contains_key(outcome) {
outcome_to_position_map.insert(outcome, Vec::new());
}
outcome_to_position_map
.get_mut(outcome)
.unwrap()
.push(position);
}
let best_to_worst_outcomes = [
Outcome::Win,
Outcome::CatsGame,
Outcome::Unknown,
Outcome::Loss,
];
for outcome in &best_to_worst_outcomes {
if outcome_to_position_map.contains_key(outcome) {
let random_position = **outcome_to_position_map
.get(outcome)
.unwrap()
.choose(&mut rand::thread_rng())
.unwrap();
return Some(random_position);
}
}
None
}
fn initialize_free_position_outcomes(
free_positions: game::FreePositions,
outcome: Outcome,
) -> HashMap<game::Position, Outcome> {
let mut outcomes = HashMap::new();
for position in free_positions {
outcomes.insert(position, outcome);
}
outcomes
}
fn worst_to_best_outcomes(is_my_turn: bool) -> [Outcome; 3] {
if is_my_turn {
[Outcome::Loss, Outcome::CatsGame, Outcome::Win]
} else {
[Outcome::Win, Outcome::CatsGame, Outcome::Loss]
}
}
fn is_worst_outcome(outcome: Outcome, is_my_turn: bool) -> bool {
const WORST_OUTCOME_INDEX: usize = 0;
worst_to_best_outcomes(is_my_turn)[WORST_OUTCOME_INDEX] == outcome
}
fn worst_outcome(outcomes: &HashSet<Outcome>, is_my_turn: bool) -> Outcome {
for outcome in &worst_to_best_outcomes(is_my_turn) {
if outcomes.contains(outcome) {
return *outcome;
}
}
Outcome::Unknown
}
fn is_new_game(game: &game::Game) -> bool {
let board_size = game.board().size();
let total_positions = board_size.columns * board_size.rows;
game.free_positions().count() as i32 == total_positions
}
#[allow(non_snake_case)]
#[cfg(test)]
mod tests {
use super::*;
const PLAYER_X_MOVE_WITH_WIN_AVAILABLE: [game::Position; 6] = [
game::Position { row: 0, column: 0 },
game::Position { row: 0, column: 1 },
game::Position { row: 0, column: 2 },
game::Position { row: 1, column: 1 },
game::Position { row: 2, column: 0 },
game::Position { row: 2, column: 2 },
];
const PLAYER_X_WIN: [game::Position; 7] = [
game::Position { row: 0, column: 0 },
game::Position { row: 0, column: 1 },
game::Position { row: 0, column: 2 },
game::Position { row: 1, column: 1 },
game::Position { row: 2, column: 0 },
game::Position { row: 2, column: 2 },
game::Position { row: 1, column: 0 },
];
fn create_game(owned_positions: &[game::Position]) -> game::Game {
let mut game = game::Game::new();
for position in owned_positions {
game.do_move(*position).unwrap();
}
game
}
#[test]
fn opponent_new_should_set_difficulty() {
let expected_difficulty = Difficulty::Medium;
let opponent = Opponent::new(expected_difficulty);
let actual_difficulty = opponent.difficulty;
assert_eq!(expected_difficulty, actual_difficulty);
}
#[test]
fn opponent_get_move_when_game_is_over_should_be_none() {
let game = create_game(&PLAYER_X_WIN);
let opponent = Opponent::new(Difficulty::None);
let expected_position = None;
let actual_position = opponent.get_move(&game);
assert_eq!(
expected_position,
actual_position,
"\nGame board used for this test: \n{}",
game.board()
);
}
#[test]
fn opponent_get_move_when_unbeatable_difficulty_should_pick_wining_position() {
let game = create_game(&PLAYER_X_MOVE_WITH_WIN_AVAILABLE);
let opponent = Opponent::new(Difficulty::Unbeatable);
let expected_position = game::Position { row: 1, column: 0 };
let actual_position = opponent.get_move(&game).unwrap();
assert_eq!(
expected_position,
actual_position,
"\nGame board used for this test: \n{}",
game.board()
);
}
#[test]
fn opponent_evaluate_game_when_new_game_and_unbeatable_difficulty_should_be_cats_game_for_all_positions(
) {
let game = game::Game::new();
let opponent = Opponent::new(Difficulty::Unbeatable);
let expected_outcomes =
initialize_free_position_outcomes(game.free_positions(), Outcome::CatsGame);
let actual_outcomes = opponent.evaluate_game(&game);
assert_eq!(
expected_outcomes,
actual_outcomes,
"\nGame board used for this test: \n{}",
game.board()
);
}
#[test]
fn opponent_evaluate_game_when_game_over_should_be_empty_map() {
let game = create_game(&PLAYER_X_WIN);
let opponent = Opponent::new(Difficulty::Unbeatable);
let expected_outcomes = HashMap::new();
let actual_outcomes = opponent.evaluate_game(&game);
assert_eq!(
expected_outcomes,
actual_outcomes,
"\nGame board used for this test: \n{}",
game.board()
);
}
#[test]
fn opponent_evaluate_game_when_unbeatable_difficulty_should_evaluate_all_positions() {
let game = create_game(&PLAYER_X_MOVE_WITH_WIN_AVAILABLE);
let opponent = Opponent::new(Difficulty::Unbeatable);
let mut expected_outcomes = HashMap::new();
expected_outcomes.insert(game::Position { row: 1, column: 0 }, Outcome::Win);
expected_outcomes.insert(game::Position { row: 1, column: 2 }, Outcome::Loss);
expected_outcomes.insert(game::Position { row: 2, column: 1 }, Outcome::CatsGame);
let actual_outcomes = opponent.evaluate_game(&game);
assert_eq!(
expected_outcomes,
actual_outcomes,
"\nGame board used for this test: \n{}",
game.board()
);
}
#[test]
fn opponent_evaluate_game_when_none_difficulty_should_see_unknown_outcome_for_all_positions() {
let game = create_game(&PLAYER_X_MOVE_WITH_WIN_AVAILABLE);
let opponent = Opponent::new(Difficulty::None);
let mut expected_outcomes = HashMap::new();
expected_outcomes.insert(game::Position { row: 1, column: 0 }, Outcome::Unknown);
expected_outcomes.insert(game::Position { row: 1, column: 2 }, Outcome::Unknown);
expected_outcomes.insert(game::Position { row: 2, column: 1 }, Outcome::Unknown);
let actual_outcomes = opponent.evaluate_game(&game);
assert_eq!(
expected_outcomes,
actual_outcomes,
"\nGame board used for this test: \n{}",
game.board()
);
}
#[test]
fn opponent_evaluate_game_depth_should_start_at_zero() {
let game = create_game(&PLAYER_X_MOVE_WITH_WIN_AVAILABLE);
let opponent = Opponent::new(Difficulty::Custom(|depth| {
assert_eq!(depth, 0);
false
}));
opponent.evaluate_game(&game);
}
#[test]
#[should_panic(expected = "The depth has been incremented.")]
fn opponent_evaluate_game_should_increment_depth() {
let game = create_game(&PLAYER_X_MOVE_WITH_WIN_AVAILABLE);
let opponent = Opponent::new(Difficulty::Custom(|depth| {
if depth > 0 {
panic!("The depth has been incremented.");
}
true
}));
opponent.evaluate_game(&game);
}
#[test]
fn opponent_best_position_when_outcomes_empty_should_none() {
let outcomes = HashMap::new();
let expected_position = None;
let actual_position = best_position(&outcomes);
assert_eq!(expected_position, actual_position);
}
#[test]
fn opponent_best_position_when_win_and_cats_game_should_be_win() {
let mut outcomes = HashMap::new();
outcomes.insert(game::Position { row: 0, column: 0 }, Outcome::CatsGame);
let expected_position = game::Position { row: 0, column: 1 };
outcomes.insert(expected_position, Outcome::Win);
let actual_position = best_position(&outcomes);
assert_eq!(Some(expected_position), actual_position);
}
#[test]
fn opponent_best_position_when_win_and_unknown_should_be_win() {
let mut outcomes = HashMap::new();
outcomes.insert(game::Position { row: 0, column: 0 }, Outcome::Unknown);
let expected_position = game::Position { row: 0, column: 1 };
outcomes.insert(expected_position, Outcome::Win);
let actual_position = best_position(&outcomes);
assert_eq!(Some(expected_position), actual_position);
}
#[test]
fn opponent_best_position_when_win_and_loss_should_be_win() {
let mut outcomes = HashMap::new();
outcomes.insert(game::Position { row: 0, column: 0 }, Outcome::Loss);
let expected_position = game::Position { row: 0, column: 1 };
outcomes.insert(expected_position, Outcome::Win);
let actual_position = best_position(&outcomes);
assert_eq!(Some(expected_position), actual_position);
}
#[test]
fn opponent_best_position_when_cats_game_and_loss_should_be_cats_game() {
let mut outcomes = HashMap::new();
outcomes.insert(game::Position { row: 0, column: 0 }, Outcome::Loss);
let expected_position = game::Position { row: 0, column: 1 };
outcomes.insert(expected_position, Outcome::CatsGame);
let actual_position = best_position(&outcomes);
assert_eq!(Some(expected_position), actual_position);
}
#[test]
fn opponent_best_position_when_cats_game_and_unknown_should_be_cats_game() {
let mut outcomes = HashMap::new();
outcomes.insert(game::Position { row: 0, column: 0 }, Outcome::Unknown);
let expected_position = game::Position { row: 0, column: 1 };
outcomes.insert(expected_position, Outcome::CatsGame);
let actual_position = best_position(&outcomes);
assert_eq!(Some(expected_position), actual_position);
}
#[test]
fn opponent_best_position_when_unknown_and_loss_should_be_unknown() {
let mut outcomes = HashMap::new();
outcomes.insert(game::Position { row: 0, column: 0 }, Outcome::Loss);
let expected_position = game::Position { row: 0, column: 1 };
outcomes.insert(expected_position, Outcome::Unknown);
let actual_position = best_position(&outcomes);
assert_eq!(Some(expected_position), actual_position);
}
#[test]
fn opponent_best_position_when_same_outcome_should_pick_random_position() {
let mut outcomes = HashMap::new();
outcomes.insert(game::Position { row: 0, column: 0 }, Outcome::CatsGame);
outcomes.insert(game::Position { row: 0, column: 1 }, Outcome::CatsGame);
outcomes.insert(game::Position { row: 0, column: 2 }, Outcome::CatsGame);
let mut positions_set = HashSet::new();
const NUM_TIMES: i32 = 300;
for _ in 0..NUM_TIMES {
let position = best_position(&outcomes);
positions_set.insert(position);
}
assert_eq!(
outcomes.len(),
positions_set.len(),
"This test relies on random behavior. Therefore, it is possible, \
although highly unlikely, that the test could fail even if the \
code is working as expected. If this happens try re-running the \
test a few times. Continual failures indicate there is a problem \
that needs addressed in the code as the requirement of picking \
random positions is not being fulfilled."
);
}
#[test]
fn difficulty_when_custom_should_call_provided_function() {
const TRUE_DEPTH_VALUE: i32 = 42_000;
let custom_difficulty = Difficulty::Custom(|depth| depth == TRUE_DEPTH_VALUE);
assert!(custom_difficulty.should_evaluate_node(TRUE_DEPTH_VALUE));
assert!(!custom_difficulty.should_evaluate_node(0));
}
#[test]
fn ai_player_from_game_state_when_player_X_move_should_be_player_X() {
let game_state = game::State::PlayerXMove;
let expected_ai_player = AiPlayer::PlayerX;
let actual_ai_player = AiPlayer::from_game_state(game_state);
assert_eq!(expected_ai_player, actual_ai_player);
}
#[test]
fn ai_player_from_game_state_when_player_O_move_should_be_player_O() {
let game_state = game::State::PlayerOMove;
let expected_ai_player = AiPlayer::PlayerO;
let actual_ai_player = AiPlayer::from_game_state(game_state);
assert_eq!(expected_ai_player, actual_ai_player);
}
#[test]
#[should_panic]
fn ai_player_from_game_state_when_game_over_should_panic() {
let game_state = game::State::CatsGame;
let _actual_ai_player = AiPlayer::from_game_state(game_state);
}
#[test]
fn outcome_from_game_state_when_cats_game_should_be_cats_game() {
let game_state = game::State::CatsGame;
let ai_player = AiPlayer::PlayerX;
let expected_outcome = Outcome::CatsGame;
let actual_outcome = Outcome::from_game_state(game_state, ai_player);
assert_eq!(expected_outcome, actual_outcome);
}
#[test]
fn outcome_from_game_state_when_player_X_win_and_player_X_should_be_win() {
let game_state = game::State::PlayerXWin(Default::default());
let ai_player = AiPlayer::PlayerX;
let expected_outcome = Outcome::Win;
let actual_outcome = Outcome::from_game_state(game_state, ai_player);
assert_eq!(expected_outcome, actual_outcome);
}
#[test]
fn outcome_from_game_state_when_player_X_win_and_player_O_should_be_loss() {
let game_state = game::State::PlayerXWin(Default::default());
let ai_player = AiPlayer::PlayerO;
let expected_outcome = Outcome::Loss;
let actual_outcome = Outcome::from_game_state(game_state, ai_player);
assert_eq!(expected_outcome, actual_outcome);
}
#[test]
fn outcome_from_game_state_when_player_O_win_and_player_O_should_be_win() {
let game_state = game::State::PlayerOWin(Default::default());
let ai_player = AiPlayer::PlayerO;
let expected_outcome = Outcome::Win;
let actual_outcome = Outcome::from_game_state(game_state, ai_player);
assert_eq!(expected_outcome, actual_outcome);
}
#[test]
fn outcome_from_game_state_when_player_O_win_and_player_X_should_be_loss() {
let game_state = game::State::PlayerOWin(Default::default());
let ai_player = AiPlayer::PlayerX;
let expected_outcome = Outcome::Loss;
let actual_outcome = Outcome::from_game_state(game_state, ai_player);
assert_eq!(expected_outcome, actual_outcome);
}
#[test]
fn worst_outcome_when_empty_should_be_unknown() {
let outcomes = Default::default();
let is_my_turn = true;
let expected_outcome = Outcome::Unknown;
let actual_outcome = worst_outcome(&outcomes, is_my_turn);
assert_eq!(expected_outcome, actual_outcome);
}
#[test]
fn worst_outcome_when_my_turn_with_win_and_loss_should_be_loss() {
let outcomes = [Outcome::Win, Outcome::Loss].iter().cloned().collect();
let is_my_turn = true;
let expected_outcome = Outcome::Loss;
let actual_outcome = worst_outcome(&outcomes, is_my_turn);
assert_eq!(expected_outcome, actual_outcome);
}
#[test]
fn worst_outcome_when_my_turn_with_cats_game_and_loss_should_be_loss() {
let outcomes = [Outcome::CatsGame, Outcome::Loss].iter().cloned().collect();
let is_my_turn = true;
let expected_outcome = Outcome::Loss;
let actual_outcome = worst_outcome(&outcomes, is_my_turn);
assert_eq!(expected_outcome, actual_outcome);
}
#[test]
fn worst_outcome_when_my_turn_with_cats_game_and_cats_game_should_be_cats_game() {
let outcomes = [Outcome::Win, Outcome::CatsGame].iter().cloned().collect();
let is_my_turn = true;
let expected_outcome = Outcome::CatsGame;
let actual_outcome = worst_outcome(&outcomes, is_my_turn);
assert_eq!(expected_outcome, actual_outcome);
}
#[test]
fn worst_outcome_when_not_my_turn_with_win_and_loss_should_be_win() {
let outcomes = [Outcome::Win, Outcome::Loss].iter().cloned().collect();
let is_my_turn = false;
let expected_outcome = Outcome::Win;
let actual_outcome = worst_outcome(&outcomes, is_my_turn);
assert_eq!(expected_outcome, actual_outcome);
}
#[test]
fn worst_outcome_when_not_my_turn_with_cats_game_and_loss_should_be_cats_game() {
let outcomes = [Outcome::CatsGame, Outcome::Loss].iter().cloned().collect();
let is_my_turn = false;
let expected_outcome = Outcome::CatsGame;
let actual_outcome = worst_outcome(&outcomes, is_my_turn);
assert_eq!(expected_outcome, actual_outcome);
}
#[test]
fn worst_outcome_when_not_my_turn_with_cats_game_and_cats_game_should_be_win() {
let outcomes = [Outcome::Win, Outcome::CatsGame].iter().cloned().collect();
let is_my_turn = false;
let expected_outcome = Outcome::Win;
let actual_outcome = worst_outcome(&outcomes, is_my_turn);
assert_eq!(expected_outcome, actual_outcome);
}
#[test]
fn initialize_free_position_outcomes_should_set_indicated_outcome() {
let game = game::Game::new();
let expected_outcome = Outcome::Win;
let actual_outcomes =
initialize_free_position_outcomes(game.free_positions(), expected_outcome);
assert!(actual_outcomes
.iter()
.all(|(_position, outcome)| *outcome == expected_outcome));
}
}