use std::collections::HashMap;
use std::fmt::Debug;
use turnbase::{Error, Game, PlayerId, Prng, sample_chance};
use turnbase_bots::Bot;
pub enum PlayerAgent<G: Game> {
Human,
Ai(Box<dyn Bot<G>>),
}
pub struct Simulator<G: Game> {
game: G,
state: G::State,
agents: HashMap<PlayerId, PlayerAgent<G>>,
log_history: Vec<String>,
chance: Prng,
}
const CHANCE_SEED_OFFSET: u64 = 0x00C0_FFEE;
impl<G: Game> Simulator<G> {
#[must_use]
pub fn new(game: G, seed: u64, agents: HashMap<PlayerId, PlayerAgent<G>>) -> Self {
let state = game.new_initial_state(seed);
Self {
game,
state,
agents,
log_history: Vec::new(),
chance: Prng::new(seed ^ CHANCE_SEED_OFFSET),
}
}
#[must_use]
pub const fn game(&self) -> &G {
&self.game
}
#[must_use]
pub const fn state(&self) -> &G::State {
&self.state
}
#[must_use]
pub fn log_history(&self) -> &[String] {
&self.log_history
}
#[must_use]
pub fn awaiting_human(&self) -> Option<PlayerId> {
self.game.active_players(&self.state).iter().find(|player| {
!player.is_chance()
&& matches!(self.agents.get(player), Some(PlayerAgent::Human) | None)
})
}
#[must_use]
pub fn is_terminal(&self) -> bool {
self.game.is_terminal(&self.state)
}
#[must_use]
pub fn primary_human(&self) -> Option<PlayerId> {
self.agents
.iter()
.filter_map(|(&player, agent)| matches!(agent, PlayerAgent::Human).then_some(player))
.min()
}
}
impl<G: Game> Simulator<G>
where
G::Action: Debug,
{
pub fn step(&mut self) -> Result<bool, Error> {
if self.game.is_terminal(&self.state) {
return Ok(false);
}
let Some(player) = self.game.active_players(&self.state).iter().next() else {
return Ok(false);
};
if player.is_chance() {
let Some(action) = sample_chance(&self.game, &self.state, &mut self.chance) else {
return Ok(false);
};
log::debug!("{player} revealed: {action:?}");
self.log_history.push(format!("{player} moved"));
self.game.apply(&mut self.state, player, action);
return Ok(true);
}
let Some(PlayerAgent::Ai(bot)) = self.agents.get_mut(&player) else {
return Ok(false);
};
let Some(action) = bot.choose(&self.game, &self.state, player) else {
return Ok(false);
};
if !self.game.is_legal(&self.state, player, &action) {
return Err(Error::IllegalAction { player });
}
log::debug!("{player} chose: {action:?}");
self.log_history.push(format!("{player} chose: {action:?}"));
self.game.apply(&mut self.state, player, action);
Ok(true)
}
pub fn select_human_action(
&mut self,
player: PlayerId,
action: G::Action,
) -> Result<(), Error> {
if !self.game.active_players(&self.state).contains(player) {
return Err(Error::NotActive { player });
}
if !self.game.is_legal(&self.state, player, &action) {
return Err(Error::IllegalAction { player });
}
log::debug!("{player} chose: {action:?}");
self.log_history.push(format!("{player} chose: {action:?}"));
self.game.apply(&mut self.state, player, action);
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use turnbase::{ActivePlayers, Game, PlayerId};
use turnbase_bots::Random;
use super::{PlayerAgent, Simulator};
const P0: PlayerId = PlayerId::new(0);
const P1: PlayerId = PlayerId::new(1);
struct CountToThree;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
struct Bump;
impl Game for CountToThree {
type State = u32;
type Action = Bump;
type View = u32;
fn new_initial_state(&self, _seed: u64) -> Self::State {
0
}
fn num_players(&self) -> usize {
2
}
fn active_players(&self, state: &Self::State) -> ActivePlayers {
if self.is_terminal(state) {
ActivePlayers::none()
} else {
ActivePlayers::one(PlayerId::new(state % 2))
}
}
fn legal_actions(&self, state: &Self::State, _player: PlayerId) -> Vec<Self::Action> {
if self.is_terminal(state) {
Vec::new()
} else {
vec![Bump]
}
}
fn apply(&self, state: &mut Self::State, _player: PlayerId, _action: Self::Action) {
*state += 1;
}
fn is_terminal(&self, state: &Self::State) -> bool {
*state >= 3
}
fn reward(&self, state: &Self::State, player: PlayerId) -> f64 {
let winner = (state + 1) % 2;
if player.index() == winner { 1.0 } else { -1.0 }
}
fn view(&self, state: &Self::State, _viewer: Option<PlayerId>) -> Self::View {
*state
}
}
fn ai_agents() -> HashMap<PlayerId, PlayerAgent<CountToThree>> {
let mut agents = HashMap::new();
agents.insert(P0, PlayerAgent::Ai(Box::new(Random::new(1))));
agents.insert(P1, PlayerAgent::Ai(Box::new(Random::new(2))));
agents
}
#[test]
fn steps_all_ai_seats_to_a_terminal_state() {
let mut sim = Simulator::new(CountToThree, 0, ai_agents());
let mut steps = 0;
while !sim.is_terminal() && sim.step().unwrap() {
steps += 1;
}
assert_eq!(steps, 3, "three bumps reach the target from zero");
assert!(sim.is_terminal());
assert_eq!(sim.log_history().len(), 3);
}
#[test]
fn step_blocks_on_a_human_seat_until_driven() {
let mut agents: HashMap<PlayerId, PlayerAgent<CountToThree>> = HashMap::new();
agents.insert(P0, PlayerAgent::Human);
agents.insert(P1, PlayerAgent::Ai(Box::new(Random::new(7))));
let mut sim = Simulator::new(CountToThree, 0, agents);
assert_eq!(sim.awaiting_human(), Some(P0));
assert_eq!(sim.step(), Ok(false), "step refuses to act for a human");
assert!(sim.log_history().is_empty());
sim.select_human_action(P0, Bump).unwrap();
assert_eq!(sim.awaiting_human(), None);
assert!(sim.step().unwrap(), "the AI seat advances once unblocked");
assert_eq!(sim.log_history().len(), 2);
}
#[test]
fn primary_human_picks_the_lowest_seat() {
let mut agents: HashMap<PlayerId, PlayerAgent<CountToThree>> = HashMap::new();
agents.insert(P0, PlayerAgent::Ai(Box::new(Random::new(1))));
agents.insert(P1, PlayerAgent::Human);
let sim = Simulator::new(CountToThree, 0, agents);
assert_eq!(sim.primary_human(), Some(P1));
}
struct RevealOnce;
impl Game for RevealOnce {
type State = Option<u8>;
type Action = u8;
type View = Option<u8>;
fn new_initial_state(&self, _seed: u64) -> Self::State {
None
}
fn num_players(&self) -> usize {
0
}
fn active_players(&self, state: &Self::State) -> ActivePlayers {
if state.is_some() {
ActivePlayers::none()
} else {
ActivePlayers::one(PlayerId::CHANCE)
}
}
fn legal_actions(&self, state: &Self::State, player: PlayerId) -> Vec<Self::Action> {
if player.is_chance() && state.is_none() {
vec![0, 1, 2]
} else {
Vec::new()
}
}
fn apply(&self, state: &mut Self::State, _player: PlayerId, action: Self::Action) {
*state = Some(action);
}
fn is_terminal(&self, state: &Self::State) -> bool {
state.is_some()
}
fn reward(&self, _state: &Self::State, _player: PlayerId) -> f64 {
0.0
}
fn view(&self, state: &Self::State, _viewer: Option<PlayerId>) -> Self::View {
*state
}
}
#[test]
fn step_auto_resolves_a_chance_node() {
let mut sim = Simulator::new(RevealOnce, 7, HashMap::new());
assert_eq!(sim.awaiting_human(), None, "a chance seat is not a human");
assert!(sim.step().unwrap(), "the chance node advanced");
assert!(sim.is_terminal());
assert!(matches!(sim.state(), Some(0..=2)));
assert_eq!(sim.log_history().len(), 1);
}
#[test]
fn chance_outcomes_are_not_leaked_into_the_log() {
for seed in 0..32u64 {
let mut sim = Simulator::new(RevealOnce, seed, HashMap::new());
assert!(sim.step().unwrap());
let revealed = sim.state().unwrap();
let entry = &sim.log_history()[0];
assert!(
!entry.contains(&revealed.to_string()),
"log entry {entry:?} leaked the chance outcome {revealed}"
);
}
}
}