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}