Skip to main content

yahtzee_engine/
solver.rs

1//! The exact solver: expected values for every state, keep, and category
2//! under optimal play.
3
4use crate::state::max_upper;
5use crate::tables::{DiceTables, KEEPS, ROLLS};
6use crate::{Categories, Category, Dice, Keep, State, Strategy, TurnAction, View};
7
8/// An exact expectimax solver over the full game.
9///
10/// The value table is keyed by [`State::index`] and filled by backward
11/// induction: a state's expected further score is the mean over the
12/// turn's roll/keep/roll/keep/roll/score lattice of the best reward plus
13/// the value of the resulting state.  From the empty card this comes to
14/// an optimal solo expectation of 254.5877 points under the official
15/// forced-joker rules this crate implements.  The widely cited 254.5896
16/// (Verhoeff, 1999) uses a laxer convention — an extra Yahtzee may be
17/// scored in any open box, with joker values gated on the matching
18/// upper box being filled — which this engine reproduces exactly when
19/// the forcing in [`State::legal_categories`] is lifted.
20///
21/// Construction is cheap; values are computed on demand.  Querying a
22/// mid-game state solves only the states reachable from it, so "what is
23/// best here" costs a fraction of a full [`solve`](Self::solve).  All
24/// queries break ties deterministically (first category in card order,
25/// then first keep in canonical order), so replays are stable.
26#[derive(Debug, Clone)]
27pub struct Solver {
28    values: Vec<f64>,
29    tables: DiceTables,
30}
31
32/// Per-roll menu of legal categories and box values for one mask.
33///
34/// Legality and box values depend only on the scored set and the dice —
35/// not on the upper subtotal or the Yahtzee-50 flag — so the menu is
36/// computed once per mask and shared by all its table slots.  Entries
37/// come straight from [`State::apply`], keeping the solver on the same
38/// rules as the game.
39type Menu = Vec<Vec<(Category, u8)>>;
40
41fn menu_for(tables: &DiceTables, mask: Categories) -> Menu {
42    let probe = State::new(mask, 0, false);
43    (0..ROLLS)
44        .map(|r| {
45            let dice = tables.roll(r);
46            probe
47                .legal_categories(dice)
48                .iter()
49                .map(|c| (c, probe.apply(c, dice).expect("legal").value))
50                .collect()
51        })
52        .collect()
53}
54
55/// Expected score of each roll when the turn must end now: the best
56/// category's reward plus the value of the state it leads to.
57fn final_layer(values: &[f64], tables: &DiceTables, state: State, menu: &Menu) -> [f64; ROLLS] {
58    let scored = state.scored().bits() as usize;
59    let upper = state.upper();
60    let flag = state.yahtzee_50();
61    let mut e_roll = [f64::NEG_INFINITY; ROLLS];
62    for (r, options) in menu.iter().enumerate() {
63        let bonus_eligible = flag && tables.roll(r).yahtzee_face().is_some();
64        let mut best = f64::NEG_INFINITY;
65        for &(category, value) in options {
66            // The same bookkeeping as `State::apply`, on table indices.
67            let (next_upper, upper_bonus) = if (category as u8) < 6 {
68                let subtotal = upper + value;
69                (subtotal.min(63), upper < 63 && subtotal >= 63)
70            } else {
71                (upper, false)
72            };
73            let next_flag = flag || (category == Category::Yahtzee && value == 50);
74            let next = scored
75                | 1 << category as usize
76                | (next_upper as usize) << 13
77                | usize::from(next_flag) << 19;
78            let reward = u16::from(value)
79                + if bonus_eligible { 100 } else { 0 }
80                + if upper_bonus { 35 } else { 0 };
81            let v = f64::from(reward) + values[next];
82            debug_assert!(!v.is_nan(), "unsolved successor of {state:?}");
83            if v > best {
84                best = v;
85            }
86        }
87        e_roll[r] = best;
88    }
89    e_roll
90}
91
92/// One reroll of backward induction: value every keep as the mean over
93/// its rolls, then every roll as its best keep.  The full five-die keep
94/// carries the "stop rolling" option through unchanged.
95fn sweep(tables: &DiceTables, e_roll: &[f64; ROLLS]) -> [f64; ROLLS] {
96    let mut e_keep = [0.0; KEEPS];
97    for (k, slot) in e_keep.iter_mut().enumerate() {
98        *slot = tables
99            .keep_successors(k)
100            .iter()
101            .map(|&(r, p)| p * e_roll[usize::from(r)])
102            .sum();
103    }
104    let mut out = [f64::NEG_INFINITY; ROLLS];
105    for (r, slot) in out.iter_mut().enumerate() {
106        *slot = tables
107            .roll_keeps(r)
108            .iter()
109            .map(|&k| e_keep[usize::from(k)])
110            .fold(f64::NEG_INFINITY, f64::max);
111    }
112    out
113}
114
115/// The expected further score from the start of a turn in `state`.
116fn turn_value(values: &[f64], tables: &DiceTables, state: State, menu: &Menu) -> f64 {
117    let mut e_roll = final_layer(values, tables, state, menu);
118    e_roll = sweep(tables, &e_roll);
119    e_roll = sweep(tables, &e_roll);
120    tables
121        .roll_prob()
122        .iter()
123        .zip(e_roll)
124        .map(|(p, v)| p * v)
125        .sum()
126}
127
128/// Values for every table slot of one mask, assuming every strict
129/// superset mask is already solved.
130fn solve_mask(values: &[f64], tables: &DiceTables, mask: Categories) -> Vec<(usize, f64)> {
131    let variants = |mask: Categories| {
132        [false, true]
133            .into_iter()
134            .filter(move |&flag| !flag || mask.contains(Category::Yahtzee))
135            .flat_map(move |flag| {
136                (0..=max_upper(mask)).map(move |upper| State::new(mask, upper, flag))
137            })
138    };
139    if mask == Categories::ALL {
140        return variants(mask).map(|s| (s.index(), 0.0)).collect();
141    }
142    let menu = menu_for(tables, mask);
143    variants(mask)
144        .map(|s| (s.index(), turn_value(values, tables, s, &menu)))
145        .collect()
146}
147
148impl Solver {
149    /// Builds the dice tables; no states are solved yet.
150    #[must_use]
151    pub fn new() -> Self {
152        let mut values = vec![f64::NAN; 1 << 20];
153        for upper in 0..=63 {
154            for flag in [false, true] {
155                values[State::new(Categories::ALL, upper, flag).index()] = 0.0;
156            }
157        }
158        Self {
159            values,
160            tables: DiceTables::new(),
161        }
162    }
163
164    /// Whether [`value`](Self::value) for this state is already computed.
165    #[must_use]
166    pub fn is_solved(&self, state: State) -> bool {
167        !self.values[state.index()].is_nan()
168    }
169
170    /// Solves every state with exactly `filled` categories scored.
171    ///
172    /// Tiers must be solved from 13 down to 0; [`solve`](Self::solve)
173    /// does exactly that.  Exposed so incremental front ends can report
174    /// progress between tiers.  With the `parallel` feature the tier is
175    /// solved across threads, bit-identical to the serial build.
176    ///
177    /// # Panics
178    ///
179    /// Panics if `filled > 13`, or if the tiers above `filled` have not
180    /// been solved yet — solving out of order would silently poison the
181    /// table otherwise.
182    pub fn solve_tier(&mut self, filled: u32) {
183        assert!(filled <= 13, "a card has 13 categories");
184        let masks: Vec<Categories> = (0..=Categories::ALL.bits())
185            .filter(|bits| bits.count_ones() == filled)
186            .map(|bits| Categories::from_bits(bits).expect("13-bit masks"))
187            .collect();
188        let (values, tables) = (&self.values, &self.tables);
189        #[cfg(feature = "parallel")]
190        let results: Vec<(usize, f64)> = {
191            use rayon::prelude::*;
192            masks
193                .par_iter()
194                .flat_map_iter(|&mask| solve_mask(values, tables, mask))
195                .collect()
196        };
197        #[cfg(not(feature = "parallel"))]
198        let results: Vec<(usize, f64)> = masks
199            .iter()
200            .flat_map(|&mask| solve_mask(values, tables, mask))
201            .collect();
202        for (index, value) in results {
203            // A finite value needs every successor solved; an unsolved
204            // successor's NaN turns the whole state's value non-finite.
205            assert!(
206                value.is_finite(),
207                "solve_tier({filled}) before its successor tiers: solve from 13 down to 0"
208            );
209            self.values[index] = value;
210        }
211    }
212
213    /// Solves the whole game, all reachable states, bottom-up.
214    ///
215    /// Takes seconds in a release build (use the `parallel` feature to
216    /// spread it across cores); afterwards every query is a table lookup
217    /// plus one turn evaluation.
218    pub fn solve(&mut self) {
219        for filled in (0..=13).rev() {
220            self.solve_tier(filled);
221        }
222    }
223
224    /// The expected further score from the start of a turn in `state`,
225    /// under optimal play.
226    ///
227    /// Solves lazily: only states reachable from `state` are computed,
228    /// so early queries from a part-filled card are much cheaper than a
229    /// full [`solve`](Self::solve).
230    pub fn value(&mut self, state: State) -> f64 {
231        if self.is_solved(state) {
232            return self.values[state.index()];
233        }
234        let scored = state.scored().bits();
235        let complement = (!state.scored()).bits();
236        // Every subset of the open categories, largest masks first, so
237        // each mask finds its successors already solved.
238        let mut subsets = Vec::with_capacity(1 << complement.count_ones());
239        let mut bits = complement;
240        loop {
241            subsets.push(bits);
242            if bits == 0 {
243                break;
244            }
245            bits = (bits - 1) & complement;
246        }
247        subsets.sort_by_key(|bits| core::cmp::Reverse(bits.count_ones()));
248        for bits in subsets {
249            let mask = Categories::from_bits(scored | bits).expect("13-bit masks");
250            // `solve_mask` fills every slot of a mask, so one probe slot
251            // tells whether the whole mask is done.
252            if self.values[State::new(mask, 0, false).index()].is_nan() {
253                for (index, value) in solve_mask(&self.values, &self.tables, mask) {
254                    self.values[index] = value;
255                }
256            }
257        }
258        self.values[state.index()]
259    }
260
261    /// The expected further score of scoring `dice` in `category` now
262    /// and playing on optimally: the write, its bonuses, and everything
263    /// after — points already on the card are not included.
264    ///
265    /// Returns [`None`] if the category is illegal here; see
266    /// [`State::legal_categories`].
267    pub fn category_ev(&mut self, state: State, dice: Dice, category: Category) -> Option<f64> {
268        let delta = state.apply(category, dice)?;
269        Some(f64::from(delta.reward()) + self.value(delta.next))
270    }
271
272    /// The expected further score of holding `keep` and rerolling the
273    /// rest, with `rolls_left` rolls remaining (clamped to the turn's
274    /// two rerolls).
275    ///
276    /// Returns [`None`] if the card is full, no rolls remain, or `keep`
277    /// is not part of `dice`.
278    pub fn keep_ev(&mut self, state: State, dice: Dice, keep: Keep, rolls_left: u8) -> Option<f64> {
279        if state.is_full() || rolls_left == 0 || !dice.contains(keep) {
280            return None;
281        }
282        self.value(state);
283        let menu = menu_for(&self.tables, state.scored());
284        let mut e_roll = final_layer(&self.values, &self.tables, state, &menu);
285        for _ in 1..rolls_left.min(2) {
286            e_roll = sweep(&self.tables, &e_roll);
287        }
288        let keep_id = self.tables.keep_id(keep);
289        Some(
290            self.tables
291                .keep_successors(keep_id)
292                .iter()
293                .map(|&(r, p)| p * e_roll[usize::from(r)])
294                .sum(),
295        )
296    }
297
298    /// The best category for `dice` when the turn must end now.
299    ///
300    /// # Panics
301    ///
302    /// Panics if the card is already full.
303    pub fn best_category(&mut self, state: State, dice: Dice) -> Category {
304        assert!(!state.is_full(), "no category is open on a full card");
305        self.value(state);
306        state
307            .legal_categories(dice)
308            .iter()
309            .map(|c| {
310                let delta = state.apply(c, dice).expect("legal");
311                (
312                    c,
313                    f64::from(delta.reward()) + self.values[delta.next.index()],
314                )
315            })
316            .fold(None, |best: Option<(Category, f64)>, (c, v)| match best {
317                Some((_, bv)) if bv >= v => best,
318                _ => Some((c, v)),
319            })
320            .expect("legal categories are non-empty until the card is full")
321            .0
322    }
323
324    /// The optimal action for `dice` with `rolls_left` rolls remaining
325    /// (clamped to the turn's two rerolls).  Scoring wins ties against
326    /// rerolling.
327    ///
328    /// # Panics
329    ///
330    /// Panics if the card is already full.
331    pub fn best_action(&mut self, state: State, dice: Dice, rolls_left: u8) -> TurnAction {
332        let category = self.best_category(state, dice);
333        if rolls_left == 0 {
334            return TurnAction::Score(category);
335        }
336        let menu = menu_for(&self.tables, state.scored());
337        let mut e_roll = final_layer(&self.values, &self.tables, state, &menu);
338        let score_now = e_roll[self.tables.roll_id(dice)];
339        for _ in 1..rolls_left.min(2) {
340            e_roll = sweep(&self.tables, &e_roll);
341        }
342        let mut best = TurnAction::Score(category);
343        let mut best_value = score_now;
344        for keep_id in 0..KEEPS {
345            let keep = self.tables.keep(keep_id);
346            if !dice.contains(keep) {
347                continue;
348            }
349            let value: f64 = self
350                .tables
351                .keep_successors(keep_id)
352                .iter()
353                .map(|&(r, p)| p * e_roll[usize::from(r)])
354                .sum();
355            if value > best_value {
356                best_value = value;
357                best = TurnAction::Reroll(keep);
358            }
359        }
360        best
361    }
362}
363
364impl Default for Solver {
365    fn default() -> Self {
366        Self::new()
367    }
368}
369
370/// A [`Strategy`] that plays perfectly, backed by a [`Solver`].
371///
372/// The default construction solves lazily: the first decision of a fresh
373/// game triggers close to a full solve, which takes seconds in a release
374/// build.  Use [`presolved`](Self::presolved) to pay that cost up front,
375/// or [`from_solver`](Self::from_solver) to share a table you already
376/// built.
377#[derive(Debug, Clone, Default)]
378pub struct OptimalBot {
379    solver: Solver,
380}
381
382impl OptimalBot {
383    /// A bot that solves on demand.
384    #[must_use]
385    pub fn new() -> Self {
386        Self::default()
387    }
388
389    /// A bot with the whole game solved up front; see [`Solver::solve`].
390    #[must_use]
391    pub fn presolved() -> Self {
392        let mut solver = Solver::new();
393        solver.solve();
394        Self { solver }
395    }
396
397    /// A bot backed by an existing solver, keeping its solved states.
398    #[must_use]
399    pub const fn from_solver(solver: Solver) -> Self {
400        Self { solver }
401    }
402
403    /// The underlying solver, for inspection or further queries.
404    pub const fn solver(&mut self) -> &mut Solver {
405        &mut self.solver
406    }
407}
408
409impl Strategy for OptimalBot {
410    fn choose_action(&mut self, view: &View<'_>) -> TurnAction {
411        self.solver
412            .best_action(view.state(), view.dice(), view.rolls_left())
413    }
414
415    fn choose_category(&mut self, view: &View<'_>) -> Category {
416        self.solver.best_category(view.state(), view.dice())
417    }
418
419    fn name(&self) -> &str {
420        "optimal"
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    fn dice(s: &str) -> Dice {
429        s.parse().expect("a valid roll")
430    }
431
432    /// With only Chance open, optimal play keeps 5-6 on the first two
433    /// rolls and 4-6 on the last, worth exactly 70/3 by independence of
434    /// the five dice.
435    #[test]
436    fn chance_alone_is_worth_seventy_thirds() {
437        let mut solver = Solver::new();
438        let state = State::new(!Category::Chance.bit(), 0, false);
439        let value = solver.value(state);
440        assert!((value - 70.0 / 3.0).abs() < 1e-9, "got {value}");
441    }
442
443    /// With only the Yahtzee box open, the optimal single-turn Yahtzee
444    /// probability is 4.6029%, a classic figure.
445    #[test]
446    fn yahtzee_alone_matches_the_classic_probability() {
447        let mut solver = Solver::new();
448        let state = State::new(!Category::Yahtzee.bit(), 0, false);
449        let value = solver.value(state);
450        assert!((value - 50.0 * 0.046_029).abs() < 1e-3, "got {value}");
451    }
452
453    /// The lazy path and a fresh tier-by-tier solve agree bit for bit.
454    #[test]
455    fn lazy_and_tiered_solves_agree() {
456        let scored = !(Category::Yahtzee
457            .bit()
458            .with(Category::Chance)
459            .with(Category::Sixes));
460        let state = State::new(scored, 40, false);
461        let mut lazy = Solver::new();
462        let lazy_value = lazy.value(state);
463        let mut tiered = Solver::new();
464        for filled in (scored.len()..=13).rev() {
465            tiered.solve_tier(filled);
466        }
467        assert!(tiered.is_solved(state));
468        let tiered_value = tiered.value(state);
469        assert_eq!(lazy_value.to_bits(), tiered_value.to_bits());
470    }
471
472    /// Brute force straight from `State::apply`, with no shared tables:
473    /// the strongest guard against index or probability bugs in the
474    /// solver.  Dice fill one at a time (uniform over faces), so the
475    /// probabilities emerge from counting rather than multinomials; the
476    /// memo only tames the blowup and stores nothing the solver computes.
477    fn brute_force_turn(
478        solver: &mut Solver,
479        memo: &mut std::collections::HashMap<(usize, u8), f64>,
480        state: State,
481        rolls_left: u8,
482        counts: [u8; 6],
483    ) -> f64 {
484        let key = counts
485            .iter()
486            .rev()
487            .fold(0, |acc, &c| acc * 6 + usize::from(c));
488        if let Some(&value) = memo.get(&(key, rolls_left)) {
489            return value;
490        }
491        let filled = usize::from(counts.iter().sum::<u8>());
492        let value = if filled < 5 {
493            // Average over the next die of the unordered remainder.
494            (0..6)
495                .map(|f| {
496                    let mut counts = counts;
497                    counts[f] += 1;
498                    brute_force_turn(solver, memo, state, rolls_left, counts)
499                })
500                .sum::<f64>()
501                / 6.0
502        } else {
503            let roll = Dice::from_counts(counts).expect("five dice");
504            let score_now = state
505                .legal_categories(roll)
506                .iter()
507                .map(|c| {
508                    let delta = state.apply(c, roll).expect("legal");
509                    f64::from(delta.reward()) + solver.value(delta.next)
510                })
511                .fold(f64::NEG_INFINITY, f64::max);
512            if rolls_left == 0 {
513                score_now
514            } else {
515                roll.keeps()
516                    .map(|keep| {
517                        brute_force_turn(solver, memo, state, rolls_left - 1, keep.counts())
518                    })
519                    .fold(score_now, f64::max)
520            }
521        };
522        memo.insert((key, rolls_left), value);
523        value
524    }
525
526    /// The widget agrees with the brute-force evaluator on joker-loaded
527    /// endgames (Yahtzee scored 50, scored 0, and open).
528    #[test]
529    fn widget_matches_brute_force() {
530        let lowers_done = Categories::LOWER.with(Category::Aces).with(Category::Twos);
531        let cases = [
532            State::new(!Category::Chance.bit(), 21, true),
533            State::new(!Category::Chance.bit(), 21, false),
534            State::new(lowers_done, 5, true),
535            State::new(lowers_done, 5, false),
536            State::new(!(Category::Yahtzee.bit().with(Category::Fours)), 60, false),
537        ];
538        for state in cases {
539            let mut solver = Solver::new();
540            let mut memo = std::collections::HashMap::new();
541            let expected = brute_force_turn(&mut solver, &mut memo, state, 2, [0; 6]);
542            let value = solver.value(state);
543            assert!(
544                (value - expected).abs() < 1e-9,
545                "{state:?}: widget {value} vs brute force {expected}"
546            );
547        }
548    }
549
550    /// Query sanity on a late-game state: the best action is among the
551    /// legal ones and EV accessors agree with it.
552    #[test]
553    fn queries_are_consistent() {
554        let mut solver = Solver::new();
555        let state = State::new(!(Category::Chance.bit().with(Category::Fives)), 30, false);
556        let roll = dice("35556");
557        let action = solver.best_action(state, roll, 2);
558        match action {
559            TurnAction::Score(category) => {
560                assert!(state.legal_categories(roll).contains(category));
561            }
562            TurnAction::Reroll(keep) => {
563                let best = solver.best_category(state, roll);
564                let ev = solver.keep_ev(state, roll, keep, 2).expect("legal keep");
565                let stand = solver.category_ev(state, roll, best).expect("legal");
566                assert!(ev >= stand);
567            }
568        }
569        assert_eq!(solver.keep_ev(state, roll, Keep::EMPTY, 0), None);
570        assert_eq!(
571            solver.keep_ev(state, roll, "11".parse().expect("keep"), 1),
572            None
573        );
574        // A full card has nothing to reroll for.
575        let full = State::new(Categories::ALL, 63, true);
576        assert_eq!(solver.keep_ev(full, roll, Keep::EMPTY, 2), None);
577    }
578
579    /// Solving a tier before its successors must fail loudly, not
580    /// poison the table with non-finite values.
581    #[test]
582    #[should_panic]
583    fn out_of_order_tiers_are_rejected() {
584        Solver::new().solve_tier(11);
585    }
586}