yahtzee-engine 0.1.0

Yahtzee rules, scoring, and bots: a fast heuristic and an exact optimal expected-value solver
Documentation
//! The compact scorecard state: the single authority on which categories
//! may be scored and what they pay, including all joker rules.

use crate::{Categories, Category, Dice};

/// Everything about a scorecard that affects future play.
///
/// Only three facts matter for legality and expected value: which
/// categories are filled, the upper-section subtotal capped at the bonus
/// threshold of 63, and whether the Yahtzee box holds 50 (the gate for
/// the 100-point bonus).  Box-by-box history is display data and lives in
/// [`Scorecard`](crate::Scorecard); the solver keys its value table on
/// this type via [`State::index`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct State {
    scored: Categories,
    upper: u8,
    yahtzee_50: bool,
}

/// The outcome of scoring a category: the box value, any bonuses it
/// triggered, and the resulting state.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ScoreDelta {
    /// The number written in the box (base or joker value).
    pub value: u8,
    /// Whether this roll earned a 100-point Yahtzee bonus.
    pub yahtzee_bonus: bool,
    /// Whether this write crossed the 63-point upper-bonus threshold.
    pub upper_bonus: bool,
    /// The state after the write.
    pub next: State,
}

impl ScoreDelta {
    /// The total points this write adds: box value plus bonuses.
    #[must_use]
    pub const fn reward(self) -> u16 {
        self.value as u16
            + if self.yahtzee_bonus { 100 } else { 0 }
            + if self.upper_bonus { 35 } else { 0 }
    }
}

/// The largest upper subtotal reachable with `scored` filled, capped at
/// the bonus threshold.  Bounds the meaningful `upper` values of a state.
pub(crate) const fn max_upper(scored: Categories) -> u8 {
    let mut sum = 0u8;
    let mut face = 1;
    while face <= 6 {
        if scored.bits() & (1 << (face - 1)) != 0 {
            sum += 5 * face;
        }
        face += 1;
    }
    if sum < 63 { sum } else { 63 }
}

impl State {
    /// The empty card at the start of a game.
    pub const START: Self = Self {
        scored: Categories::EMPTY,
        upper: 0,
        yahtzee_50: false,
    };

    /// Builds a state, clamping to consistency: `upper` is capped at what
    /// the scored upper categories can total (and at 63), and
    /// `yahtzee_50` is cleared unless the Yahtzee box is scored.
    #[must_use]
    pub const fn new(scored: Categories, upper: u8, yahtzee_50: bool) -> Self {
        let cap = max_upper(scored);
        Self {
            scored,
            upper: if upper < cap { upper } else { cap },
            yahtzee_50: yahtzee_50 && scored.contains(Category::Yahtzee),
        }
    }

    /// The filled categories.
    #[must_use]
    pub const fn scored(self) -> Categories {
        self.scored
    }

    /// The upper-section subtotal, capped at the bonus threshold of 63.
    #[must_use]
    pub const fn upper(self) -> u8 {
        self.upper
    }

    /// Whether the Yahtzee box holds 50, enabling 100-point bonuses and
    /// joker values for further Yahtzees.
    #[must_use]
    pub const fn yahtzee_50(self) -> bool {
        self.yahtzee_50
    }

    /// Whether every category is filled and the game is over.
    #[must_use]
    pub const fn is_full(self) -> bool {
        self.scored.bits() == Categories::ALL.bits()
    }

    /// A perfect 20-bit hash: category bits, then the capped upper
    /// subtotal, then the Yahtzee-50 flag.  Always below `1 << 20`.
    #[must_use]
    pub const fn index(self) -> usize {
        self.scored.bits() as usize | (self.upper as usize) << 13 | (self.yahtzee_50 as usize) << 19
    }

    /// Rebuilds a state from [`Self::index`].
    ///
    /// Returns [`None`] for indices that violate the invariants: an upper
    /// subtotal beyond what the scored upper categories can total (or
    /// beyond the 63 cap), or a Yahtzee-50 flag without the Yahtzee box
    /// scored.
    #[must_use]
    pub const fn from_index(index: usize) -> Option<Self> {
        if index >= 1 << 20 {
            return None;
        }
        let scored = match Categories::from_bits((index & 0x1FFF) as u16) {
            Some(scored) => scored,
            None => return None,
        };
        let upper = (index >> 13 & 0x3F) as u8;
        let yahtzee_50 = index >> 19 & 1 != 0;
        if upper > max_upper(scored) || (yahtzee_50 && !scored.contains(Category::Yahtzee)) {
            return None;
        }
        Some(Self {
            scored,
            upper,
            yahtzee_50,
        })
    }

    /// The categories `dice` may legally be scored in, applying joker
    /// forcing for extra Yahtzees.
    ///
    /// Normally every open category is legal (scoring zero is always
    /// allowed).  When the roll is a Yahtzee but the Yahtzee box is
    /// already filled — with 50 *or* zero — the forced-joker rule
    /// restricts the choice:
    ///
    /// 1. the matching upper box, if open, is the only legal category;
    /// 2. otherwise any open lower box, at full joker value;
    /// 3. otherwise any open upper box, for zero.
    ///
    /// The result is non-empty unless the card [`is full`](Self::is_full).
    #[must_use]
    pub fn legal_categories(self, dice: Dice) -> Categories {
        let open = !self.scored;
        if let Some(face) = dice.yahtzee_face()
            && self.scored.contains(Category::Yahtzee)
        {
            let upper = Category::upper(face).expect("a die face has an upper category");
            if open.contains(upper) {
                return upper.bit();
            }
            let lower = open & Categories::LOWER;
            if !lower.is_empty() {
                return lower;
            }
        }
        open
    }

    /// Scores `dice` in `category`: the written value, bonuses, and the
    /// next state.
    ///
    /// Returns [`None`] if `category` is not in
    /// [`legal_categories`](Self::legal_categories).  An extra Yahtzee
    /// scores joker values in the lower section (Full House 25, Small
    /// Straight 30, Large Straight 40, the rest the dice total) and earns
    /// the 100-point bonus whenever the Yahtzee box holds 50, even on a
    /// forced zero.
    #[must_use]
    pub fn apply(self, category: Category, dice: Dice) -> Option<ScoreDelta> {
        if !self.legal_categories(dice).contains(category) {
            return None;
        }
        let joker = dice.yahtzee_face().is_some() && self.scored.contains(Category::Yahtzee);
        let value = match category {
            Category::FullHouse if joker => 25,
            Category::SmallStraight if joker => 30,
            Category::LargeStraight if joker => 40,
            _ => category.score(dice),
        };
        let upper_bonus = category.upper_face().is_some() && {
            let subtotal = self.upper + value;
            self.upper < 63 && subtotal >= 63
        };
        let upper = if category.upper_face().is_some() {
            (self.upper + value).min(63)
        } else {
            self.upper
        };
        Some(ScoreDelta {
            value,
            yahtzee_bonus: dice.yahtzee_face().is_some() && self.yahtzee_50,
            upper_bonus,
            next: Self {
                scored: self.scored.with(category),
                upper,
                yahtzee_50: self.yahtzee_50 || (category == Category::Yahtzee && value == 50),
            },
        })
    }
}

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

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

    /// A state with the given categories filled, for joker scenarios.
    fn state(scored: Categories, upper: u8, yahtzee_50: bool) -> State {
        assert!(!yahtzee_50 || scored.contains(Category::Yahtzee));
        State::new(scored, upper, yahtzee_50)
    }

    #[test]
    fn open_categories_are_legal_without_a_yahtzee() {
        let s = state(Category::Aces.bit().with(Category::Chance), 3, false);
        assert_eq!(s.legal_categories(dice("13446")), !s.scored());
        // Zeroing a category you cannot make is always allowed.
        let delta = s.apply(Category::LargeStraight, dice("13446"));
        assert_eq!(delta.expect("legal").value, 0);
        // Filled categories are not.
        assert_eq!(s.apply(Category::Aces, dice("13446")), None);
    }

    #[test]
    fn first_yahtzee_is_not_a_joker() {
        // With the Yahtzee box open, no forcing: score it anywhere, at
        // base value (a Yahtzee is not a base full house).
        let s = State::START;
        assert_eq!(s.legal_categories(dice("44444")), Categories::ALL);
        assert_eq!(s.apply(Category::Yahtzee, dice("44444")).unwrap().value, 50);
        assert_eq!(
            s.apply(Category::FullHouse, dice("44444")).unwrap().value,
            0
        );
        let delta = s.apply(Category::Yahtzee, dice("44444")).unwrap();
        assert!(!delta.yahtzee_bonus);
        assert!(delta.next.yahtzee_50());
    }

    #[test]
    fn joker_forces_the_matching_upper_box() {
        let s = state(Category::Yahtzee.bit(), 0, true);
        assert_eq!(s.legal_categories(dice("44444")), Category::Fours.bit());
        let delta = s.apply(Category::Fours, dice("44444")).expect("forced");
        assert_eq!(delta.value, 20);
        assert!(delta.yahtzee_bonus);
        assert_eq!(delta.reward(), 120);
        assert_eq!(s.apply(Category::Chance, dice("44444")), None);
    }

    #[test]
    fn joker_pays_full_value_in_the_lower_section() {
        let s = state(Category::Yahtzee.bit().with(Category::Fours), 20, true);
        let legal = s.legal_categories(dice("44444"));
        assert_eq!(legal, (!s.scored()) & Categories::LOWER);
        let apply = |c| s.apply(c, dice("44444")).expect("legal joker").value;
        assert_eq!(apply(Category::ThreeOfAKind), 20);
        assert_eq!(apply(Category::FourOfAKind), 20);
        assert_eq!(apply(Category::FullHouse), 25);
        assert_eq!(apply(Category::SmallStraight), 30);
        assert_eq!(apply(Category::LargeStraight), 40);
        assert_eq!(apply(Category::Chance), 20);
        // Open upper boxes other than Fours are excluded by the joker.
        assert_eq!(s.apply(Category::Aces, dice("44444")), None);
    }

    #[test]
    fn joker_forces_a_zero_when_the_lower_section_is_full() {
        let scored = Categories::LOWER.with(Category::Fours);
        let s = state(scored, 20, true);
        assert_eq!(s.legal_categories(dice("44444")), !scored);
        let delta = s.apply(Category::Sixes, dice("44444")).expect("forced");
        assert_eq!(delta.value, 0);
        // The 100-point bonus is earned even on a forced zero.
        assert!(delta.yahtzee_bonus);
        assert_eq!(delta.reward(), 100);
    }

    #[test]
    fn zeroed_yahtzee_box_forces_the_joker_but_never_pays_the_bonus() {
        let s = state(Category::Yahtzee.bit(), 0, false);
        assert_eq!(s.legal_categories(dice("44444")), Category::Fours.bit());
        let delta = s.apply(Category::Fours, dice("44444")).expect("forced");
        assert_eq!(delta.value, 20);
        assert!(!delta.yahtzee_bonus);
        assert!(!delta.next.yahtzee_50());
    }

    #[test]
    fn upper_bonus_fires_exactly_on_crossing_63() {
        let scored = Categories::UPPER - Category::Sixes.bit();
        let s = state(scored, 45, false);
        let delta = s.apply(Category::Sixes, dice("66612")).expect("legal");
        assert_eq!(delta.value, 18);
        assert!(delta.upper_bonus);
        assert_eq!(delta.reward(), 53);
        assert_eq!(delta.next.upper(), 63);

        // One pip short: no bonus, and the subtotal is exact.
        let s = state(scored, 44, false);
        let delta = s.apply(Category::Sixes, dice("66612")).expect("legal");
        assert!(!delta.upper_bonus);
        assert_eq!(delta.next.upper(), 62);

        // Already across: never a second bonus, and the cap holds.
        let s = state(scored, 63, false);
        let delta = s.apply(Category::Sixes, dice("66666")).expect("legal");
        assert!(!delta.upper_bonus);
        assert_eq!(delta.next.upper(), 63);
    }

    #[test]
    fn lower_scores_leave_the_upper_subtotal_alone() {
        let s = state(Category::Aces.bit(), 3, false);
        let delta = s.apply(Category::Chance, dice("13446")).expect("legal");
        assert_eq!(delta.next.upper(), 3);
        assert!(!delta.upper_bonus);
    }

    #[test]
    fn new_clamps_to_consistency() {
        // Upper subtotal beyond what the scored boxes can pay is clamped.
        assert_eq!(State::new(Category::Aces.bit(), 63, false).upper(), 5);
        assert_eq!(State::new(Categories::UPPER, 63, false).upper(), 63);
        assert_eq!(State::new(Categories::EMPTY, 10, false).upper(), 0);
        // The Yahtzee-50 flag requires the Yahtzee box to be scored.
        assert!(!State::new(Categories::EMPTY, 0, true).yahtzee_50());
        assert!(State::new(Category::Yahtzee.bit(), 0, true).yahtzee_50());
    }

    #[test]
    fn index_round_trips() {
        let mut seen = 0usize;
        for index in 0..1 << 20 {
            if let Some(s) = State::from_index(index) {
                assert_eq!(s.index(), index);
                seen += 1;
            }
        }
        // Every state built from parts round-trips too.
        let s = state(Category::Yahtzee.bit().with(Category::Fives), 20, true);
        assert_eq!(State::from_index(s.index()), Some(s));
        assert!(seen > 100_000, "unexpectedly few valid states: {seen}");
    }
}