Skip to main content

yahtzee_engine/
scorecard.rs

1//! The display-grade scorecard: box-by-box values and running totals.
2
3use crate::{Categories, Category, Dice, GameError, ScoreDelta, State};
4
5/// One player's card: every box value plus the Yahtzee-bonus count.
6///
7/// This is the record a paper card would hold.  Legality and valuation
8/// are delegated to [`State`], so the card and the solver can never
9/// disagree on the rules.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
11pub struct Scorecard {
12    boxes: [Option<u8>; 13],
13    yahtzee_bonuses: u8,
14}
15
16impl Scorecard {
17    /// An empty card.
18    #[must_use]
19    pub const fn new() -> Self {
20        Self {
21            boxes: [None; 13],
22            yahtzee_bonuses: 0,
23        }
24    }
25
26    /// The value written in `category`, or [`None`] while open.
27    #[must_use]
28    pub const fn get(self, category: Category) -> Option<u8> {
29        self.boxes[category as usize]
30    }
31
32    /// The compact rules state of this card.
33    #[must_use]
34    pub fn state(self) -> State {
35        let scored = Category::ALL
36            .into_iter()
37            .filter(|&c| self.get(c).is_some())
38            .fold(Categories::EMPTY, Categories::with);
39        State::new(
40            scored,
41            self.upper_subtotal().min(63),
42            self.get(Category::Yahtzee) == Some(50),
43        )
44    }
45
46    /// The upper-section subtotal, uncapped (at most 105).
47    #[must_use]
48    pub fn upper_subtotal(self) -> u8 {
49        self.boxes[..6].iter().flatten().sum()
50    }
51
52    /// The upper-section bonus: 35 once the subtotal reaches 63.
53    #[must_use]
54    pub fn upper_bonus(self) -> u8 {
55        if self.upper_subtotal() >= 63 { 35 } else { 0 }
56    }
57
58    /// How many 100-point Yahtzee bonuses have been earned.
59    #[must_use]
60    pub const fn yahtzee_bonuses(self) -> u8 {
61        self.yahtzee_bonuses
62    }
63
64    /// The grand total: all boxes plus both bonuses (at most 1575).
65    #[must_use]
66    pub fn total(self) -> u16 {
67        self.boxes
68            .iter()
69            .flatten()
70            .map(|&v| u16::from(v))
71            .sum::<u16>()
72            + u16::from(self.upper_bonus())
73            + 100 * u16::from(self.yahtzee_bonuses)
74    }
75
76    /// The categories `dice` may legally be scored in; see
77    /// [`State::legal_categories`].
78    #[must_use]
79    pub fn legal_categories(self, dice: Dice) -> Categories {
80        self.state().legal_categories(dice)
81    }
82
83    /// Writes `dice` into `category`, updating bonuses.
84    ///
85    /// # Errors
86    ///
87    /// [`GameError::IllegalCategory`] if the box is filled or the joker
88    /// rule forces a different one; the card is left unchanged.
89    pub fn score(&mut self, category: Category, dice: Dice) -> Result<ScoreDelta, GameError> {
90        let delta = self
91            .state()
92            .apply(category, dice)
93            .ok_or(GameError::IllegalCategory { category })?;
94        self.boxes[category as usize] = Some(delta.value);
95        self.yahtzee_bonuses += u8::from(delta.yahtzee_bonus);
96        Ok(delta)
97    }
98
99    /// Whether every box is filled.
100    #[must_use]
101    pub fn is_full(self) -> bool {
102        self.boxes.iter().all(Option::is_some)
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    fn dice(s: &str) -> Dice {
111        s.parse().expect("a valid roll")
112    }
113
114    #[test]
115    fn totals_add_boxes_and_bonuses() {
116        let mut card = Scorecard::new();
117        card.score(Category::Yahtzee, dice("44444")).expect("legal");
118        assert_eq!(card.total(), 50);
119        // An extra Yahtzee is forced into the matching upper box and
120        // earns the 100-point bonus.
121        let delta = card.score(Category::Fours, dice("44444")).expect("legal");
122        assert!(delta.yahtzee_bonus);
123        assert_eq!(card.get(Category::Fours), Some(20));
124        assert_eq!(card.yahtzee_bonuses(), 1);
125        assert_eq!(card.total(), 170);
126        assert_eq!(card.upper_subtotal(), 20);
127        assert_eq!(card.upper_bonus(), 0);
128    }
129
130    #[test]
131    fn state_projection_matches_the_boxes() {
132        let mut card = Scorecard::new();
133        card.score(Category::Sixes, dice("66666")).expect("legal");
134        card.score(Category::Chance, dice("13446")).expect("legal");
135        let state = card.state();
136        assert_eq!(state.scored(), Category::Sixes.bit().with(Category::Chance));
137        assert_eq!(state.upper(), 30);
138        assert!(!state.yahtzee_50());
139        // A zeroed Yahtzee box never sets the flag.
140        card.score(Category::Yahtzee, dice("13446")).expect("legal");
141        assert!(!card.state().yahtzee_50());
142    }
143
144    #[test]
145    fn rejections_leave_the_card_unchanged() {
146        let mut card = Scorecard::new();
147        card.score(Category::Chance, dice("13446")).expect("legal");
148        let before = card;
149        let result = card.score(Category::Chance, dice("22222"));
150        assert!(result.is_err());
151        assert_eq!(card, before);
152    }
153
154    #[test]
155    fn upper_bonus_shows_up_in_the_total() {
156        let mut card = Scorecard::new();
157        card.score(Category::Fours, dice("44444")).expect("legal");
158        card.score(Category::Fives, dice("55555")).expect("legal");
159        card.score(Category::Sixes, dice("66666")).expect("legal");
160        assert_eq!(card.upper_subtotal(), 75);
161        assert_eq!(card.upper_bonus(), 35);
162        assert_eq!(card.total(), 110);
163    }
164}