Skip to main content

turnbase/
game.rs

1//! The core `Game` trait and its optional make/unmake and determinize
2//! extensions.
3
4use crate::{ActivePlayers, Error, PlayerId, Prng};
5
6/// A turn-based game defined as pure functions from state and action to new
7/// state.
8///
9/// Per-match configuration (player count, board size, variant rules) lives on
10/// the implementing value (`&self`), so [`Self::State`] stays lean and cheap to
11/// clone (cloning is the default backtracking primitive) and there is one home
12/// for `num_players` and variant flags. Everything is synchronous and
13/// side-effect-free; randomness comes from a generator stored inside the state.
14pub trait Game {
15    /// A full position in a match.
16    type State;
17
18    /// A single decision. `PartialEq` powers the default [`Self::is_legal`]
19    /// membership check; it is trivially derivable for the small enums and
20    /// indices actions usually are.
21    type Action: PartialEq;
22
23    /// What a player or spectator observes, produced by [`Self::view`].
24    type View;
25
26    /// Returns the initial position for a match, its generator seeded from
27    /// `seed`.
28    fn new_initial_state(&self, seed: u64) -> Self::State;
29
30    /// Returns the number of seats in this match: the fixed roster, seats
31    /// `0..num_players`, excluding the chance pseudo-player.
32    ///
33    /// A constant property of the configured match, not a live count and not a
34    /// capacity. It does not shrink when a player is eliminated (an eliminated
35    /// seat simply stops appearing in [`Self::active_players`] and has no legal
36    /// actions). "Who owes a decision right now" is [`Self::active_players`];
37    /// the seats valid for [`Self::reward`] and [`Self::legal_actions`] are
38    /// `0..num_players`.
39    fn num_players(&self) -> usize;
40
41    /// Returns the players who owe a decision right now.
42    ///
43    /// Empty during engine-only resolution, one for alternating play, many
44    /// during simultaneous or secret phases. May contain [`PlayerId::CHANCE`]
45    /// when a committed random outcome is pending.
46    fn active_players(&self, state: &Self::State) -> ActivePlayers;
47
48    /// Returns the actions available to `player` at this decision point.
49    ///
50    /// A full turn is a sequence of `apply` calls until [`Self::active_players`]
51    /// changes; this enumerates one decision point, not a whole turn.
52    fn legal_actions(&self, state: &Self::State, player: PlayerId) -> Vec<Self::Action>;
53
54    /// Returns whether `action` is legal for `player` right now.
55    ///
56    /// Defaults to membership in [`Self::legal_actions`]. Override with a direct
57    /// check for decision points whose legal set is too large to enumerate
58    /// (arbitrary map targeting, payment combinatorics).
59    fn is_legal(&self, state: &Self::State, player: PlayerId, action: &Self::Action) -> bool {
60        self.legal_actions(state, player).contains(action)
61    }
62
63    /// Returns the probability distribution over chance outcomes while
64    /// [`PlayerId::CHANCE`] is active: each possible outcome action paired with
65    /// its probability. The probabilities should sum to 1.
66    ///
67    /// Defaults to uniform over `legal_actions(state, CHANCE)`. Override for
68    /// non-uniform chance (loaded dice, weighted decks). Mirrors OpenSpiel's
69    /// `chance_outcomes`. The committed outcome is chosen by a chance sampler
70    /// (see `sample_chance`) using a generator owned by the driver, distinct
71    /// from any per-state generator used for implicit rolls inside `apply`.
72    ///
73    /// Returns a materialized `Vec`, not an iterator, because callers pass over
74    /// it more than once — summing the weights and then sampling, or
75    /// enumerating every outcome for expectiminimax. Chance branching is small,
76    /// so the allocation is negligible (this matches OpenSpiel's materialized
77    /// `ChanceOutcomes`).
78    fn chance_outcomes(&self, state: &Self::State) -> Vec<(Self::Action, f64)> {
79        let actions = self.legal_actions(state, PlayerId::CHANCE);
80        if actions.is_empty() {
81            return Vec::new();
82        }
83        // Outcome counts are small; the reciprocal is exact enough as a weight.
84        #[allow(clippy::cast_precision_loss)]
85        let probability = 1.0 / actions.len() as f64;
86        actions
87            .into_iter()
88            .map(|action| (action, probability))
89            .collect()
90    }
91
92    /// Advances `state` in place by applying `player`'s `action`.
93    ///
94    /// Assumes the action is legal for an active player; callers that want
95    /// checking use [`Self::apply_cloned`]. Draw any randomness from the
96    /// generator inside `state` so the effect stays reproducible.
97    fn apply(&self, state: &mut Self::State, player: PlayerId, action: Self::Action);
98
99    /// Returns a copy of `state` advanced by `action`, without mutating the
100    /// original. The default backtracking primitive for search and tests.
101    ///
102    /// # Errors
103    /// Returns [`Error::NotActive`] if `player` is not currently active, or
104    /// [`Error::IllegalAction`] if the action is not legal for them.
105    fn apply_cloned(
106        &self,
107        state: &Self::State,
108        player: PlayerId,
109        action: Self::Action,
110    ) -> Result<Self::State, Error>
111    where
112        Self::State: Clone,
113    {
114        if !self.active_players(state).contains(player) {
115            return Err(Error::NotActive { player });
116        }
117        if !self.is_legal(state, player, &action) {
118            return Err(Error::IllegalAction { player });
119        }
120        let mut next = state.clone();
121        self.apply(&mut next, player, action);
122        Ok(next)
123    }
124
125    /// Returns whether the match has ended.
126    fn is_terminal(&self, state: &Self::State) -> bool;
127
128    /// Returns `player`'s terminal outcome as a single scalar (the minimal
129    /// win/loss signal for search and RL). Meaningful only when
130    /// [`Self::is_terminal`] holds; richer scoring belongs in public state.
131    fn reward(&self, state: &Self::State, player: PlayerId) -> f64;
132
133    /// Returns `player`'s immediate reward for the last step.
134    ///
135    /// Defaults to 0 (all signal at the terminal). Override to give RL trainers
136    /// a dense, per-step, per-player signal.
137    fn step_reward(&self, state: &Self::State, player: PlayerId) -> f64 {
138        let _ = (state, player);
139        0.0
140    }
141
142    /// Returns what `viewer` is allowed to observe. `None` is a seatless
143    /// spectator and observes the public projection only.
144    fn view(&self, state: &Self::State, viewer: Option<PlayerId>) -> Self::View;
145}
146
147/// Opt-in make/unmake for games where cloning the whole state per search node
148/// is too slow (chess-scale branching).
149///
150/// Cloning ([`Game::apply_cloned`]) is the default and is always correct;
151/// implement this only when per-node cloning dominates the search budget. A
152/// wrong `undo` corrupts search silently instead of crashing, so it is a
153/// deliberate opt-in.
154///
155/// RNG invariant: [`Self::UndoRecord`] must capture the generator's position as
156/// it was *before* the move (a small `Copy` value, [`Prng::position`]) and
157/// [`Self::undo`] must restore it. A move may consume a variable number of
158/// draws, so the pre-move position cannot be recovered by counting draws; it
159/// must be snapshotted up front. The clone path gets this for free by cloning
160/// the whole state.
161///
162/// [`Prng::position`]: crate::Prng::position
163pub trait Reversible: Game {
164    /// Enough information to reverse one [`Self::apply_undoable`] call.
165    type UndoRecord;
166
167    /// Advances `state` in place like [`Game::apply`], returning a record that
168    /// [`Self::undo`] can use to reverse it exactly (including the generator
169    /// position).
170    fn apply_undoable(
171        &self,
172        state: &mut Self::State,
173        player: PlayerId,
174        action: Self::Action,
175    ) -> Self::UndoRecord;
176
177    /// Reverses the move that produced `record`, restoring `state` (and its
178    /// generator position) to what it was before.
179    fn undo(&self, state: &mut Self::State, record: Self::UndoRecord);
180}
181
182/// Opt-in resampling of hidden information for imperfect-information search.
183///
184/// The imperfect-info analog of [`Reversible`]: an optional capability a game
185/// implements to unlock a bot that could not otherwise work. A perfect-info
186/// game implements it trivially as `state.clone()`; a hidden-info game randomly
187/// fills in what `observer` cannot see. Information-set MCTS (`Ismcts`) calls
188/// this once per simulation to search over sampled worlds.
189///
190/// The one invariant that makes search sound: the result must be **consistent
191/// with `observer`'s information set**. Everything `observer` can already see is
192/// preserved exactly (so [`Game::view`] for `observer` is unchanged); only the
193/// cards, tiles, or rolls they cannot see are resampled. Draw the resampling
194/// randomness from `rng`, not from the state, so repeated calls explore
195/// different worlds. This is the game-specific knowledge that a generic engine
196/// cannot supply (mirroring OpenSpiel's `ResampleFromInfostate`), which is why
197/// it is a trait a game opts into rather than a core method.
198pub trait Determinize: Game {
199    /// Returns a random full state consistent with what `observer` can see in
200    /// `state` (a determinization), resampling hidden information from `rng`.
201    fn determinize(&self, state: &Self::State, observer: PlayerId, rng: &mut Prng) -> Self::State;
202}
203
204#[cfg(test)]
205mod tests {
206    use super::{Game, Reversible};
207    use crate::{ActivePlayers, PlayerId, State};
208
209    /// Two-seat toy game: each turn the active player "rolls" a random 1..=6
210    /// into a shared total. It embeds a `Prng` (via `State<P, Q>`) and consumes
211    /// a variable number of draws per move, so it exercises `apply_cloned`, the
212    /// standard view, and the `Reversible` RNG-snapshot invariant.
213    struct RollGame;
214
215    #[derive(Clone, PartialEq, Eq, Debug)]
216    enum Action {
217        Roll,
218    }
219
220    type RollState = State<u32, u32>;
221
222    struct Undo {
223        prev_total: u32,
224        prev_position: u64,
225    }
226
227    impl Game for RollGame {
228        type State = RollState;
229        type Action = Action;
230        type View = crate::state::PlayerView<u32, u32>;
231
232        fn new_initial_state(&self, seed: u64) -> Self::State {
233            State::new(0, seed)
234        }
235
236        fn num_players(&self) -> usize {
237            2
238        }
239
240        fn active_players(&self, state: &Self::State) -> ActivePlayers {
241            if self.is_terminal(state) {
242                ActivePlayers::none()
243            } else {
244                ActivePlayers::one(PlayerId::new(*state.public() % 2))
245            }
246        }
247
248        fn legal_actions(&self, state: &Self::State, _player: PlayerId) -> Vec<Self::Action> {
249            if self.is_terminal(state) {
250                vec![]
251            } else {
252                vec![Action::Roll]
253            }
254        }
255
256        fn apply(&self, state: &mut Self::State, _player: PlayerId, _action: Self::Action) {
257            let roll = state.rng_mut().range(1, 7);
258            *state.public_mut() += u32::try_from(roll).unwrap();
259        }
260
261        fn is_terminal(&self, state: &Self::State) -> bool {
262            *state.public() >= 20
263        }
264
265        fn reward(&self, state: &Self::State, player: PlayerId) -> f64 {
266            // The player who crossed 20 (the previous mover) wins.
267            let winner = (*state.public() + 1) % 2;
268            if player.index() == winner { 1.0 } else { -1.0 }
269        }
270
271        fn view(&self, state: &Self::State, viewer: Option<PlayerId>) -> Self::View {
272            state.view_for(viewer)
273        }
274    }
275
276    impl Reversible for RollGame {
277        type UndoRecord = Undo;
278
279        fn apply_undoable(
280            &self,
281            state: &mut Self::State,
282            player: PlayerId,
283            action: Self::Action,
284        ) -> Self::UndoRecord {
285            let record = Undo {
286                prev_total: *state.public(),
287                prev_position: state.rng().position(),
288            };
289            self.apply(state, player, action);
290            record
291        }
292
293        fn undo(&self, state: &mut Self::State, record: Self::UndoRecord) {
294            *state.public_mut() = record.prev_total;
295            state.rng_mut().set_position(record.prev_position);
296        }
297    }
298
299    #[test]
300    fn apply_cloned_rejects_out_of_turn_and_illegal() {
301        let game = RollGame;
302        let state = game.new_initial_state(1);
303        // Seat 1 is not active on the first turn (total 0 -> seat 0).
304        assert!(
305            game.apply_cloned(&state, PlayerId::new(1), Action::Roll)
306                .is_err()
307        );
308    }
309
310    #[test]
311    fn apply_cloned_does_not_touch_the_original() {
312        let game = RollGame;
313        let state = game.new_initial_state(1);
314        let next = game
315            .apply_cloned(&state, PlayerId::new(0), Action::Roll)
316            .unwrap();
317        assert_eq!(*state.public(), 0);
318        assert!(*next.public() >= 1 && *next.public() <= 6);
319    }
320
321    #[test]
322    fn reversible_round_trip_restores_state_and_rng() {
323        let game = RollGame;
324        let mut state = game.new_initial_state(42);
325        let before = state.clone();
326
327        let record = game.apply_undoable(&mut state, PlayerId::new(0), Action::Roll);
328        assert_ne!(state, before);
329        game.undo(&mut state, record);
330        assert_eq!(state, before, "undo restores total and generator position");
331    }
332
333    #[test]
334    fn undo_then_reapply_reproduces_the_same_draw() {
335        let game = RollGame;
336        let mut state = game.new_initial_state(42);
337
338        let first = {
339            let r = game.apply_undoable(&mut state, PlayerId::new(0), Action::Roll);
340            let total = *state.public();
341            game.undo(&mut state, r);
342            total
343        };
344        // Because undo rewound the generator, the replay draws identically.
345        game.apply(&mut state, PlayerId::new(0), Action::Roll);
346        assert_eq!(*state.public(), first);
347    }
348}