Compare commits

..

7 Commits

Author SHA1 Message Date
786e70fe9a brutus tweaks 2024-03-06 10:38:02 -08:00
00270569db Minor edit 2024-03-06 10:36:04 -08:00
b39c618a2a Added brutus agent 2024-03-06 09:56:04 -08:00
4ee7f8a9ac Minor cleanup & utilities 2024-03-06 09:55:47 -08:00
89aba4f930 Cleanup 2024-03-05 14:58:36 -08:00
e8614dd29f Cleanup 2024-03-05 14:35:26 -08:00
b344ae359b Documentation & Tweaks 2024-03-05 14:22:30 -08:00
13 changed files with 465 additions and 177 deletions

60
Cargo.lock generated
View File

@ -120,6 +120,31 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7"
[[package]]
name = "crossbeam-deque"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345"
[[package]] [[package]]
name = "either" name = "either"
version = "1.10.0" version = "1.10.0"
@ -170,22 +195,23 @@ dependencies = [
] ]
[[package]] [[package]]
name = "numtoa" name = "minimax"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef"
[[package]]
name = "ops"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
"itertools", "itertools",
"rand", "rand",
"rayon",
"termion", "termion",
] ]
[[package]]
name = "numtoa"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef"
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.17" version = "0.2.17"
@ -240,6 +266,26 @@ dependencies = [
"getrandom", "getrandom",
] ]
[[package]]
name = "rayon"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4963ed1bc86e4f3ee217022bd855b297cef07fb9eac5dfa1f788b220b49b3bd"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.4.1" version = "0.4.1"

View File

@ -1,5 +1,5 @@
[package] [package]
name = "ops" name = "minimax"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
@ -8,4 +8,5 @@ anyhow = "1.0.80"
clap = { version = "4.5.1", features = ["derive"] } clap = { version = "4.5.1", features = ["derive"] }
itertools = "0.12.1" itertools = "0.12.1"
rand = "0.8.5" rand = "0.8.5"
rayon = "1.9.0"
termion = "3.0.0" termion = "3.0.0"

36
README.md Normal file
View File

@ -0,0 +1,36 @@
# Minimax
## Rules
This game is played in two rounds, starting with an empty eleven-space board. Red always goes first.
On Red's board (i.e, duing the first round), Red's job is to maximize the value of the expression; Blue's job is to minimize it.
Players take turns placing the fourteen symbols `0123456789+-×÷` on the board, with the maximizing player taking the first move.
A board's syntax must always be valid. Namely, the following rules are enforced:
- Each symbol may only be used once
- The binary operators `+-×÷` may not be next to one another, and may not be at the end slots.
- The unary operator `-` (negative) must have a number as an argument. Therefore, it cannot be left of an operator (like `-×`), and it may not be in the rightmost slot.
- `0` may not follow `÷`. This prevents most cases of zero-division, but isn't perfect. `÷-0` will break the game, and `÷0_+` is forbidden despite being valid syntax once the empty slot is filled (for example, with `÷03+`). This is done to simplyify game logic, and might be improved later.
## Building & Running
As always, run this project with `cargo run`. The app takes one argument by default: the name of the blue player. This can be any of the following:
- `human`: Play against a human
- `random`: Play against a random agent (very easy)
- `chase`: Play against a simple extremum-chasing agent (easy)
- `diffuse`: Play against a slightly more intellegent extremum chaser (medium)
- `brutus`: Play against a simple brute-force agent (hard)
For example, `cargo run -- random` will start a game against a random player. Use your arrow keys and space bar to play.
Additional options are available, see `cargo run -- --help`.
Win rates against random are as follows:
- `human`: ~100%
- `random`: ~50%
- `chase`: ~70%
- `diffuse`: ~76%
- `brutus`: ~90%

142
src/agents/brutus.rs Normal file
View File

@ -0,0 +1,142 @@
use std::{cmp::Ordering, iter};
use anyhow::Result;
use itertools::Itertools;
use rand::{seq::SliceRandom, thread_rng};
use rayon::iter::{ParallelBridge, ParallelIterator};
use super::{Agent, Chase, MaximizerAgent, MinimizerAgent};
use crate::{
agents::{util::best_board_noop, Diffuse},
board::{Board, PlayerAction},
util::{Player, Symb},
};
pub struct Brutus {
player: Player,
}
impl Brutus {
pub fn new(player: Player) -> Self {
Self { player }
}
fn step(&mut self, board: &Board, minimize: bool) -> Result<PlayerAction> {
let symbols = [Symb::Minus, Symb::Times, Symb::Plus, Symb::Div]
.into_iter()
.filter(|x| !board.contains(*x))
.collect_vec();
if symbols.is_empty() {
return if minimize {
Chase::new(self.player).step_min(board)
} else {
Chase::new(self.player).step_max(board)
};
}
// Number of free slots
let n_free = board.get_board().iter().filter(|x| x.is_none()).count();
// Number of slots we need to fill with numbers
// Add one if we have two or fewer symbols available, so that we can
// account for one unused symbol while keeping a reasonable runtime
let n_fill = n_free - symbols.len() + if symbols.len() <= 2 { 1 } else { 0 };
let mut items = iter::repeat(None)
.take(n_fill)
.chain(symbols.iter().map(|x| Some(x.clone())))
.permutations(n_free)
.unique()
.par_bridge()
.filter_map(move |x| {
let mut tmp_board = board.clone();
for (i, s) in x.iter().enumerate() {
if let Some(s) = s {
let pos = board.ith_empty_slot(i).unwrap();
if !tmp_board.can_play(&PlayerAction { symb: *s, pos }) {
return None;
}
tmp_board.get_board_mut()[pos] = Some(*s);
}
}
let min = best_board_noop(&tmp_board, true);
let max = best_board_noop(&tmp_board, false);
if min.is_none() || max.is_none() {
return None;
}
let v_min = min.unwrap().evaluate().unwrap();
let v_max = max.unwrap().evaluate().unwrap();
let v = v_min + v_max / 2.0;
Some((tmp_board, v))
})
.collect::<Vec<_>>();
if minimize {
// Sort from smallest to biggest midpoint
items.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal));
} else {
items.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal));
}
// TODO: why can `items` be empty?
// We shouldn't need this escape hatch
if items.is_empty() {
return if minimize {
Diffuse::new(self.player).step_min(board)
} else {
Diffuse::new(self.player).step_max(board)
};
}
let (t, _) = items.first().unwrap();
let mut symbols = symbols.clone();
symbols.shuffle(&mut thread_rng());
// Place a random unused symbol
for target_s in symbols {
for (i, s) in t.get_board().iter().enumerate() {
if let Some(s) = s {
if board.get_board()[i].is_none() && target_s == *s {
return Ok(PlayerAction { pos: i, symb: *s });
}
}
}
}
// Final escape hatch, if we didn't decide to place any symbols
// (which is possible, since we add one to free_spots above!)
if minimize {
Chase::new(self.player).step_min(board)
} else {
Chase::new(self.player).step_max(board)
}
}
}
impl Agent for Brutus {
fn name(&self) -> &'static str {
"Brutus"
}
fn player(&self) -> Player {
self.player
}
}
impl MinimizerAgent for Brutus {
fn step_min(&mut self, board: &Board) -> Result<PlayerAction> {
self.step(board, true)
}
}
impl MaximizerAgent for Brutus {
fn step_max(&mut self, board: &Board) -> Result<PlayerAction> {
self.step(board, false)
}
}

View File

@ -8,11 +8,11 @@ use crate::{
util::{Player, Symb}, util::{Player, Symb},
}; };
pub struct SimpleMinimax { pub struct Chase {
player: Player, player: Player,
} }
impl SimpleMinimax { impl Chase {
pub fn new(player: Player) -> Self { pub fn new(player: Player) -> Self {
Self { player } Self { player }
} }
@ -33,13 +33,13 @@ impl SimpleMinimax {
return Random::new(self.player).step_min(board); return Random::new(self.player).step_min(board);
} }
let t = free_slots_by_influence(&board); let t = free_slots_by_influence(board);
if t.is_none() { if t.is_none() {
bail!("could not compute next move!") bail!("could not compute next move!")
} }
let t = t.unwrap(); let t = t.unwrap();
if t.len() == 0 { if t.is_empty() {
return Random::new(self.player).step_min(board); return Random::new(self.player).step_min(board);
} }
@ -63,13 +63,11 @@ impl SimpleMinimax {
} else { } else {
available_numbers[available_numbers.len() - 1 - offset] available_numbers[available_numbers.len() - 1 - offset]
} }
} else { } else if val <= 0.0 {
if val <= 0.0 {
available_numbers[offset] available_numbers[offset]
} else { } else {
available_numbers[available_numbers.len() - 1 - offset] available_numbers[available_numbers.len() - 1 - offset]
} }
}
}); });
offset += 1; offset += 1;
} }
@ -81,9 +79,9 @@ impl SimpleMinimax {
} }
} }
impl Agent for SimpleMinimax { impl Agent for Chase {
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
"Minimax" "Chase"
} }
fn player(&self) -> Player { fn player(&self) -> Player {
@ -91,13 +89,13 @@ impl Agent for SimpleMinimax {
} }
} }
impl MinimizerAgent for SimpleMinimax { impl MinimizerAgent for Chase {
fn step_min(&mut self, board: &Board) -> Result<PlayerAction> { fn step_min(&mut self, board: &Board) -> Result<PlayerAction> {
self.step(board, true) self.step(board, true)
} }
} }
impl MaximizerAgent for SimpleMinimax { impl MaximizerAgent for Chase {
fn step_max(&mut self, board: &Board) -> Result<PlayerAction> { fn step_max(&mut self, board: &Board) -> Result<PlayerAction> {
self.step(board, false) self.step(board, false)
} }

View File

@ -1,6 +1,7 @@
use anyhow::Result; use anyhow::Result;
use rand::{seq::SliceRandom, thread_rng};
use super::{Agent, MaximizerAgent, MinimizerAgent, Random, SimpleMinimax}; use super::{Agent, Chase, MaximizerAgent, MinimizerAgent, Random};
use crate::{ use crate::{
board::{Board, PlayerAction}, board::{Board, PlayerAction},
util::{Player, Symb}, util::{Player, Symb},
@ -9,6 +10,7 @@ use crate::{
/// A simple "operator diffusion" MINIMIZER agent. /// A simple "operator diffusion" MINIMIZER agent.
/// ///
/// Tries to keep operators as far apart as possible, denying large numbers. /// Tries to keep operators as far apart as possible, denying large numbers.
/// Places numbers using the same algorithm as chase.
pub struct Diffuse { pub struct Diffuse {
player: Player, player: Player,
} }
@ -86,30 +88,30 @@ impl Agent for Diffuse {
impl MinimizerAgent for Diffuse { impl MinimizerAgent for Diffuse {
fn step_min(&mut self, board: &Board) -> Result<PlayerAction> { fn step_min(&mut self, board: &Board) -> Result<PlayerAction> {
let symb = [Symb::Minus, Symb::Times, Symb::Plus, Symb::Div] let mut x = [Symb::Minus, Symb::Times, Symb::Plus, Symb::Div];
.iter() x.shuffle(&mut thread_rng());
.find(|x| !board.contains(**x)); let symb = x.iter().find(|x| !board.contains(**x));
if let Some(symb) = symb { if let Some(symb) = symb {
Ok(self.step_symb(board, *symb)) Ok(self.step_symb(board, *symb))
} else { } else {
// No symbols available, play a random number // No symbols available, play a random number
SimpleMinimax::new(self.player).step_min(board) Chase::new(self.player).step_min(board)
} }
} }
} }
impl MaximizerAgent for Diffuse { impl MaximizerAgent for Diffuse {
fn step_max(&mut self, board: &Board) -> Result<PlayerAction> { fn step_max(&mut self, board: &Board) -> Result<PlayerAction> {
let symb = [Symb::Minus, Symb::Times, Symb::Plus, Symb::Div] let mut x = [Symb::Minus, Symb::Times, Symb::Plus, Symb::Div];
.iter() x.shuffle(&mut thread_rng());
.find(|x| !board.contains(**x)); let symb = x.iter().find(|x| !board.contains(**x));
if let Some(symb) = symb { if let Some(symb) = symb {
Ok(self.step_symb(board, *symb)) Ok(self.step_symb(board, *symb))
} else { } else {
// No symbols available, play a random number // No symbols available, play a random number
SimpleMinimax::new(self.player).step_max(board) Chase::new(self.player).step_max(board)
} }
} }
} }

View File

@ -35,7 +35,7 @@ impl SymbolSelector {
} }
} }
fn up(&mut self, board: &Board) { fn down(&mut self, board: &Board) {
if self.cursor == 0 { if self.cursor == 0 {
self.cursor = self.symbols.len() - 1; self.cursor = self.symbols.len() - 1;
} else { } else {
@ -51,7 +51,7 @@ impl SymbolSelector {
} }
} }
fn down(&mut self, board: &Board) { fn up(&mut self, board: &Board) {
if self.cursor == self.symbols.len() - 1 { if self.cursor == self.symbols.len() - 1 {
self.cursor = 0; self.cursor = 0;
} else { } else {
@ -92,14 +92,18 @@ impl Human {
self.symbol_selector.check(board); self.symbol_selector.check(board);
let board_label = format!(
"{}{:<6}{}",
color::Fg(self.player.color()),
if minimize { "Min" } else { "Max" },
color::Fg(color::Reset)
);
// Ask for input until we get a valid move // Ask for input until we get a valid move
loop { loop {
print!( print!(
"\r{}{}{} {}{}{}{}{}{}", "\r{}{}{}{}{}{}{}",
// Goal board_label,
color::Fg(self.player.color()),
if minimize { "Min" } else { "Max" },
color::Fg(color::Reset),
// Cursor // Cursor
" ".repeat(self.cursor), " ".repeat(self.cursor),
color::Fg(self.player.color()), color::Fg(self.player.color()),
@ -156,7 +160,7 @@ impl Human {
self.symbol_selector.down(board); self.symbol_selector.down(board);
break; break;
} }
Key::Char('\n') => { Key::Char(' ') | Key::Char('\n') => {
let symb = Symb::from_char(self.symbol_selector.current()); let symb = Symb::from_char(self.symbol_selector.current());
if let Some(symb) = symb { if let Some(symb) = symb {
let action = PlayerAction { let action = PlayerAction {
@ -191,7 +195,7 @@ impl Human {
impl Agent for Human { impl Agent for Human {
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
"Player" "Human"
} }
fn player(&self) -> Player { fn player(&self) -> Player {

View File

@ -1,12 +1,14 @@
mod brutus;
mod chase;
mod diffuse; mod diffuse;
mod human; mod human;
mod minimax;
mod random; mod random;
pub mod util; pub mod util;
pub use brutus::Brutus;
pub use chase::Chase;
pub use diffuse::Diffuse; pub use diffuse::Diffuse;
pub use human::Human; pub use human::Human;
pub use minimax::SimpleMinimax;
pub use random::Random; pub use random::Random;
use crate::{ use crate::{

View File

@ -1,10 +1,10 @@
use itertools::Itertools; use itertools::Itertools;
use std::num::NonZeroU8; use std::{mem::swap, num::NonZeroU8};
use crate::{board::Board, util::Symb}; use crate::{board::Board, util::Symb};
/// Returns an iterator of (sort, coords, char_idx, f32) for each empty slot in the listed partials. /// Returns an iterator of (idx, coords, char_idx, f32) for each empty slot in the listed partials.
/// - sort is the index of this slot. /// - idx is the index of this slot in the board string.
/// - coords are the coordinate of this slot's partial /// - coords are the coordinate of this slot's partial
/// - char_idx is the index of this slot in its partial /// - char_idx is the index of this slot in its partial
/// - f32 is the influence of this slot /// - f32 is the influence of this slot
@ -51,16 +51,12 @@ pub fn free_slots_by_influence(board: &Board) -> Option<Vec<(usize, f32)>> {
Some(slots) Some(slots)
} }
/// Find the maximum possible value of the given board /// Find the minimum or maximum possible value of the given board,
#[allow(dead_code)] /// without adding any operations. Returns None if we couldn't find
pub fn maximize_value(board: &Board) -> Option<Board> { /// a best board.
pub fn best_board_noop(board: &Board, minimize: bool) -> Option<Board> {
let n_free = board.get_board().iter().filter(|x| x.is_none()).count(); let n_free = board.get_board().iter().filter(|x| x.is_none()).count();
// Assume we have 10 or fewer available slots
if n_free >= 10 {
panic!()
}
let available_numbers = (0..=9) let available_numbers = (0..=9)
.map(|x| match x { .map(|x| match x {
0 => Symb::Zero, 0 => Symb::Zero,
@ -69,19 +65,36 @@ pub fn maximize_value(board: &Board) -> Option<Board> {
.filter(|x| !board.contains(*x)) .filter(|x| !board.contains(*x))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let slots = free_slots_by_influence(&board)?; if n_free > available_numbers.len() {
return None;
}
let slots = free_slots_by_influence(board)?;
let all_symbols = { let all_symbols = {
// We need this many from the bottom, and this many from the top. let mut a = {
let neg_count = slots.iter().filter(|(_, x)| *x <= 0.0).count(); // Number of slots we want to minimize
let pos_count = slots.iter().filter(|(_, x)| *x > 0.0).count(); let mut neg_count = slots.iter().filter(|(_, x)| *x <= 0.0).count();
// Number of slots we want to maximize
let mut pos_count = slots.iter().filter(|(_, x)| *x > 0.0).count();
let mut a_iter = available_numbers if minimize {
swap(&mut neg_count, &mut pos_count);
}
available_numbers
.iter() .iter()
.take(neg_count) .take(neg_count)
.chain(available_numbers.iter().rev().take(pos_count).rev()); .chain(available_numbers.iter().rev().take(pos_count).rev())
.collect_vec()
};
let mut g = slots if !minimize {
a.reverse();
}
let mut a_iter = a.into_iter();
slots
// Group slots with equal weights // Group slots with equal weights
// and count the number of elements in each group // and count the number of elements in each group
.iter() .iter()
@ -92,7 +105,7 @@ pub fn maximize_value(board: &Board) -> Option<Board> {
// equal-weight slots // equal-weight slots
.map(|s| { .map(|s| {
(0..s) (0..s)
.map(|_| a_iter.next().unwrap().clone()) .map(|_| *a_iter.next().unwrap())
.permutations(s) .permutations(s)
.unique() .unique()
.collect_vec() .collect_vec()
@ -101,37 +114,32 @@ pub fn maximize_value(board: &Board) -> Option<Board> {
// of this set of sets // of this set of sets
.multi_cartesian_product() .multi_cartesian_product()
.map(|x| x.iter().flatten().cloned().collect_vec()) .map(|x| x.iter().flatten().cloned().collect_vec())
.map(|v| slots.iter().zip(v).collect_vec()) // Finally, attach the coordinate of each slot to each symbol
.collect_vec(); .map(|v| slots.iter().map(|x| x.0).zip(v).collect_vec())
.collect_vec()
// Sort these vectors so the order of values
// matches the order of empty slots
g.iter_mut()
.for_each(|v| v.sort_by(|(a, _), (b, _)| b.0.partial_cmp(&a.0).unwrap()));
g.into_iter()
.map(|v| v.into_iter().map(|(_, s)| s).collect_vec())
}; };
let mut best_board = None; let mut best_board = None;
let mut best_value = None; let mut best_value = None;
for i in all_symbols { for i_iter in all_symbols {
let mut i_iter = i.iter(); let mut tmp_board = board.clone();
let filled = Board::from_board(board.get_board().map(|x| match x { for (i, s) in i_iter {
None => i_iter.next().cloned(), tmp_board.get_board_mut()[i] = Some(s);
_ => x, }
}));
let val = filled.evaluate(); let val = tmp_board.evaluate();
if let Some(val) = val { if let Some(val) = val {
if let Some(best) = best_value { if minimize {
if val > best { if best_value.is_none() || val < best_value.unwrap() {
best_value = Some(val); best_value = Some(val);
best_board = Some(filled) best_board = Some(tmp_board)
} }
} else { } else {
if best_value.is_none() || val > best_value.unwrap() {
best_value = Some(val); best_value = Some(val);
best_board = Some(filled) best_board = Some(tmp_board)
}
} }
} }
} }

View File

@ -1,6 +1,6 @@
use anyhow::Result; use anyhow::Result;
use itertools::Itertools; use itertools::Itertools;
use std::fmt::Write; use std::fmt::{Debug, Display, Write};
use termion::color::{self, Color}; use termion::color::{self, Color};
use super::{PlayerAction, TreeElement}; use super::{PlayerAction, TreeElement};
@ -65,14 +65,18 @@ pub struct Board {
last_placed: Option<usize>, last_placed: Option<usize>,
} }
impl ToString for Board { impl Display for Board {
fn to_string(&self) -> String { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut s = String::new(); for c in self.board {
s.extend( write!(f, "{}", c.map(|s| s.get_char().unwrap()).unwrap_or('_'))?
self.board }
.map(|x| x.map(|s| s.to_char().unwrap()).unwrap_or('_')), Ok(())
); }
s }
impl Debug for Board {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
Display::fmt(&self, f)
} }
} }
@ -95,6 +99,24 @@ impl Board {
&mut self.board &mut self.board
} }
/// Get the index of the ith empty slot
pub fn ith_empty_slot(&self, mut idx: usize) -> Option<usize> {
for (i, c) in self.board.iter().enumerate() {
if c.is_none() {
if idx == 0 {
return Some(i);
}
idx -= 1;
}
}
if idx == 0 {
Some(self.board.len() - 1)
} else {
None
}
}
pub fn is_done(&self) -> bool { pub fn is_done(&self) -> bool {
self.free_spots == 0 self.free_spots == 0
} }
@ -369,7 +391,7 @@ impl Board {
if c == '_' { if c == '_' {
Some(None) Some(None)
} else { } else {
Symb::from_char(c).map(|s| Some(s)) Symb::from_char(c).map(Some)
} }
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();

View File

@ -1,59 +1,79 @@
use std::fmt::Display;
use clap::{Parser, ValueEnum}; use clap::{Parser, ValueEnum};
use crate::{ use crate::{
agents::{Diffuse, Human, MaximizerAgent, MinimizerAgent, Random, SimpleMinimax}, agents::{Brutus, Chase, Diffuse, Human, MaximizerAgent, MinimizerAgent, Random},
util::Player, util::Player,
}; };
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(version, about)] #[command(about)]
pub struct Cli { pub struct Cli {
pub red: AgentSelector, /// The agent that controls the Blue (opponent) player
pub blue: AgentSelector, pub blue: AgentSelector,
/// The agent that controls the Red (home) player
#[arg(long, default_value = "human")]
pub red: AgentSelector,
/// If this is greater than one, repeat the game this many times and print a summary.
#[arg(long, short, default_value = "0")] #[arg(long, short, default_value = "0")]
pub repeat: usize, pub repeat: usize,
#[arg(long, short, default_value = "false")]
pub silent: bool,
} }
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
pub enum AgentSelector { pub enum AgentSelector {
Random, /// A human agent. Asks for input.
Diffuse,
Minimax,
Human, Human,
/// A random agent (very easy)
Random,
/// A simple extremum-chaser (easy)
Chase,
/// A smarter extremum-chaser (medium)
Diffuse,
/// A very smart brute-force agent (hard)
Brutus,
} }
impl AgentSelector { impl AgentSelector {
pub fn to_maxi(&self, player: Player) -> Box<dyn MaximizerAgent> { pub fn get_maximizer(&self, player: Player) -> Box<dyn MaximizerAgent> {
match self { match self {
Self::Random => Box::new(Random::new(player)), Self::Random => Box::new(Random::new(player)),
Self::Minimax => Box::new(SimpleMinimax::new(player)), Self::Chase => Box::new(Chase::new(player)),
Self::Diffuse => Box::new(Diffuse::new(player)), Self::Diffuse => Box::new(Diffuse::new(player)),
Self::Brutus => Box::new(Brutus::new(player)),
Self::Human => Box::new(Human::new(player)), Self::Human => Box::new(Human::new(player)),
} }
} }
pub fn to_mini(&self, player: Player) -> Box<dyn MinimizerAgent> { pub fn get_minimizer(&self, player: Player) -> Box<dyn MinimizerAgent> {
match self { match self {
Self::Random => Box::new(Random::new(player)), Self::Random => Box::new(Random::new(player)),
Self::Minimax => Box::new(SimpleMinimax::new(player)), Self::Chase => Box::new(Chase::new(player)),
Self::Diffuse => Box::new(Diffuse::new(player)), Self::Diffuse => Box::new(Diffuse::new(player)),
Self::Brutus => Box::new(Brutus::new(player)),
Self::Human => Box::new(Human::new(player)), Self::Human => Box::new(Human::new(player)),
} }
} }
} }
impl ToString for AgentSelector { impl Display for AgentSelector {
fn to_string(&self) -> String { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self { match self {
Self::Random => "random", Self::Random => "random",
Self::Diffuse => "diffuse", Self::Diffuse => "diffuse",
Self::Minimax => "minimax", Self::Brutus => "brutus",
Self::Chase => "chase",
Self::Human => "human", Self::Human => "human",
} }
.to_string() )
} }
} }

View File

@ -1,7 +1,11 @@
use std::cmp::Ordering; use std::{
cmp::Ordering,
io::{stdout, Write},
};
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use clap::Parser; use clap::Parser;
use rayon::iter::{IntoParallelIterator, ParallelIterator};
use termion::color::{self}; use termion::color::{self};
mod agents; mod agents;
@ -59,17 +63,27 @@ fn play(
let mut is_first_turn = true; let mut is_first_turn = true;
let mut is_maxi_turn = true; let mut is_maxi_turn = true;
let board_label = format!(
"{}{:<6}{}",
color::Fg(color::LightBlack),
maxi.player().to_string(),
color::Fg(color::Reset)
);
while !board.is_done() { while !board.is_done() {
// Print board // Print board
println!( println!(
"\r{}{}{}{}", "\r{}{}{}{}",
" ".repeat(6), board_label,
if is_first_turn { '╓' } else { '║' }, if is_first_turn { '╓' } else { '║' },
board.prettyprint()?, board.prettyprint()?,
if is_first_turn { '╖' } else { '║' }, if is_first_turn { '╖' } else { '║' },
); );
is_first_turn = false; is_first_turn = false;
print!("Thinking...");
stdout().flush()?;
// Take action // Take action
let action = if is_maxi_turn { let action = if is_maxi_turn {
maxi.step_max(&board)? maxi.step_max(&board)?
@ -99,76 +113,73 @@ fn play(
is_maxi_turn = !is_maxi_turn; is_maxi_turn = !is_maxi_turn;
} }
println!("\r{}{}", " ".repeat(6), board.prettyprint()?); println!("\r{}{}", board_label, board.prettyprint()?);
println!("\r{}{}", " ".repeat(6), " ".repeat(board.size())); println!("\r{}{}", board_label, " ".repeat(board.size()));
Ok(board) Ok(board)
} }
fn main() -> Result<()> { fn main() -> Result<()> {
let cli = cli::Cli::parse(); let cli = cli::Cli::parse();
if cli.repeat > 1 { rayon::ThreadPoolBuilder::new()
let mut red_wins = 0f32; .num_threads(8)
let mut blue_wins = 0f32; .build_global()
.unwrap();
for _ in 0..cli.repeat { if cli.repeat > 1 {
let mut maxi = cli.red.to_maxi(Player::Red); let x = (0..cli.repeat)
let mut mini = cli.blue.to_mini(Player::Blue); .into_par_iter()
let red_board = match if cli.silent { .filter_map(|_| {
play_silent(&mut *maxi, &mut *mini) let mut maxi = cli.red.get_maximizer(Player::Red);
} else { let mut mini = cli.blue.get_minimizer(Player::Blue);
play(&mut *maxi, &mut *mini) let red_board = match play_silent(&mut *maxi, &mut *mini) {
} {
Ok(x) => x, Ok(x) => x,
Err(e) => { Err(e) => {
println!("Error: {e}"); println!("Error: {e}");
continue; return None;
} }
}; };
let mut maxi = cli.blue.to_maxi(Player::Blue); let mut maxi = cli.blue.get_maximizer(Player::Blue);
let mut mini = cli.red.to_mini(Player::Red); let mut mini = cli.red.get_minimizer(Player::Red);
let blue_board = match if cli.silent { let blue_board = match play_silent(&mut *maxi, &mut *mini) {
play_silent(&mut *maxi, &mut *mini)
} else {
play(&mut *maxi, &mut *mini)
} {
Ok(x) => x, Ok(x) => x,
Err(e) => { Err(e) => {
println!("Error: {e}"); println!("Error: {e}");
continue; return None;
} }
}; };
match red_board.evaluate().partial_cmp(&blue_board.evaluate()) { match red_board.evaluate().partial_cmp(&blue_board.evaluate()) {
Some(Ordering::Equal) => {} Some(Ordering::Equal) => None,
Some(Ordering::Greater) => red_wins += 1.0, Some(Ordering::Greater) => Some(Player::Red),
Some(Ordering::Less) => blue_wins += 1.0, Some(Ordering::Less) => Some(Player::Blue),
None => { None => {
println!("Error"); println!("Error");
None
} }
} }
} })
.collect::<Vec<_>>();
let red_wins = x.iter().filter(|x| **x == Player::Red).count();
let blue_wins = x.iter().filter(|x| **x == Player::Blue).count();
println!("Played {} rounds\n", cli.repeat); println!("Played {} rounds\n", cli.repeat);
println!( println!(
"Red win rate: {:.2} ({})", "Red win rate: {:.2} ({})",
red_wins / cli.repeat as f32, red_wins as f32 / cli.repeat as f32,
cli.red.to_string(), cli.red,
); );
println!( println!(
"Blue win rate: {:.2} ({}) ", "Blue win rate: {:.2} ({}) ",
blue_wins / cli.repeat as f32, blue_wins as f32 / cli.repeat as f32,
cli.blue.to_string(), cli.blue,
); );
} else { } else {
let mut maxi = cli.red.to_maxi(Player::Red); let mut maxi = cli.red.get_maximizer(Player::Red);
let mut mini = cli.blue.to_mini(Player::Blue); let mut mini = cli.blue.get_minimizer(Player::Blue);
let red_board = if cli.silent { let red_board = play(&mut *maxi, &mut *mini)?;
play_silent(&mut *maxi, &mut *mini)
} else {
play(&mut *maxi, &mut *mini)
}?;
if red_board.is_done() { if red_board.is_done() {
println!( println!(
@ -187,13 +198,9 @@ fn main() -> Result<()> {
return Ok(()); return Ok(());
} }
let mut maxi = cli.blue.to_maxi(Player::Blue); let mut maxi = cli.blue.get_maximizer(Player::Blue);
let mut mini = cli.red.to_mini(Player::Red); let mut mini = cli.red.get_minimizer(Player::Red);
let blue_board = if cli.silent { let blue_board = play(&mut *maxi, &mut *mini)?;
play_silent(&mut *maxi, &mut *mini)
} else {
play(&mut *maxi, &mut *mini)
}?;
if blue_board.is_done() { if blue_board.is_done() {
println!( println!(
@ -220,7 +227,7 @@ fn main() -> Result<()> {
println!( println!(
"\r\n{}Red ({}){} wins!", "\r\n{}Red ({}){} wins!",
color::Fg(Player::Red.color()), color::Fg(Player::Red.color()),
cli.red.to_string(), cli.red,
color::Fg(color::Reset), color::Fg(color::Reset),
); );
} }
@ -228,7 +235,7 @@ fn main() -> Result<()> {
println!( println!(
"\r\n{}Blue ({}){} wins!", "\r\n{}Blue ({}){} wins!",
color::Fg(Player::Blue.color()), color::Fg(Player::Blue.color()),
cli.blue.to_string(), cli.blue,
color::Fg(color::Reset), color::Fg(color::Reset),
); );
} }

View File

@ -69,7 +69,7 @@ impl Symb {
self == &Self::Minus self == &Self::Minus
} }
pub const fn to_char(&self) -> Option<char> { pub const fn get_char(&self) -> Option<char> {
match self { match self {
Self::Plus => Some('+'), Self::Plus => Some('+'),
Self::Minus => Some('-'), Self::Minus => Some('-'),