Skip to main content

yahtzee_engine/
heuristic.rs

1//! A fast rule-of-thumb strategy: no tables, microsecond decisions.
2
3use crate::{Categories, Category, Dice, Keep, Strategy, TurnAction, View};
4
5/// Tuning knobs for [`HeuristicBot`].
6#[derive(Debug, Clone, Copy, PartialEq)]
7#[non_exhaustive]
8pub struct HeuristicConfig {
9    /// Par value per category, in card order: what filling that box is
10    /// worth on an average game.  The bot scores the category whose
11    /// points exceed par by the most, so a box scored below par reads as
12    /// a sacrifice and the cheapest one gets zeroed first.
13    pub par: [f32; 13],
14}
15
16impl Default for HeuristicConfig {
17    /// Pars seeded from per-category expectations under optimal play.
18    fn default() -> Self {
19        Self {
20            par: [
21                1.9, 3.8, 5.6, 7.5, 9.4, 11.3, // Aces..Sixes
22                21.4, 12.9, 22.3, 29.1, 32.5, 16.9, 22.0,
23            ],
24        }
25    }
26}
27
28/// A deterministic rule-of-thumb player.
29///
30/// Keeps follow a fixed ladder — bank made hands, chase the biggest
31/// matched set, then straights, then a paired upper face — and category
32/// choice maximizes points over [par](HeuristicConfig::par).  Averages
33/// 216 points over 10 000 seeded games (`examples/arena.rs`) against
34/// the optimal 254.59; use [`OptimalBot`](crate::OptimalBot) when
35/// strength matters more than startup time.
36#[derive(Debug, Clone, Copy, Default)]
37pub struct HeuristicBot {
38    config: HeuristicConfig,
39}
40
41impl HeuristicBot {
42    /// A bot with the default [`HeuristicConfig`].
43    #[must_use]
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    /// Replaces the tuning knobs, builder style.
49    #[must_use]
50    pub const fn config(mut self, config: HeuristicConfig) -> Self {
51        self.config = config;
52        self
53    }
54}
55
56/// The face with the most dice, ties to the higher face.
57fn best_face(dice: Dice) -> u8 {
58    (1..=6).rev().max_by_key(|&f| dice.count(f)).expect("faces")
59}
60
61/// The faces of the longest run, ties to the higher run.
62fn longest_run(dice: Dice) -> core::ops::RangeInclusive<u8> {
63    let (mut best, mut len) = (6, 0u8);
64    let mut streak = 0u8;
65    for face in 1..=6 {
66        streak = if dice.count(face) > 0 { streak + 1 } else { 0 };
67        if streak >= len {
68            (best, len) = (face, streak);
69        }
70    }
71    best + 1 - len..=best
72}
73
74/// Should we stop rolling and bank the hand now?
75fn made_a_monster(view: &View<'_>) -> bool {
76    let dice = view.dice();
77    let open = !view.state().scored();
78    // An extra Yahtzee banks its bonus and joker value immediately.
79    dice.yahtzee_face().is_some()
80        || (Category::LargeStraight.score(dice) > 0 && open.contains(Category::LargeStraight))
81        || (Category::SmallStraight.score(dice) > 0
82            && open.contains(Category::SmallStraight)
83            && !open.contains(Category::LargeStraight))
84        || (Category::FullHouse.score(dice) > 0 && open.contains(Category::FullHouse))
85}
86
87/// The keep ladder: first matching rule wins.
88fn pick_keep(view: &View<'_>) -> Keep {
89    let dice = view.dice();
90    let open = !view.state().scored();
91    let face = best_face(dice);
92    let count = dice.count(face);
93
94    // A matched set with somewhere to put it: keep it all.
95    let set_targets = Category::upper(face)
96        .map_or(Categories::EMPTY, Category::bit)
97        .with(Category::ThreeOfAKind)
98        .with(Category::FourOfAKind)
99        .with(Category::Yahtzee);
100    if count >= 3 && !(open & set_targets).is_empty() {
101        return keep_of_face(face, count);
102    }
103
104    // A budding straight: keep one die of each face in the run.
105    let straights = Category::SmallStraight.bit().with(Category::LargeStraight);
106    let run = longest_run(dice);
107    if run.clone().count() >= 4 && !(open & straights).is_empty() {
108        let mut counts = [0u8; 6];
109        for f in run {
110            counts[usize::from(f - 1)] = 1;
111        }
112        return Keep::from_counts(counts).expect("at most five run faces");
113    }
114
115    // A pair whose upper box is open: keep the pair.
116    if count == 2
117        && let Some(upper) = Category::upper(face)
118        && open.contains(upper)
119    {
120        return keep_of_face(face, count);
121    }
122
123    // Chaff.  When Chance is the box to fill, keep only high dice
124    // (5-6 with two rolls left, 4-6 with one); otherwise chase the
125    // most frequent face.
126    if open.contains(Category::Chance) {
127        let threshold = if view.rolls_left() >= 2 { 5 } else { 4 };
128        let mut counts = [0u8; 6];
129        for f in threshold..=6 {
130            counts[usize::from(f - 1)] = dice.count(f);
131        }
132        return Keep::from_counts(counts).expect("a subset of the roll");
133    }
134    keep_of_face(face, count)
135}
136
137fn keep_of_face(face: u8, count: u8) -> Keep {
138    let mut counts = [0u8; 6];
139    counts[usize::from(face - 1)] = count;
140    Keep::from_counts(counts).expect("at most five dice of one face")
141}
142
143impl Strategy for HeuristicBot {
144    fn choose_action(&mut self, view: &View<'_>) -> TurnAction {
145        if made_a_monster(view) {
146            return TurnAction::Score(self.choose_category(view));
147        }
148        let keep = pick_keep(view);
149        if keep.len() == 5 {
150            TurnAction::Score(self.choose_category(view))
151        } else {
152            TurnAction::Reroll(keep)
153        }
154    }
155
156    fn choose_category(&mut self, view: &View<'_>) -> Category {
157        let state = view.state();
158        let dice = view.dice();
159        state
160            .legal_categories(dice)
161            .iter()
162            .max_by(|&a, &b| {
163                let margin = |c: Category| {
164                    let reward = state.apply(c, dice).expect("legal").reward();
165                    f32::from(reward) - self.config.par[c as usize]
166                };
167                margin(a).total_cmp(&margin(b))
168            })
169            .expect("legal categories are non-empty until the card is full")
170    }
171
172    fn name(&self) -> &str {
173        "heuristic"
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use crate::{Game, Phase};
181
182    fn game_with(dice: &str) -> Game {
183        let mut game = Game::new(1);
184        game.roll(dice.parse().expect("a valid roll"))
185            .expect("first roll");
186        game
187    }
188
189    #[test]
190    fn banks_a_made_large_straight() {
191        let game = game_with("23456");
192        let action = HeuristicBot::new().choose_action(&game.view());
193        assert_eq!(action, TurnAction::Score(Category::LargeStraight));
194    }
195
196    #[test]
197    fn keeps_a_matched_set() {
198        let game = game_with("44412");
199        let action = HeuristicBot::new().choose_action(&game.view());
200        assert_eq!(action, TurnAction::Reroll("444".parse().expect("a keep")));
201    }
202
203    #[test]
204    fn chases_a_straight() {
205        let game = game_with("12346");
206        let action = HeuristicBot::new().choose_action(&game.view());
207        assert_eq!(action, TurnAction::Reroll("1234".parse().expect("a keep")));
208    }
209
210    #[test]
211    fn plays_a_full_game() {
212        let mut bot = HeuristicBot::new();
213        let mut game = Game::new(1);
214        // A fixed die cycle exercises the whole pipeline without rand.
215        let mut faces = (1..=6u8).cycle();
216        while !game.is_over() {
217            match game.phase() {
218                Phase::AwaitingRoll => {
219                    let mut counts = game.kept().counts();
220                    for _ in game.kept().len()..5 {
221                        counts[usize::from(faces.next().expect("cycle") - 1)] += 1;
222                    }
223                    let dice = crate::Dice::from_counts(counts).expect("five dice");
224                    game.roll(dice).expect("a legal roll");
225                }
226                Phase::AwaitingAction => {
227                    let action = if game.rolls_left() > 0 {
228                        bot.choose_action(&game.view())
229                    } else {
230                        TurnAction::Score(bot.choose_category(&game.view()))
231                    };
232                    game.act(action).expect("a legal action");
233                }
234                Phase::Finished => unreachable!(),
235            }
236        }
237        assert!(game.scorecard(0).is_full());
238    }
239}