Skip to main content

turnbase_match/
simulator.rs

1//! In-memory turn loop over a [`turnbase::Game`]: seat agents plus a stepper.
2
3use std::collections::HashMap;
4use std::fmt::Debug;
5
6use turnbase::{Error, Game, PlayerId, Prng, sample_chance};
7use turnbase_bots::Bot;
8
9/// Who is deciding a seat's moves.
10///
11/// A seat with no entry in [`Simulator`]'s agent map behaves like [`Human`]:
12/// it blocks [`Simulator::step`] rather than panicking or being skipped, so a
13/// game can add players without wiring every seat up front.
14///
15/// [`Human`]: PlayerAgent::Human
16pub enum PlayerAgent<G: Game> {
17    /// A seat driven by external input via [`Simulator::select_human_action`].
18    Human,
19    /// A seat driven by a [`Bot`], asked for a move every time it is active.
20    Ai(Box<dyn Bot<G>>),
21}
22
23/// Coordinates one match: the rules (`G`), its current position, which agent
24/// controls each seat, and a running log of committed actions.
25///
26/// Pure in-memory bookkeeping: [`Simulator::step`] and
27/// [`Simulator::select_human_action`] are the only ways the position changes,
28/// and both just forward to [`Game::apply`]. There is no terminal, socket, or
29/// timer here, so a simulator runs identically under `cargo test` and behind
30/// a rendering client.
31///
32/// A chance node ([`PlayerId::CHANCE`] active) is not a seat any agent
33/// controls; [`Simulator::step`] resolves it automatically by sampling from
34/// `chance`, a generator seeded from the match seed but offset so it does not
35/// share a stream with the state's own generator.
36pub 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
44/// Offset mixed into the match seed for the chance sampler, so committed
45/// chance outcomes do not correlate with a game's own in-state generator
46/// (which `new_initial_state` seeds directly from the match seed).
47const CHANCE_SEED_OFFSET: u64 = 0x00C0_FFEE;
48
49impl<G: Game> Simulator<G> {
50    /// Starts a match: `game.new_initial_state(seed)` seeded with `seed`, with
51    /// `agents` controlling each seat.
52    #[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    /// Returns the rules governing this match.
65    #[must_use]
66    pub const fn game(&self) -> &G {
67        &self.game
68    }
69
70    /// Returns the current position.
71    #[must_use]
72    pub const fn state(&self) -> &G::State {
73        &self.state
74    }
75
76    /// Returns every action committed so far, oldest first, formatted for
77    /// display (a log monitor, a test assertion) rather than for replay.
78    #[must_use]
79    pub fn log_history(&self) -> &[String] {
80        &self.log_history
81    }
82
83    /// Returns the seat a human is expected to act for right now, if any.
84    ///
85    /// The first (lowest seat index) active [`PlayerAgent::Human`] seat, since
86    /// [`turnbase::ActivePlayers`] iterates in ascending order; simultaneous
87    /// phases with more than one human seat resolve one at a time, same as
88    /// [`Simulator::step`] resolves AI seats one at a time.
89    #[must_use]
90    pub fn awaiting_human(&self) -> Option<PlayerId> {
91        self.game.active_players(&self.state).iter().find(|player| {
92            // A chance seat is resolved by `step`, never a human, even
93            // though (like a human) it has no agent-map entry.
94            !player.is_chance()
95                && matches!(self.agents.get(player), Some(PlayerAgent::Human) | None)
96        })
97    }
98
99    /// Returns whether the match has ended.
100    #[must_use]
101    pub fn is_terminal(&self) -> bool {
102        self.game.is_terminal(&self.state)
103    }
104
105    /// Returns the lowest-indexed seat controlled by [`PlayerAgent::Human`],
106    /// if any.
107    ///
108    /// Iterates a [`HashMap`], so this picks by seat index rather than
109    /// insertion order, keeping the result deterministic regardless of hash
110    /// order. `turnbase-simulator`'s dashboard uses this once, at
111    /// construction, to decide whose [`Game::View`] it renders from for the
112    /// whole match: a human should never see another seat's hidden
113    /// information (their cards, say) just because it happened to be an AI's
114    /// turn when the frame was drawn.
115    #[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
124// Logging formats the committed action with `{action:?}`, so these two entry
125// points need `Action: Debug`; every other method above works for any game.
126impl<G: Game> Simulator<G>
127where
128    G::Action: Debug,
129{
130    /// Advances the match by one atomic decision.
131    ///
132    /// Returns `Ok(true)` if an AI seat or a chance node advanced, `Ok(false)`
133    /// if the match is already over or the active seat is waiting on
134    /// [`Simulator::select_human_action`], or `Err` if the bot chose an action
135    /// [`Game::is_legal`] rejects.
136    ///
137    /// A chance node ([`PlayerId::CHANCE`] active) is resolved here by sampling
138    /// a committed outcome, so a driver loop advances past deck deals and dice
139    /// without any per-game wiring.
140    ///
141    /// # Errors
142    /// Returns [`Error::IllegalAction`] if the active bot's chosen action is
143    /// not legal for it in the current state.
144    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            // The player-facing log must not carry the concrete outcome: a
156            // committed chance move often deals a card that is hidden from
157            // some seat (a blackjack hole card, a Hanabi draw), and the log
158            // strip is not redacted per viewer the way `Game::view` is. Public
159            // outcomes still surface through the view; the log only narrates
160            // that a chance move happened. The developer `debug!` keeps the
161            // detail for determinism debugging (it is not shown in any UI).
162            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    /// Externally forces `player`'s action into the state machine.
183    ///
184    /// For a [`PlayerAgent::Human`] seat's UI (or a test) to commit a choice
185    /// once [`Simulator::awaiting_human`] names that seat.
186    ///
187    /// # Errors
188    /// Returns [`Error::NotActive`] if `player` is not currently owed a
189    /// decision, or [`Error::IllegalAction`] if `action` is not legal for
190    /// them.
191    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    /// Two seats alternately add 1 to a shared total; whoever reaches 3 wins.
222    /// A self-contained game so this crate's tests need no example crate.
223    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    /// One chance node reveals a value 0/1/2, then the match ends. Nothing
314    /// else acts, so it exercises chance resolution in isolation.
315    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        // No agents at all: the only active seat is CHANCE, which `step`
359        // samples and commits without anyone controlling it.
360        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        // Whatever value the chance node commits, the log entry must not
371        // contain it, so a hidden dealt card never shows in the log strip.
372        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}