yahtzee-engine 0.1.0

Yahtzee rules, scoring, and bots: a fast heuristic and an exact optimal expected-value solver
Documentation
//! The display-grade scorecard: box-by-box values and running totals.

use crate::{Categories, Category, Dice, GameError, ScoreDelta, State};

/// One player's card: every box value plus the Yahtzee-bonus count.
///
/// This is the record a paper card would hold.  Legality and valuation
/// are delegated to [`State`], so the card and the solver can never
/// disagree on the rules.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct Scorecard {
    boxes: [Option<u8>; 13],
    yahtzee_bonuses: u8,
}

impl Scorecard {
    /// An empty card.
    #[must_use]
    pub const fn new() -> Self {
        Self {
            boxes: [None; 13],
            yahtzee_bonuses: 0,
        }
    }

    /// The value written in `category`, or [`None`] while open.
    #[must_use]
    pub const fn get(self, category: Category) -> Option<u8> {
        self.boxes[category as usize]
    }

    /// The compact rules state of this card.
    #[must_use]
    pub fn state(self) -> State {
        let scored = Category::ALL
            .into_iter()
            .filter(|&c| self.get(c).is_some())
            .fold(Categories::EMPTY, Categories::with);
        State::new(
            scored,
            self.upper_subtotal().min(63),
            self.get(Category::Yahtzee) == Some(50),
        )
    }

    /// The upper-section subtotal, uncapped (at most 105).
    #[must_use]
    pub fn upper_subtotal(self) -> u8 {
        self.boxes[..6].iter().flatten().sum()
    }

    /// The upper-section bonus: 35 once the subtotal reaches 63.
    #[must_use]
    pub fn upper_bonus(self) -> u8 {
        if self.upper_subtotal() >= 63 { 35 } else { 0 }
    }

    /// How many 100-point Yahtzee bonuses have been earned.
    #[must_use]
    pub const fn yahtzee_bonuses(self) -> u8 {
        self.yahtzee_bonuses
    }

    /// The grand total: all boxes plus both bonuses (at most 1575).
    #[must_use]
    pub fn total(self) -> u16 {
        self.boxes
            .iter()
            .flatten()
            .map(|&v| u16::from(v))
            .sum::<u16>()
            + u16::from(self.upper_bonus())
            + 100 * u16::from(self.yahtzee_bonuses)
    }

    /// The categories `dice` may legally be scored in; see
    /// [`State::legal_categories`].
    #[must_use]
    pub fn legal_categories(self, dice: Dice) -> Categories {
        self.state().legal_categories(dice)
    }

    /// Writes `dice` into `category`, updating bonuses.
    ///
    /// # Errors
    ///
    /// [`GameError::IllegalCategory`] if the box is filled or the joker
    /// rule forces a different one; the card is left unchanged.
    pub fn score(&mut self, category: Category, dice: Dice) -> Result<ScoreDelta, GameError> {
        let delta = self
            .state()
            .apply(category, dice)
            .ok_or(GameError::IllegalCategory { category })?;
        self.boxes[category as usize] = Some(delta.value);
        self.yahtzee_bonuses += u8::from(delta.yahtzee_bonus);
        Ok(delta)
    }

    /// Whether every box is filled.
    #[must_use]
    pub fn is_full(self) -> bool {
        self.boxes.iter().all(Option::is_some)
    }
}

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

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

    #[test]
    fn totals_add_boxes_and_bonuses() {
        let mut card = Scorecard::new();
        card.score(Category::Yahtzee, dice("44444")).expect("legal");
        assert_eq!(card.total(), 50);
        // An extra Yahtzee is forced into the matching upper box and
        // earns the 100-point bonus.
        let delta = card.score(Category::Fours, dice("44444")).expect("legal");
        assert!(delta.yahtzee_bonus);
        assert_eq!(card.get(Category::Fours), Some(20));
        assert_eq!(card.yahtzee_bonuses(), 1);
        assert_eq!(card.total(), 170);
        assert_eq!(card.upper_subtotal(), 20);
        assert_eq!(card.upper_bonus(), 0);
    }

    #[test]
    fn state_projection_matches_the_boxes() {
        let mut card = Scorecard::new();
        card.score(Category::Sixes, dice("66666")).expect("legal");
        card.score(Category::Chance, dice("13446")).expect("legal");
        let state = card.state();
        assert_eq!(state.scored(), Category::Sixes.bit().with(Category::Chance));
        assert_eq!(state.upper(), 30);
        assert!(!state.yahtzee_50());
        // A zeroed Yahtzee box never sets the flag.
        card.score(Category::Yahtzee, dice("13446")).expect("legal");
        assert!(!card.state().yahtzee_50());
    }

    #[test]
    fn rejections_leave_the_card_unchanged() {
        let mut card = Scorecard::new();
        card.score(Category::Chance, dice("13446")).expect("legal");
        let before = card;
        let result = card.score(Category::Chance, dice("22222"));
        assert!(result.is_err());
        assert_eq!(card, before);
    }

    #[test]
    fn upper_bonus_shows_up_in_the_total() {
        let mut card = Scorecard::new();
        card.score(Category::Fours, dice("44444")).expect("legal");
        card.score(Category::Fives, dice("55555")).expect("legal");
        card.score(Category::Sixes, dice("66666")).expect("legal");
        assert_eq!(card.upper_subtotal(), 75);
        assert_eq!(card.upper_bonus(), 35);
        assert_eq!(card.total(), 110);
    }
}