1use crate::state::max_upper;
5use crate::tables::{DiceTables, KEEPS, ROLLS};
6use crate::{Categories, Category, Dice, Keep, State, Strategy, TurnAction, View};
7
8#[derive(Debug, Clone)]
27pub struct Solver {
28 values: Vec<f64>,
29 tables: DiceTables,
30}
31
32type 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
55fn 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 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
92fn 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
115fn 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
128fn 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 #[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 #[must_use]
166 pub fn is_solved(&self, state: State) -> bool {
167 !self.values[state.index()].is_nan()
168 }
169
170 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 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 pub fn solve(&mut self) {
219 for filled in (0..=13).rev() {
220 self.solve_tier(filled);
221 }
222 }
223
224 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 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 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 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 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 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 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#[derive(Debug, Clone, Default)]
378pub struct OptimalBot {
379 solver: Solver,
380}
381
382impl OptimalBot {
383 #[must_use]
385 pub fn new() -> Self {
386 Self::default()
387 }
388
389 #[must_use]
391 pub fn presolved() -> Self {
392 let mut solver = Solver::new();
393 solver.solve();
394 Self { solver }
395 }
396
397 #[must_use]
399 pub const fn from_solver(solver: Solver) -> Self {
400 Self { solver }
401 }
402
403 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 #[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 #[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 #[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 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 (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 #[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 #[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 let full = State::new(Categories::ALL, 63, true);
576 assert_eq!(solver.keep_ev(full, roll, Keep::EMPTY, 2), None);
577 }
578
579 #[test]
582 #[should_panic]
583 fn out_of_order_tiers_are_rejected() {
584 Solver::new().solve_tier(11);
585 }
586}