yahtzee-engine 0.1.0

Yahtzee rules, scoring, and bots: a fast heuristic and an exact optimal expected-value solver
Documentation
//! The table-level state machine: seats, rounds, rolls, and scoring.

use crate::{Category, Dice, Keep, ScoreDelta, Scorecard, TurnAction, View};

/// Where the game stands, and what input it expects next.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Phase {
    /// Dice are due: feed a roll with [`Game::roll`] or
    /// `Game::roll_with` (feature `rand`; not linked so docs build
    /// without it).
    AwaitingRoll,
    /// Dice are showing: the acting player decides with [`Game::act`].
    AwaitingAction,
    /// Thirteen rounds are complete; the card is full.
    Finished,
}

/// A rules violation, reported without changing the game.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum GameError {
    /// [`Game::act`] was called while dice were due.
    #[error("dice must be rolled before acting")]
    RollPending,
    /// [`Game::roll`] was called while a decision was due.
    #[error("an action is pending; the dice have already been rolled")]
    ActionPending,
    /// The injected roll does not contain the dice held from last roll.
    #[error("roll does not contain the held dice {kept}")]
    KeptNotHeld {
        /// The dice held when the roll was requested.
        kept: Keep,
    },
    /// The kept dice are not a subset of the dice on the table.
    #[error("keep {keep} is not part of the current dice")]
    KeepNotInDice {
        /// The offending keep.
        keep: Keep,
    },
    /// A reroll was requested after the third roll.
    #[error("no rerolls left this turn")]
    NoRerollsLeft,
    /// The category is filled, or the joker rule forces another.
    #[error("cannot score {category} here")]
    IllegalCategory {
        /// The offending category.
        category: Category,
    },
    /// The game is over.
    #[error("the game is finished")]
    Finished,
}

/// A game of Yahtzee for one or more players.
///
/// Players act in seat order, thirteen rounds each.  The primitive input
/// is deterministic dice *injection* — [`roll`](Self::roll) takes the
/// complete five-die result, which makes replays and front ends trivial
/// and keeps the rules free of randomness.  `Game::roll_with` rolls for
/// you behind the `rand` feature.
///
/// Illegal inputs are rejected with a [`GameError`] and leave the game
/// unchanged, so interactive callers can simply retry.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Game {
    scorecards: Vec<Scorecard>,
    seat: usize,
    round: u8,
    kept: Keep,
    dice: Option<Dice>,
    rolls_used: u8,
}

impl Game {
    /// Sets up a game.
    ///
    /// # Panics
    ///
    /// Panics if `players` is zero.
    #[must_use]
    pub fn new(players: usize) -> Self {
        assert!(players > 0, "a game needs at least one player");
        Self {
            scorecards: vec![Scorecard::new(); players],
            seat: 0,
            round: 0,
            kept: Keep::EMPTY,
            dice: None,
            rolls_used: 0,
        }
    }

    /// The current phase.
    #[must_use]
    pub const fn phase(&self) -> Phase {
        if self.round >= 13 {
            Phase::Finished
        } else if self.dice.is_none() {
            Phase::AwaitingRoll
        } else {
            Phase::AwaitingAction
        }
    }

    /// Whether all thirteen rounds are complete.
    #[must_use]
    pub const fn is_over(&self) -> bool {
        self.round >= 13
    }

    /// The seat of the player to act, `0..players`.
    #[must_use]
    pub const fn seat(&self) -> usize {
        self.seat
    }

    /// The current round, `0..13`.
    #[must_use]
    pub const fn round(&self) -> u8 {
        self.round
    }

    /// How many rolls remain this turn: 3 before the first roll, 0 after
    /// the third (when only scoring is left) and once the game is over.
    #[must_use]
    pub const fn rolls_left(&self) -> u8 {
        if self.is_over() {
            0
        } else {
            3 - self.rolls_used
        }
    }

    /// The dice held for the pending roll; empty at the start of a turn.
    #[must_use]
    pub const fn kept(&self) -> Keep {
        self.kept
    }

    /// The dice on the table, or [`None`] while a roll is due.
    #[must_use]
    pub const fn dice(&self) -> Option<Dice> {
        self.dice
    }

    /// One player's card.
    ///
    /// # Panics
    ///
    /// Panics if `seat` is out of range.
    #[must_use]
    pub fn scorecard(&self, seat: usize) -> &Scorecard {
        &self.scorecards[seat]
    }

    /// All cards in seat order.
    #[must_use]
    pub fn scorecards(&self) -> &[Scorecard] {
        &self.scorecards
    }

    /// Every player's grand total, in seat order.
    #[must_use]
    pub fn totals(&self) -> Vec<u16> {
        self.scorecards.iter().map(|card| card.total()).collect()
    }

    /// The decision context for the acting player.
    #[must_use]
    pub const fn view(&self) -> View<'_> {
        View::new(self)
    }

    /// Resolves the pending roll with a complete five-die result, which
    /// must contain the held dice.
    ///
    /// # Errors
    ///
    /// [`GameError::Finished`], [`GameError::ActionPending`], or
    /// [`GameError::KeptNotHeld`] if `dice` dropped a held die.
    pub fn roll(&mut self, dice: Dice) -> Result<(), GameError> {
        match self.phase() {
            Phase::Finished => Err(GameError::Finished),
            Phase::AwaitingAction => Err(GameError::ActionPending),
            Phase::AwaitingRoll if !dice.contains(self.kept) => {
                Err(GameError::KeptNotHeld { kept: self.kept })
            }
            Phase::AwaitingRoll => {
                self.dice = Some(dice);
                self.rolls_used += 1;
                Ok(())
            }
        }
    }

    /// Rolls the unheld dice with `rng` and resolves the pending roll.
    ///
    /// # Errors
    ///
    /// [`GameError::Finished`] or [`GameError::ActionPending`].
    #[cfg(feature = "rand")]
    pub fn roll_with<R: rand::Rng + ?Sized>(&mut self, rng: &mut R) -> Result<Dice, GameError> {
        use rand::RngExt as _;
        let mut counts = self.kept.counts();
        for _ in self.kept.len()..5 {
            counts[rng.random_range(0..6)] += 1;
        }
        let dice = Dice::from_counts(counts).expect("kept dice plus rerolls make five");
        self.roll(dice).map(|()| dice)
    }

    /// Applies the acting player's decision: hold and reroll, or score.
    ///
    /// Scoring returns the [`ScoreDelta`] and advances to the next seat;
    /// a reroll returns [`None`] and awaits the next roll.
    ///
    /// # Errors
    ///
    /// [`GameError::Finished`], [`GameError::RollPending`],
    /// [`GameError::NoRerollsLeft`], [`GameError::KeepNotInDice`], or
    /// [`GameError::IllegalCategory`]; the game is left unchanged.
    pub fn act(&mut self, action: TurnAction) -> Result<Option<ScoreDelta>, GameError> {
        let dice = match self.phase() {
            Phase::Finished => return Err(GameError::Finished),
            Phase::AwaitingRoll => return Err(GameError::RollPending),
            Phase::AwaitingAction => self.dice.expect("dice are showing"),
        };
        match action {
            TurnAction::Reroll(_) if self.rolls_left() == 0 => Err(GameError::NoRerollsLeft),
            TurnAction::Reroll(keep) if !dice.contains(keep) => {
                Err(GameError::KeepNotInDice { keep })
            }
            TurnAction::Reroll(keep) => {
                self.kept = keep;
                self.dice = None;
                Ok(None)
            }
            TurnAction::Score(category) => {
                let delta = self.scorecards[self.seat].score(category, dice)?;
                self.kept = Keep::EMPTY;
                self.dice = None;
                self.rolls_used = 0;
                self.seat += 1;
                if self.seat == self.scorecards.len() {
                    self.seat = 0;
                    self.round += 1;
                }
                Ok(Some(delta))
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn dice(s: &str) -> Dice {
        s.parse().expect("a valid roll")
    }

    fn keep(s: &str) -> Keep {
        s.parse().expect("a valid keep")
    }

    #[test]
    fn a_turn_runs_roll_keep_roll_keep_roll_score() {
        let mut game = Game::new(1);
        assert_eq!(game.phase(), Phase::AwaitingRoll);
        assert_eq!(game.rolls_left(), 3);
        game.roll(dice("13446")).expect("first roll");
        assert_eq!(game.rolls_left(), 2);
        game.act(TurnAction::Reroll(keep("44")))
            .expect("hold fours");
        assert_eq!(game.phase(), Phase::AwaitingRoll);
        game.roll(dice("24445")).expect("second roll");
        game.act(TurnAction::Reroll(keep("444")))
            .expect("hold fours");
        game.roll(dice("14446")).expect("third roll");
        assert_eq!(game.rolls_left(), 0);
        let delta = game
            .act(TurnAction::Score(Category::FourOfAKind))
            .expect("scoring is always legal");
        assert_eq!(delta.expect("a score").value, 0);
        assert_eq!(game.round(), 1);
        assert_eq!(game.phase(), Phase::AwaitingRoll);
    }

    #[test]
    fn rejections_leave_the_game_unchanged() {
        let mut game = Game::new(1);
        assert_eq!(
            game.act(TurnAction::Score(Category::Chance)),
            Err(GameError::RollPending)
        );
        game.roll(dice("13446")).expect("first roll");
        let before = game.clone();
        assert_eq!(game.roll(dice("13446")), Err(GameError::ActionPending));
        assert_eq!(
            game.act(TurnAction::Reroll(keep("55"))),
            Err(GameError::KeepNotInDice { keep: keep("55") })
        );
        assert_eq!(game, before);

        game.act(TurnAction::Reroll(keep("446"))).expect("legal");
        assert_eq!(
            game.roll(dice("11123")),
            Err(GameError::KeptNotHeld { kept: keep("446") })
        );

        game.roll(dice("44612")).expect("second roll");
        game.act(TurnAction::Reroll(keep("446"))).expect("legal");
        game.roll(dice("44613")).expect("third roll");
        assert_eq!(
            game.act(TurnAction::Reroll(Keep::EMPTY)),
            Err(GameError::NoRerollsLeft)
        );
    }

    #[test]
    fn players_alternate_and_the_game_ends_after_13_rounds() {
        let mut game = Game::new(2);
        for round in 0..13 {
            for seat in 0..2 {
                assert_eq!(game.seat(), seat);
                assert_eq!(game.round(), round);
                game.roll(dice("13446")).expect("a roll");
                let category = Category::ALL[usize::from(round)];
                game.act(TurnAction::Score(category)).expect("open box");
            }
        }
        assert!(game.is_over());
        assert_eq!(game.phase(), Phase::Finished);
        assert_eq!(game.rolls_left(), 0);
        assert_eq!(game.roll(dice("13446")), Err(GameError::Finished));
        assert_eq!(game.totals().len(), 2);
    }
}