Skip to main content

yahtzee_engine/
game.rs

1//! The table-level state machine: seats, rounds, rolls, and scoring.
2
3use crate::{Category, Dice, Keep, ScoreDelta, Scorecard, TurnAction, View};
4
5/// Where the game stands, and what input it expects next.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
7pub enum Phase {
8    /// Dice are due: feed a roll with [`Game::roll`] or
9    /// `Game::roll_with` (feature `rand`; not linked so docs build
10    /// without it).
11    AwaitingRoll,
12    /// Dice are showing: the acting player decides with [`Game::act`].
13    AwaitingAction,
14    /// Thirteen rounds are complete; the card is full.
15    Finished,
16}
17
18/// A rules violation, reported without changing the game.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
20#[non_exhaustive]
21pub enum GameError {
22    /// [`Game::act`] was called while dice were due.
23    #[error("dice must be rolled before acting")]
24    RollPending,
25    /// [`Game::roll`] was called while a decision was due.
26    #[error("an action is pending; the dice have already been rolled")]
27    ActionPending,
28    /// The injected roll does not contain the dice held from last roll.
29    #[error("roll does not contain the held dice {kept}")]
30    KeptNotHeld {
31        /// The dice held when the roll was requested.
32        kept: Keep,
33    },
34    /// The kept dice are not a subset of the dice on the table.
35    #[error("keep {keep} is not part of the current dice")]
36    KeepNotInDice {
37        /// The offending keep.
38        keep: Keep,
39    },
40    /// A reroll was requested after the third roll.
41    #[error("no rerolls left this turn")]
42    NoRerollsLeft,
43    /// The category is filled, or the joker rule forces another.
44    #[error("cannot score {category} here")]
45    IllegalCategory {
46        /// The offending category.
47        category: Category,
48    },
49    /// The game is over.
50    #[error("the game is finished")]
51    Finished,
52}
53
54/// A game of Yahtzee for one or more players.
55///
56/// Players act in seat order, thirteen rounds each.  The primitive input
57/// is deterministic dice *injection* — [`roll`](Self::roll) takes the
58/// complete five-die result, which makes replays and front ends trivial
59/// and keeps the rules free of randomness.  `Game::roll_with` rolls for
60/// you behind the `rand` feature.
61///
62/// Illegal inputs are rejected with a [`GameError`] and leave the game
63/// unchanged, so interactive callers can simply retry.
64#[derive(Debug, Clone, PartialEq, Eq, Hash)]
65pub struct Game {
66    scorecards: Vec<Scorecard>,
67    seat: usize,
68    round: u8,
69    kept: Keep,
70    dice: Option<Dice>,
71    rolls_used: u8,
72}
73
74impl Game {
75    /// Sets up a game.
76    ///
77    /// # Panics
78    ///
79    /// Panics if `players` is zero.
80    #[must_use]
81    pub fn new(players: usize) -> Self {
82        assert!(players > 0, "a game needs at least one player");
83        Self {
84            scorecards: vec![Scorecard::new(); players],
85            seat: 0,
86            round: 0,
87            kept: Keep::EMPTY,
88            dice: None,
89            rolls_used: 0,
90        }
91    }
92
93    /// The current phase.
94    #[must_use]
95    pub const fn phase(&self) -> Phase {
96        if self.round >= 13 {
97            Phase::Finished
98        } else if self.dice.is_none() {
99            Phase::AwaitingRoll
100        } else {
101            Phase::AwaitingAction
102        }
103    }
104
105    /// Whether all thirteen rounds are complete.
106    #[must_use]
107    pub const fn is_over(&self) -> bool {
108        self.round >= 13
109    }
110
111    /// The seat of the player to act, `0..players`.
112    #[must_use]
113    pub const fn seat(&self) -> usize {
114        self.seat
115    }
116
117    /// The current round, `0..13`.
118    #[must_use]
119    pub const fn round(&self) -> u8 {
120        self.round
121    }
122
123    /// How many rolls remain this turn: 3 before the first roll, 0 after
124    /// the third (when only scoring is left) and once the game is over.
125    #[must_use]
126    pub const fn rolls_left(&self) -> u8 {
127        if self.is_over() {
128            0
129        } else {
130            3 - self.rolls_used
131        }
132    }
133
134    /// The dice held for the pending roll; empty at the start of a turn.
135    #[must_use]
136    pub const fn kept(&self) -> Keep {
137        self.kept
138    }
139
140    /// The dice on the table, or [`None`] while a roll is due.
141    #[must_use]
142    pub const fn dice(&self) -> Option<Dice> {
143        self.dice
144    }
145
146    /// One player's card.
147    ///
148    /// # Panics
149    ///
150    /// Panics if `seat` is out of range.
151    #[must_use]
152    pub fn scorecard(&self, seat: usize) -> &Scorecard {
153        &self.scorecards[seat]
154    }
155
156    /// All cards in seat order.
157    #[must_use]
158    pub fn scorecards(&self) -> &[Scorecard] {
159        &self.scorecards
160    }
161
162    /// Every player's grand total, in seat order.
163    #[must_use]
164    pub fn totals(&self) -> Vec<u16> {
165        self.scorecards.iter().map(|card| card.total()).collect()
166    }
167
168    /// The decision context for the acting player.
169    #[must_use]
170    pub const fn view(&self) -> View<'_> {
171        View::new(self)
172    }
173
174    /// Resolves the pending roll with a complete five-die result, which
175    /// must contain the held dice.
176    ///
177    /// # Errors
178    ///
179    /// [`GameError::Finished`], [`GameError::ActionPending`], or
180    /// [`GameError::KeptNotHeld`] if `dice` dropped a held die.
181    pub fn roll(&mut self, dice: Dice) -> Result<(), GameError> {
182        match self.phase() {
183            Phase::Finished => Err(GameError::Finished),
184            Phase::AwaitingAction => Err(GameError::ActionPending),
185            Phase::AwaitingRoll if !dice.contains(self.kept) => {
186                Err(GameError::KeptNotHeld { kept: self.kept })
187            }
188            Phase::AwaitingRoll => {
189                self.dice = Some(dice);
190                self.rolls_used += 1;
191                Ok(())
192            }
193        }
194    }
195
196    /// Rolls the unheld dice with `rng` and resolves the pending roll.
197    ///
198    /// # Errors
199    ///
200    /// [`GameError::Finished`] or [`GameError::ActionPending`].
201    #[cfg(feature = "rand")]
202    pub fn roll_with<R: rand::Rng + ?Sized>(&mut self, rng: &mut R) -> Result<Dice, GameError> {
203        use rand::RngExt as _;
204        let mut counts = self.kept.counts();
205        for _ in self.kept.len()..5 {
206            counts[rng.random_range(0..6)] += 1;
207        }
208        let dice = Dice::from_counts(counts).expect("kept dice plus rerolls make five");
209        self.roll(dice).map(|()| dice)
210    }
211
212    /// Applies the acting player's decision: hold and reroll, or score.
213    ///
214    /// Scoring returns the [`ScoreDelta`] and advances to the next seat;
215    /// a reroll returns [`None`] and awaits the next roll.
216    ///
217    /// # Errors
218    ///
219    /// [`GameError::Finished`], [`GameError::RollPending`],
220    /// [`GameError::NoRerollsLeft`], [`GameError::KeepNotInDice`], or
221    /// [`GameError::IllegalCategory`]; the game is left unchanged.
222    pub fn act(&mut self, action: TurnAction) -> Result<Option<ScoreDelta>, GameError> {
223        let dice = match self.phase() {
224            Phase::Finished => return Err(GameError::Finished),
225            Phase::AwaitingRoll => return Err(GameError::RollPending),
226            Phase::AwaitingAction => self.dice.expect("dice are showing"),
227        };
228        match action {
229            TurnAction::Reroll(_) if self.rolls_left() == 0 => Err(GameError::NoRerollsLeft),
230            TurnAction::Reroll(keep) if !dice.contains(keep) => {
231                Err(GameError::KeepNotInDice { keep })
232            }
233            TurnAction::Reroll(keep) => {
234                self.kept = keep;
235                self.dice = None;
236                Ok(None)
237            }
238            TurnAction::Score(category) => {
239                let delta = self.scorecards[self.seat].score(category, dice)?;
240                self.kept = Keep::EMPTY;
241                self.dice = None;
242                self.rolls_used = 0;
243                self.seat += 1;
244                if self.seat == self.scorecards.len() {
245                    self.seat = 0;
246                    self.round += 1;
247                }
248                Ok(Some(delta))
249            }
250        }
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    fn dice(s: &str) -> Dice {
259        s.parse().expect("a valid roll")
260    }
261
262    fn keep(s: &str) -> Keep {
263        s.parse().expect("a valid keep")
264    }
265
266    #[test]
267    fn a_turn_runs_roll_keep_roll_keep_roll_score() {
268        let mut game = Game::new(1);
269        assert_eq!(game.phase(), Phase::AwaitingRoll);
270        assert_eq!(game.rolls_left(), 3);
271        game.roll(dice("13446")).expect("first roll");
272        assert_eq!(game.rolls_left(), 2);
273        game.act(TurnAction::Reroll(keep("44")))
274            .expect("hold fours");
275        assert_eq!(game.phase(), Phase::AwaitingRoll);
276        game.roll(dice("24445")).expect("second roll");
277        game.act(TurnAction::Reroll(keep("444")))
278            .expect("hold fours");
279        game.roll(dice("14446")).expect("third roll");
280        assert_eq!(game.rolls_left(), 0);
281        let delta = game
282            .act(TurnAction::Score(Category::FourOfAKind))
283            .expect("scoring is always legal");
284        assert_eq!(delta.expect("a score").value, 0);
285        assert_eq!(game.round(), 1);
286        assert_eq!(game.phase(), Phase::AwaitingRoll);
287    }
288
289    #[test]
290    fn rejections_leave_the_game_unchanged() {
291        let mut game = Game::new(1);
292        assert_eq!(
293            game.act(TurnAction::Score(Category::Chance)),
294            Err(GameError::RollPending)
295        );
296        game.roll(dice("13446")).expect("first roll");
297        let before = game.clone();
298        assert_eq!(game.roll(dice("13446")), Err(GameError::ActionPending));
299        assert_eq!(
300            game.act(TurnAction::Reroll(keep("55"))),
301            Err(GameError::KeepNotInDice { keep: keep("55") })
302        );
303        assert_eq!(game, before);
304
305        game.act(TurnAction::Reroll(keep("446"))).expect("legal");
306        assert_eq!(
307            game.roll(dice("11123")),
308            Err(GameError::KeptNotHeld { kept: keep("446") })
309        );
310
311        game.roll(dice("44612")).expect("second roll");
312        game.act(TurnAction::Reroll(keep("446"))).expect("legal");
313        game.roll(dice("44613")).expect("third roll");
314        assert_eq!(
315            game.act(TurnAction::Reroll(Keep::EMPTY)),
316            Err(GameError::NoRerollsLeft)
317        );
318    }
319
320    #[test]
321    fn players_alternate_and_the_game_ends_after_13_rounds() {
322        let mut game = Game::new(2);
323        for round in 0..13 {
324            for seat in 0..2 {
325                assert_eq!(game.seat(), seat);
326                assert_eq!(game.round(), round);
327                game.roll(dice("13446")).expect("a roll");
328                let category = Category::ALL[usize::from(round)];
329                game.act(TurnAction::Score(category)).expect("open box");
330            }
331        }
332        assert!(game.is_over());
333        assert_eq!(game.phase(), Phase::Finished);
334        assert_eq!(game.rolls_left(), 0);
335        assert_eq!(game.roll(dice("13446")), Err(GameError::Finished));
336        assert_eq!(game.totals().len(), 2);
337    }
338}