Skip to main content

yahtzee_engine/
dice.rs

1//! Dice multisets: a full roll of five dice and a kept subset.
2
3use core::fmt;
4use core::str::FromStr;
5
6/// A roll of exactly five six-sided dice, stored as face counts.
7///
8/// Order never matters in Yahtzee, so the multiset is the canonical
9/// representation: `counts[f - 1]` dice show face `f`.  The five-dice
10/// invariant is enforced by every constructor, which is why scoring
11/// functions never have to handle short or long rolls.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub struct Dice {
14    counts: [u8; 6],
15}
16
17/// A sub-multiset of zero to five dice held between rolls.
18///
19/// A separate type from [`Dice`] keeps "a full roll" and "what I am
20/// keeping" from being confused: every function that needs five dice
21/// takes [`Dice`], and the type system rules out partial rolls there.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
23pub struct Keep {
24    counts: [u8; 6],
25}
26
27/// Error parsing dice from text.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
29#[non_exhaustive]
30pub enum ParseDiceError {
31    /// A character other than `1`–`6` or whitespace was found.
32    #[error("invalid die face (expected digits 1-6)")]
33    InvalidFace,
34    /// The number of dice does not fit the target type.
35    #[error("wrong number of dice")]
36    WrongCount,
37}
38
39const fn parse_counts(s: &str) -> Result<([u8; 6], u8), ParseDiceError> {
40    let mut counts = [0u8; 6];
41    let mut total = 0u8;
42    let bytes = s.as_bytes();
43    let mut i = 0;
44    while i < bytes.len() {
45        match bytes[i] {
46            b'1'..=b'6' => {
47                counts[(bytes[i] - b'1') as usize] += 1;
48                total += 1;
49                if total > 5 {
50                    return Err(ParseDiceError::WrongCount);
51                }
52            }
53            b' ' | b'\t' | b'\n' | b'\r' => {}
54            _ => return Err(ParseDiceError::InvalidFace),
55        }
56        i += 1;
57    }
58    Ok((counts, total))
59}
60
61fn fmt_counts(counts: &[u8; 6], f: &mut fmt::Formatter<'_>) -> fmt::Result {
62    for (i, &n) in counts.iter().enumerate() {
63        for _ in 0..n {
64            write!(f, "{}", i + 1)?;
65        }
66    }
67    Ok(())
68}
69
70impl Dice {
71    /// Builds a roll from five faces in any order.
72    ///
73    /// Returns [`None`] if any face is outside `1..=6`.
74    #[must_use]
75    pub const fn from_faces(faces: [u8; 5]) -> Option<Self> {
76        let mut counts = [0u8; 6];
77        let mut i = 0;
78        while i < 5 {
79            if faces[i] < 1 || faces[i] > 6 {
80                return None;
81            }
82            counts[(faces[i] - 1) as usize] += 1;
83            i += 1;
84        }
85        Some(Self { counts })
86    }
87
88    /// Builds a roll from face counts (`counts[f - 1]` dice show `f`).
89    ///
90    /// Returns [`None`] unless the counts sum to exactly five.
91    #[must_use]
92    pub const fn from_counts(counts: [u8; 6]) -> Option<Self> {
93        // A u16 sum: u8 would wrap on adversarial counts in release
94        // builds and let a 261-die "roll" through as five.
95        let mut sum = 0u16;
96        let mut i = 0;
97        while i < 6 {
98            sum += counts[i] as u16;
99            i += 1;
100        }
101        if sum == 5 {
102            Some(Self { counts })
103        } else {
104            None
105        }
106    }
107
108    /// How many dice show `face` (`1..=6`).
109    ///
110    /// # Panics
111    ///
112    /// Panics if `face` is outside `1..=6`.
113    #[must_use]
114    pub const fn count(self, face: u8) -> u8 {
115        self.counts[(face - 1) as usize]
116    }
117
118    /// The face counts, indexed by `face - 1`.
119    #[must_use]
120    pub const fn counts(self) -> [u8; 6] {
121        self.counts
122    }
123
124    /// The pip total of all five dice.
125    #[must_use]
126    pub const fn sum(self) -> u8 {
127        let mut sum = 0u8;
128        let mut f = 0;
129        while f < 6 {
130            sum += self.counts[f] * (f as u8 + 1);
131            f += 1;
132        }
133        sum
134    }
135
136    /// The face of a five-of-a-kind, or [`None`] if this roll is not one.
137    #[must_use]
138    pub const fn yahtzee_face(self) -> Option<u8> {
139        let mut f = 0;
140        while f < 6 {
141            if self.counts[f] == 5 {
142                return Some(f as u8 + 1);
143            }
144            f += 1;
145        }
146        None
147    }
148
149    /// The five faces in ascending order.
150    pub fn faces(self) -> impl Iterator<Item = u8> {
151        (1..=6u8).flat_map(move |f| core::iter::repeat_n(f, usize::from(self.count(f))))
152    }
153
154    /// All distinct sub-multisets of this roll, from [`Keep::EMPTY`] to
155    /// keeping everything.  A roll has at most 32 keeps (all faces
156    /// distinct) and as few as 6 (five of a kind).
157    pub fn keeps(self) -> impl Iterator<Item = Keep> {
158        Keeps {
159            limits: self.counts,
160            next: Some([0; 6]),
161        }
162    }
163
164    /// Whether `keep` is a sub-multiset of this roll.
165    #[must_use]
166    pub const fn contains(self, keep: Keep) -> bool {
167        let mut f = 0;
168        while f < 6 {
169            if keep.counts[f] > self.counts[f] {
170                return false;
171            }
172            f += 1;
173        }
174        true
175    }
176}
177
178impl Keep {
179    /// Keeping no dice: reroll everything.
180    pub const EMPTY: Self = Self { counts: [0; 6] };
181
182    /// Builds a keep from face counts (`counts[f - 1]` dice show `f`).
183    ///
184    /// Returns [`None`] if the counts sum to more than five.
185    #[must_use]
186    pub const fn from_counts(counts: [u8; 6]) -> Option<Self> {
187        // A u16 sum, like `Dice::from_counts`: u8 would wrap in release.
188        let mut sum = 0u16;
189        let mut i = 0;
190        while i < 6 {
191            sum += counts[i] as u16;
192            i += 1;
193        }
194        if sum <= 5 {
195            Some(Self { counts })
196        } else {
197            None
198        }
199    }
200
201    /// How many dice show `face` (`1..=6`).
202    ///
203    /// # Panics
204    ///
205    /// Panics if `face` is outside `1..=6`.
206    #[must_use]
207    pub const fn count(self, face: u8) -> u8 {
208        self.counts[(face - 1) as usize]
209    }
210
211    /// The face counts, indexed by `face - 1`.
212    #[must_use]
213    pub const fn counts(self) -> [u8; 6] {
214        self.counts
215    }
216
217    /// The number of dice kept.
218    #[must_use]
219    pub const fn len(self) -> u8 {
220        let mut sum = 0u8;
221        let mut i = 0;
222        while i < 6 {
223            sum += self.counts[i];
224            i += 1;
225        }
226        sum
227    }
228
229    /// Whether no dice are kept.
230    #[must_use]
231    pub const fn is_empty(self) -> bool {
232        self.len() == 0
233    }
234}
235
236impl From<Dice> for Keep {
237    /// Keeping all five dice.
238    fn from(dice: Dice) -> Self {
239        Self {
240            counts: dice.counts,
241        }
242    }
243}
244
245impl TryFrom<&[u8]> for Keep {
246    type Error = ParseDiceError;
247
248    /// Builds a keep from at most five faces in any order.
249    fn try_from(faces: &[u8]) -> Result<Self, ParseDiceError> {
250        if faces.len() > 5 {
251            return Err(ParseDiceError::WrongCount);
252        }
253        let mut counts = [0u8; 6];
254        for &face in faces {
255            if !(1..=6).contains(&face) {
256                return Err(ParseDiceError::InvalidFace);
257            }
258            counts[usize::from(face - 1)] += 1;
259        }
260        Ok(Self { counts })
261    }
262}
263
264impl FromStr for Dice {
265    type Err = ParseDiceError;
266
267    /// Parses five digits `1`–`6`, ignoring whitespace: `"13446"`.
268    fn from_str(s: &str) -> Result<Self, ParseDiceError> {
269        let (counts, total) = parse_counts(s)?;
270        if total == 5 {
271            Ok(Self { counts })
272        } else {
273            Err(ParseDiceError::WrongCount)
274        }
275    }
276}
277
278impl FromStr for Keep {
279    type Err = ParseDiceError;
280
281    /// Parses up to five digits `1`–`6`, ignoring whitespace.  The empty
282    /// string is [`Keep::EMPTY`].
283    fn from_str(s: &str) -> Result<Self, ParseDiceError> {
284        let (counts, _) = parse_counts(s)?;
285        Ok(Self { counts })
286    }
287}
288
289impl fmt::Display for Dice {
290    /// The five faces in ascending order: `"13446"`.
291    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
292        fmt_counts(&self.counts, f)
293    }
294}
295
296impl fmt::Display for Keep {
297    /// The kept faces in ascending order, empty when nothing is kept.
298    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
299        fmt_counts(&self.counts, f)
300    }
301}
302
303/// Odometer over all sub-multisets of a roll.
304struct Keeps {
305    limits: [u8; 6],
306    next: Option<[u8; 6]>,
307}
308
309impl Iterator for Keeps {
310    type Item = Keep;
311
312    fn next(&mut self) -> Option<Keep> {
313        let current = self.next?;
314        let mut counts = current;
315        self.next = 'carry: {
316            for f in 0..6 {
317                if counts[f] < self.limits[f] {
318                    counts[f] += 1;
319                    break 'carry Some(counts);
320                }
321                counts[f] = 0;
322            }
323            None
324        };
325        Some(Keep { counts: current })
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn constructors_enforce_invariants() {
335        assert_eq!(Dice::from_faces([1, 3, 4, 4, 6]), "13446".parse().ok());
336        assert_eq!(Dice::from_faces([0, 3, 4, 4, 6]), None);
337        assert_eq!(Dice::from_faces([7, 3, 4, 4, 6]), None);
338        assert_eq!(Dice::from_counts([5, 0, 0, 0, 0, 0]), "11111".parse().ok());
339        assert_eq!(Dice::from_counts([4, 0, 0, 0, 0, 0]), None);
340        assert_eq!(Keep::from_counts([6, 0, 0, 0, 0, 0]), None);
341        // Counts summing to 5 mod 256 must not sneak past validation.
342        assert_eq!(Dice::from_counts([200, 61, 0, 0, 0, 0]), None);
343        assert_eq!(Keep::from_counts([255, 6, 0, 0, 0, 0]), None);
344        assert_eq!("1344".parse::<Dice>(), Err(ParseDiceError::WrongCount));
345        assert_eq!("134467".parse::<Dice>(), Err(ParseDiceError::InvalidFace));
346        assert_eq!("".parse::<Keep>(), Ok(Keep::EMPTY));
347        assert_eq!("1 3 4\t46".parse::<Dice>(), "13446".parse());
348    }
349
350    #[test]
351    fn accessors() {
352        let dice: Dice = "13446".parse().expect("a valid roll");
353        assert_eq!(dice.count(4), 2);
354        assert_eq!(dice.sum(), 18);
355        assert_eq!(dice.yahtzee_face(), None);
356        assert_eq!(dice.faces().collect::<Vec<_>>(), [1, 3, 4, 4, 6]);
357        assert_eq!(dice.to_string(), "13446");
358
359        let yahtzee: Dice = "22222".parse().expect("a valid roll");
360        assert_eq!(yahtzee.yahtzee_face(), Some(2));
361    }
362
363    #[test]
364    fn keep_containment() {
365        let dice: Dice = "13446".parse().expect("a valid roll");
366        assert!(dice.contains("44".parse().expect("a valid keep")));
367        assert!(dice.contains(Keep::EMPTY));
368        assert!(dice.contains(Keep::from(dice)));
369        assert!(!dice.contains("444".parse().expect("a valid keep")));
370        assert!(!dice.contains("2".parse().expect("a valid keep")));
371    }
372
373    #[test]
374    fn keeps_enumerates_every_sub_multiset() {
375        let distinct: Dice = "12345".parse().expect("a valid roll");
376        assert_eq!(distinct.keeps().count(), 32);
377
378        let yahtzee: Dice = "66666".parse().expect("a valid roll");
379        assert_eq!(yahtzee.keeps().count(), 6);
380
381        let dice: Dice = "13446".parse().expect("a valid roll");
382        let keeps: Vec<Keep> = dice.keeps().collect();
383        assert_eq!(keeps.len(), 24); // 2 * 2 * 3 * 2
384        assert!(keeps.iter().all(|&k| dice.contains(k)));
385        // No duplicates: every keep is a distinct multiset.
386        for (i, a) in keeps.iter().enumerate() {
387            assert!(keeps[i + 1..].iter().all(|b| a != b));
388        }
389    }
390}