1use std::collections::HashMap;
4use std::fmt::Debug;
5
6use turnbase::{Error, Game, PlayerId, Prng, sample_chance};
7use turnbase_bots::Bot;
8
9pub enum PlayerAgent<G: Game> {
17 Human,
19 Ai(Box<dyn Bot<G>>),
21}
22
23pub struct Simulator<G: Game> {
37 game: G,
38 state: G::State,
39 agents: HashMap<PlayerId, PlayerAgent<G>>,
40 log_history: Vec<String>,
41 chance: Prng,
42}
43
44const CHANCE_SEED_OFFSET: u64 = 0x00C0_FFEE;
48
49impl<G: Game> Simulator<G> {
50 #[must_use]
53 pub fn new(game: G, seed: u64, agents: HashMap<PlayerId, PlayerAgent<G>>) -> Self {
54 let state = game.new_initial_state(seed);
55 Self {
56 game,
57 state,
58 agents,
59 log_history: Vec::new(),
60 chance: Prng::new(seed ^ CHANCE_SEED_OFFSET),
61 }
62 }
63
64 #[must_use]
66 pub const fn game(&self) -> &G {
67 &self.game
68 }
69
70 #[must_use]
72 pub const fn state(&self) -> &G::State {
73 &self.state
74 }
75
76 #[must_use]
79 pub fn log_history(&self) -> &[String] {
80 &self.log_history
81 }
82
83 #[must_use]
90 pub fn awaiting_human(&self) -> Option<PlayerId> {
91 self.game.active_players(&self.state).iter().find(|player| {
92 !player.is_chance()
95 && matches!(self.agents.get(player), Some(PlayerAgent::Human) | None)
96 })
97 }
98
99 #[must_use]
101 pub fn is_terminal(&self) -> bool {
102 self.game.is_terminal(&self.state)
103 }
104
105 #[must_use]
116 pub fn primary_human(&self) -> Option<PlayerId> {
117 self.agents
118 .iter()
119 .filter_map(|(&player, agent)| matches!(agent, PlayerAgent::Human).then_some(player))
120 .min()
121 }
122}
123
124impl<G: Game> Simulator<G>
127where
128 G::Action: Debug,
129{
130 pub fn step(&mut self) -> Result<bool, Error> {
145 if self.game.is_terminal(&self.state) {
146 return Ok(false);
147 }
148 let Some(player) = self.game.active_players(&self.state).iter().next() else {
149 return Ok(false);
150 };
151 if player.is_chance() {
152 let Some(action) = sample_chance(&self.game, &self.state, &mut self.chance) else {
153 return Ok(false);
154 };
155 log::debug!("{player} revealed: {action:?}");
163 self.log_history.push(format!("{player} moved"));
164 self.game.apply(&mut self.state, player, action);
165 return Ok(true);
166 }
167 let Some(PlayerAgent::Ai(bot)) = self.agents.get_mut(&player) else {
168 return Ok(false);
169 };
170 let Some(action) = bot.choose(&self.game, &self.state, player) else {
171 return Ok(false);
172 };
173 if !self.game.is_legal(&self.state, player, &action) {
174 return Err(Error::IllegalAction { player });
175 }
176 log::debug!("{player} chose: {action:?}");
177 self.log_history.push(format!("{player} chose: {action:?}"));
178 self.game.apply(&mut self.state, player, action);
179 Ok(true)
180 }
181
182 pub fn select_human_action(
192 &mut self,
193 player: PlayerId,
194 action: G::Action,
195 ) -> Result<(), Error> {
196 if !self.game.active_players(&self.state).contains(player) {
197 return Err(Error::NotActive { player });
198 }
199 if !self.game.is_legal(&self.state, player, &action) {
200 return Err(Error::IllegalAction { player });
201 }
202 log::debug!("{player} chose: {action:?}");
203 self.log_history.push(format!("{player} chose: {action:?}"));
204 self.game.apply(&mut self.state, player, action);
205 Ok(())
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use std::collections::HashMap;
212
213 use turnbase::{ActivePlayers, Game, PlayerId};
214 use turnbase_bots::Random;
215
216 use super::{PlayerAgent, Simulator};
217
218 const P0: PlayerId = PlayerId::new(0);
219 const P1: PlayerId = PlayerId::new(1);
220
221 struct CountToThree;
224
225 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
226 struct Bump;
227
228 impl Game for CountToThree {
229 type State = u32;
230 type Action = Bump;
231 type View = u32;
232
233 fn new_initial_state(&self, _seed: u64) -> Self::State {
234 0
235 }
236 fn num_players(&self) -> usize {
237 2
238 }
239 fn active_players(&self, state: &Self::State) -> ActivePlayers {
240 if self.is_terminal(state) {
241 ActivePlayers::none()
242 } else {
243 ActivePlayers::one(PlayerId::new(state % 2))
244 }
245 }
246 fn legal_actions(&self, state: &Self::State, _player: PlayerId) -> Vec<Self::Action> {
247 if self.is_terminal(state) {
248 Vec::new()
249 } else {
250 vec![Bump]
251 }
252 }
253 fn apply(&self, state: &mut Self::State, _player: PlayerId, _action: Self::Action) {
254 *state += 1;
255 }
256 fn is_terminal(&self, state: &Self::State) -> bool {
257 *state >= 3
258 }
259 fn reward(&self, state: &Self::State, player: PlayerId) -> f64 {
260 let winner = (state + 1) % 2;
261 if player.index() == winner { 1.0 } else { -1.0 }
262 }
263 fn view(&self, state: &Self::State, _viewer: Option<PlayerId>) -> Self::View {
264 *state
265 }
266 }
267
268 fn ai_agents() -> HashMap<PlayerId, PlayerAgent<CountToThree>> {
269 let mut agents = HashMap::new();
270 agents.insert(P0, PlayerAgent::Ai(Box::new(Random::new(1))));
271 agents.insert(P1, PlayerAgent::Ai(Box::new(Random::new(2))));
272 agents
273 }
274
275 #[test]
276 fn steps_all_ai_seats_to_a_terminal_state() {
277 let mut sim = Simulator::new(CountToThree, 0, ai_agents());
278 let mut steps = 0;
279 while !sim.is_terminal() && sim.step().unwrap() {
280 steps += 1;
281 }
282 assert_eq!(steps, 3, "three bumps reach the target from zero");
283 assert!(sim.is_terminal());
284 assert_eq!(sim.log_history().len(), 3);
285 }
286
287 #[test]
288 fn step_blocks_on_a_human_seat_until_driven() {
289 let mut agents: HashMap<PlayerId, PlayerAgent<CountToThree>> = HashMap::new();
290 agents.insert(P0, PlayerAgent::Human);
291 agents.insert(P1, PlayerAgent::Ai(Box::new(Random::new(7))));
292 let mut sim = Simulator::new(CountToThree, 0, agents);
293
294 assert_eq!(sim.awaiting_human(), Some(P0));
295 assert_eq!(sim.step(), Ok(false), "step refuses to act for a human");
296 assert!(sim.log_history().is_empty());
297
298 sim.select_human_action(P0, Bump).unwrap();
299 assert_eq!(sim.awaiting_human(), None);
300 assert!(sim.step().unwrap(), "the AI seat advances once unblocked");
301 assert_eq!(sim.log_history().len(), 2);
302 }
303
304 #[test]
305 fn primary_human_picks_the_lowest_seat() {
306 let mut agents: HashMap<PlayerId, PlayerAgent<CountToThree>> = HashMap::new();
307 agents.insert(P0, PlayerAgent::Ai(Box::new(Random::new(1))));
308 agents.insert(P1, PlayerAgent::Human);
309 let sim = Simulator::new(CountToThree, 0, agents);
310 assert_eq!(sim.primary_human(), Some(P1));
311 }
312
313 struct RevealOnce;
316
317 impl Game for RevealOnce {
318 type State = Option<u8>;
319 type Action = u8;
320 type View = Option<u8>;
321
322 fn new_initial_state(&self, _seed: u64) -> Self::State {
323 None
324 }
325 fn num_players(&self) -> usize {
326 0
327 }
328 fn active_players(&self, state: &Self::State) -> ActivePlayers {
329 if state.is_some() {
330 ActivePlayers::none()
331 } else {
332 ActivePlayers::one(PlayerId::CHANCE)
333 }
334 }
335 fn legal_actions(&self, state: &Self::State, player: PlayerId) -> Vec<Self::Action> {
336 if player.is_chance() && state.is_none() {
337 vec![0, 1, 2]
338 } else {
339 Vec::new()
340 }
341 }
342 fn apply(&self, state: &mut Self::State, _player: PlayerId, action: Self::Action) {
343 *state = Some(action);
344 }
345 fn is_terminal(&self, state: &Self::State) -> bool {
346 state.is_some()
347 }
348 fn reward(&self, _state: &Self::State, _player: PlayerId) -> f64 {
349 0.0
350 }
351 fn view(&self, state: &Self::State, _viewer: Option<PlayerId>) -> Self::View {
352 *state
353 }
354 }
355
356 #[test]
357 fn step_auto_resolves_a_chance_node() {
358 let mut sim = Simulator::new(RevealOnce, 7, HashMap::new());
361 assert_eq!(sim.awaiting_human(), None, "a chance seat is not a human");
362 assert!(sim.step().unwrap(), "the chance node advanced");
363 assert!(sim.is_terminal());
364 assert!(matches!(sim.state(), Some(0..=2)));
365 assert_eq!(sim.log_history().len(), 1);
366 }
367
368 #[test]
369 fn chance_outcomes_are_not_leaked_into_the_log() {
370 for seed in 0..32u64 {
373 let mut sim = Simulator::new(RevealOnce, seed, HashMap::new());
374 assert!(sim.step().unwrap());
375 let revealed = sim.state().unwrap();
376 let entry = &sim.log_history()[0];
377 assert!(
378 !entry.contains(&revealed.to_string()),
379 "log entry {entry:?} leaked the chance outcome {revealed}"
380 );
381 }
382 }
383}