Skip to main content

yahtzee_engine/
state.rs

1//! The compact scorecard state: the single authority on which categories
2//! may be scored and what they pay, including all joker rules.
3
4use crate::{Categories, Category, Dice};
5
6/// Everything about a scorecard that affects future play.
7///
8/// Only three facts matter for legality and expected value: which
9/// categories are filled, the upper-section subtotal capped at the bonus
10/// threshold of 63, and whether the Yahtzee box holds 50 (the gate for
11/// the 100-point bonus).  Box-by-box history is display data and lives in
12/// [`Scorecard`](crate::Scorecard); the solver keys its value table on
13/// this type via [`State::index`].
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub struct State {
16    scored: Categories,
17    upper: u8,
18    yahtzee_50: bool,
19}
20
21/// The outcome of scoring a category: the box value, any bonuses it
22/// triggered, and the resulting state.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub struct ScoreDelta {
25    /// The number written in the box (base or joker value).
26    pub value: u8,
27    /// Whether this roll earned a 100-point Yahtzee bonus.
28    pub yahtzee_bonus: bool,
29    /// Whether this write crossed the 63-point upper-bonus threshold.
30    pub upper_bonus: bool,
31    /// The state after the write.
32    pub next: State,
33}
34
35impl ScoreDelta {
36    /// The total points this write adds: box value plus bonuses.
37    #[must_use]
38    pub const fn reward(self) -> u16 {
39        self.value as u16
40            + if self.yahtzee_bonus { 100 } else { 0 }
41            + if self.upper_bonus { 35 } else { 0 }
42    }
43}
44
45/// The largest upper subtotal reachable with `scored` filled, capped at
46/// the bonus threshold.  Bounds the meaningful `upper` values of a state.
47pub(crate) const fn max_upper(scored: Categories) -> u8 {
48    let mut sum = 0u8;
49    let mut face = 1;
50    while face <= 6 {
51        if scored.bits() & (1 << (face - 1)) != 0 {
52            sum += 5 * face;
53        }
54        face += 1;
55    }
56    if sum < 63 { sum } else { 63 }
57}
58
59impl State {
60    /// The empty card at the start of a game.
61    pub const START: Self = Self {
62        scored: Categories::EMPTY,
63        upper: 0,
64        yahtzee_50: false,
65    };
66
67    /// Builds a state, clamping to consistency: `upper` is capped at what
68    /// the scored upper categories can total (and at 63), and
69    /// `yahtzee_50` is cleared unless the Yahtzee box is scored.
70    #[must_use]
71    pub const fn new(scored: Categories, upper: u8, yahtzee_50: bool) -> Self {
72        let cap = max_upper(scored);
73        Self {
74            scored,
75            upper: if upper < cap { upper } else { cap },
76            yahtzee_50: yahtzee_50 && scored.contains(Category::Yahtzee),
77        }
78    }
79
80    /// The filled categories.
81    #[must_use]
82    pub const fn scored(self) -> Categories {
83        self.scored
84    }
85
86    /// The upper-section subtotal, capped at the bonus threshold of 63.
87    #[must_use]
88    pub const fn upper(self) -> u8 {
89        self.upper
90    }
91
92    /// Whether the Yahtzee box holds 50, enabling 100-point bonuses and
93    /// joker values for further Yahtzees.
94    #[must_use]
95    pub const fn yahtzee_50(self) -> bool {
96        self.yahtzee_50
97    }
98
99    /// Whether every category is filled and the game is over.
100    #[must_use]
101    pub const fn is_full(self) -> bool {
102        self.scored.bits() == Categories::ALL.bits()
103    }
104
105    /// A perfect 20-bit hash: category bits, then the capped upper
106    /// subtotal, then the Yahtzee-50 flag.  Always below `1 << 20`.
107    #[must_use]
108    pub const fn index(self) -> usize {
109        self.scored.bits() as usize | (self.upper as usize) << 13 | (self.yahtzee_50 as usize) << 19
110    }
111
112    /// Rebuilds a state from [`Self::index`].
113    ///
114    /// Returns [`None`] for indices that violate the invariants: an upper
115    /// subtotal beyond what the scored upper categories can total (or
116    /// beyond the 63 cap), or a Yahtzee-50 flag without the Yahtzee box
117    /// scored.
118    #[must_use]
119    pub const fn from_index(index: usize) -> Option<Self> {
120        if index >= 1 << 20 {
121            return None;
122        }
123        let scored = match Categories::from_bits((index & 0x1FFF) as u16) {
124            Some(scored) => scored,
125            None => return None,
126        };
127        let upper = (index >> 13 & 0x3F) as u8;
128        let yahtzee_50 = index >> 19 & 1 != 0;
129        if upper > max_upper(scored) || (yahtzee_50 && !scored.contains(Category::Yahtzee)) {
130            return None;
131        }
132        Some(Self {
133            scored,
134            upper,
135            yahtzee_50,
136        })
137    }
138
139    /// The categories `dice` may legally be scored in, applying joker
140    /// forcing for extra Yahtzees.
141    ///
142    /// Normally every open category is legal (scoring zero is always
143    /// allowed).  When the roll is a Yahtzee but the Yahtzee box is
144    /// already filled — with 50 *or* zero — the forced-joker rule
145    /// restricts the choice:
146    ///
147    /// 1. the matching upper box, if open, is the only legal category;
148    /// 2. otherwise any open lower box, at full joker value;
149    /// 3. otherwise any open upper box, for zero.
150    ///
151    /// The result is non-empty unless the card [`is full`](Self::is_full).
152    #[must_use]
153    pub fn legal_categories(self, dice: Dice) -> Categories {
154        let open = !self.scored;
155        if let Some(face) = dice.yahtzee_face()
156            && self.scored.contains(Category::Yahtzee)
157        {
158            let upper = Category::upper(face).expect("a die face has an upper category");
159            if open.contains(upper) {
160                return upper.bit();
161            }
162            let lower = open & Categories::LOWER;
163            if !lower.is_empty() {
164                return lower;
165            }
166        }
167        open
168    }
169
170    /// Scores `dice` in `category`: the written value, bonuses, and the
171    /// next state.
172    ///
173    /// Returns [`None`] if `category` is not in
174    /// [`legal_categories`](Self::legal_categories).  An extra Yahtzee
175    /// scores joker values in the lower section (Full House 25, Small
176    /// Straight 30, Large Straight 40, the rest the dice total) and earns
177    /// the 100-point bonus whenever the Yahtzee box holds 50, even on a
178    /// forced zero.
179    #[must_use]
180    pub fn apply(self, category: Category, dice: Dice) -> Option<ScoreDelta> {
181        if !self.legal_categories(dice).contains(category) {
182            return None;
183        }
184        let joker = dice.yahtzee_face().is_some() && self.scored.contains(Category::Yahtzee);
185        let value = match category {
186            Category::FullHouse if joker => 25,
187            Category::SmallStraight if joker => 30,
188            Category::LargeStraight if joker => 40,
189            _ => category.score(dice),
190        };
191        let upper_bonus = category.upper_face().is_some() && {
192            let subtotal = self.upper + value;
193            self.upper < 63 && subtotal >= 63
194        };
195        let upper = if category.upper_face().is_some() {
196            (self.upper + value).min(63)
197        } else {
198            self.upper
199        };
200        Some(ScoreDelta {
201            value,
202            yahtzee_bonus: dice.yahtzee_face().is_some() && self.yahtzee_50,
203            upper_bonus,
204            next: Self {
205                scored: self.scored.with(category),
206                upper,
207                yahtzee_50: self.yahtzee_50 || (category == Category::Yahtzee && value == 50),
208            },
209        })
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    fn dice(s: &str) -> Dice {
218        s.parse().expect("a valid roll")
219    }
220
221    /// A state with the given categories filled, for joker scenarios.
222    fn state(scored: Categories, upper: u8, yahtzee_50: bool) -> State {
223        assert!(!yahtzee_50 || scored.contains(Category::Yahtzee));
224        State::new(scored, upper, yahtzee_50)
225    }
226
227    #[test]
228    fn open_categories_are_legal_without_a_yahtzee() {
229        let s = state(Category::Aces.bit().with(Category::Chance), 3, false);
230        assert_eq!(s.legal_categories(dice("13446")), !s.scored());
231        // Zeroing a category you cannot make is always allowed.
232        let delta = s.apply(Category::LargeStraight, dice("13446"));
233        assert_eq!(delta.expect("legal").value, 0);
234        // Filled categories are not.
235        assert_eq!(s.apply(Category::Aces, dice("13446")), None);
236    }
237
238    #[test]
239    fn first_yahtzee_is_not_a_joker() {
240        // With the Yahtzee box open, no forcing: score it anywhere, at
241        // base value (a Yahtzee is not a base full house).
242        let s = State::START;
243        assert_eq!(s.legal_categories(dice("44444")), Categories::ALL);
244        assert_eq!(s.apply(Category::Yahtzee, dice("44444")).unwrap().value, 50);
245        assert_eq!(
246            s.apply(Category::FullHouse, dice("44444")).unwrap().value,
247            0
248        );
249        let delta = s.apply(Category::Yahtzee, dice("44444")).unwrap();
250        assert!(!delta.yahtzee_bonus);
251        assert!(delta.next.yahtzee_50());
252    }
253
254    #[test]
255    fn joker_forces_the_matching_upper_box() {
256        let s = state(Category::Yahtzee.bit(), 0, true);
257        assert_eq!(s.legal_categories(dice("44444")), Category::Fours.bit());
258        let delta = s.apply(Category::Fours, dice("44444")).expect("forced");
259        assert_eq!(delta.value, 20);
260        assert!(delta.yahtzee_bonus);
261        assert_eq!(delta.reward(), 120);
262        assert_eq!(s.apply(Category::Chance, dice("44444")), None);
263    }
264
265    #[test]
266    fn joker_pays_full_value_in_the_lower_section() {
267        let s = state(Category::Yahtzee.bit().with(Category::Fours), 20, true);
268        let legal = s.legal_categories(dice("44444"));
269        assert_eq!(legal, (!s.scored()) & Categories::LOWER);
270        let apply = |c| s.apply(c, dice("44444")).expect("legal joker").value;
271        assert_eq!(apply(Category::ThreeOfAKind), 20);
272        assert_eq!(apply(Category::FourOfAKind), 20);
273        assert_eq!(apply(Category::FullHouse), 25);
274        assert_eq!(apply(Category::SmallStraight), 30);
275        assert_eq!(apply(Category::LargeStraight), 40);
276        assert_eq!(apply(Category::Chance), 20);
277        // Open upper boxes other than Fours are excluded by the joker.
278        assert_eq!(s.apply(Category::Aces, dice("44444")), None);
279    }
280
281    #[test]
282    fn joker_forces_a_zero_when_the_lower_section_is_full() {
283        let scored = Categories::LOWER.with(Category::Fours);
284        let s = state(scored, 20, true);
285        assert_eq!(s.legal_categories(dice("44444")), !scored);
286        let delta = s.apply(Category::Sixes, dice("44444")).expect("forced");
287        assert_eq!(delta.value, 0);
288        // The 100-point bonus is earned even on a forced zero.
289        assert!(delta.yahtzee_bonus);
290        assert_eq!(delta.reward(), 100);
291    }
292
293    #[test]
294    fn zeroed_yahtzee_box_forces_the_joker_but_never_pays_the_bonus() {
295        let s = state(Category::Yahtzee.bit(), 0, false);
296        assert_eq!(s.legal_categories(dice("44444")), Category::Fours.bit());
297        let delta = s.apply(Category::Fours, dice("44444")).expect("forced");
298        assert_eq!(delta.value, 20);
299        assert!(!delta.yahtzee_bonus);
300        assert!(!delta.next.yahtzee_50());
301    }
302
303    #[test]
304    fn upper_bonus_fires_exactly_on_crossing_63() {
305        let scored = Categories::UPPER - Category::Sixes.bit();
306        let s = state(scored, 45, false);
307        let delta = s.apply(Category::Sixes, dice("66612")).expect("legal");
308        assert_eq!(delta.value, 18);
309        assert!(delta.upper_bonus);
310        assert_eq!(delta.reward(), 53);
311        assert_eq!(delta.next.upper(), 63);
312
313        // One pip short: no bonus, and the subtotal is exact.
314        let s = state(scored, 44, false);
315        let delta = s.apply(Category::Sixes, dice("66612")).expect("legal");
316        assert!(!delta.upper_bonus);
317        assert_eq!(delta.next.upper(), 62);
318
319        // Already across: never a second bonus, and the cap holds.
320        let s = state(scored, 63, false);
321        let delta = s.apply(Category::Sixes, dice("66666")).expect("legal");
322        assert!(!delta.upper_bonus);
323        assert_eq!(delta.next.upper(), 63);
324    }
325
326    #[test]
327    fn lower_scores_leave_the_upper_subtotal_alone() {
328        let s = state(Category::Aces.bit(), 3, false);
329        let delta = s.apply(Category::Chance, dice("13446")).expect("legal");
330        assert_eq!(delta.next.upper(), 3);
331        assert!(!delta.upper_bonus);
332    }
333
334    #[test]
335    fn new_clamps_to_consistency() {
336        // Upper subtotal beyond what the scored boxes can pay is clamped.
337        assert_eq!(State::new(Category::Aces.bit(), 63, false).upper(), 5);
338        assert_eq!(State::new(Categories::UPPER, 63, false).upper(), 63);
339        assert_eq!(State::new(Categories::EMPTY, 10, false).upper(), 0);
340        // The Yahtzee-50 flag requires the Yahtzee box to be scored.
341        assert!(!State::new(Categories::EMPTY, 0, true).yahtzee_50());
342        assert!(State::new(Category::Yahtzee.bit(), 0, true).yahtzee_50());
343    }
344
345    #[test]
346    fn index_round_trips() {
347        let mut seen = 0usize;
348        for index in 0..1 << 20 {
349            if let Some(s) = State::from_index(index) {
350                assert_eq!(s.index(), index);
351                seen += 1;
352            }
353        }
354        // Every state built from parts round-trips too.
355        let s = state(Category::Yahtzee.bit().with(Category::Fives), 20, true);
356        assert_eq!(State::from_index(s.index()), Some(s));
357        assert!(seen > 100_000, "unexpectedly few valid states: {seen}");
358    }
359}