use crate::{Categories, Category, Dice};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct State {
scored: Categories,
upper: u8,
yahtzee_50: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ScoreDelta {
pub value: u8,
pub yahtzee_bonus: bool,
pub upper_bonus: bool,
pub next: State,
}
impl ScoreDelta {
#[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 }
}
}
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 {
pub const START: Self = Self {
scored: Categories::EMPTY,
upper: 0,
yahtzee_50: false,
};
#[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),
}
}
#[must_use]
pub const fn scored(self) -> Categories {
self.scored
}
#[must_use]
pub const fn upper(self) -> u8 {
self.upper
}
#[must_use]
pub const fn yahtzee_50(self) -> bool {
self.yahtzee_50
}
#[must_use]
pub const fn is_full(self) -> bool {
self.scored.bits() == Categories::ALL.bits()
}
#[must_use]
pub const fn index(self) -> usize {
self.scored.bits() as usize | (self.upper as usize) << 13 | (self.yahtzee_50 as usize) << 19
}
#[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,
})
}
#[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
}
#[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")
}
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());
let delta = s.apply(Category::LargeStraight, dice("13446"));
assert_eq!(delta.expect("legal").value, 0);
assert_eq!(s.apply(Category::Aces, dice("13446")), None);
}
#[test]
fn first_yahtzee_is_not_a_joker() {
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);
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);
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);
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);
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() {
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);
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;
}
}
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}");
}
}