1use crate::{Categories, Category, Dice};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub struct State {
16 scored: Categories,
17 upper: u8,
18 yahtzee_50: bool,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub struct ScoreDelta {
25 pub value: u8,
27 pub yahtzee_bonus: bool,
29 pub upper_bonus: bool,
31 pub next: State,
33}
34
35impl ScoreDelta {
36 #[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
45pub(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 pub const START: Self = Self {
62 scored: Categories::EMPTY,
63 upper: 0,
64 yahtzee_50: false,
65 };
66
67 #[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 #[must_use]
82 pub const fn scored(self) -> Categories {
83 self.scored
84 }
85
86 #[must_use]
88 pub const fn upper(self) -> u8 {
89 self.upper
90 }
91
92 #[must_use]
95 pub const fn yahtzee_50(self) -> bool {
96 self.yahtzee_50
97 }
98
99 #[must_use]
101 pub const fn is_full(self) -> bool {
102 self.scored.bits() == Categories::ALL.bits()
103 }
104
105 #[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 #[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 #[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 #[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 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 let delta = s.apply(Category::LargeStraight, dice("13446"));
233 assert_eq!(delta.expect("legal").value, 0);
234 assert_eq!(s.apply(Category::Aces, dice("13446")), None);
236 }
237
238 #[test]
239 fn first_yahtzee_is_not_a_joker() {
240 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 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 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 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 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 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 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 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}