yahtzee_engine/
heuristic.rs1use crate::{Categories, Category, Dice, Keep, Strategy, TurnAction, View};
4
5#[derive(Debug, Clone, Copy, PartialEq)]
7#[non_exhaustive]
8pub struct HeuristicConfig {
9 pub par: [f32; 13],
14}
15
16impl Default for HeuristicConfig {
17 fn default() -> Self {
19 Self {
20 par: [
21 1.9, 3.8, 5.6, 7.5, 9.4, 11.3, 21.4, 12.9, 22.3, 29.1, 32.5, 16.9, 22.0,
23 ],
24 }
25 }
26}
27
28#[derive(Debug, Clone, Copy, Default)]
37pub struct HeuristicBot {
38 config: HeuristicConfig,
39}
40
41impl HeuristicBot {
42 #[must_use]
44 pub fn new() -> Self {
45 Self::default()
46 }
47
48 #[must_use]
50 pub const fn config(mut self, config: HeuristicConfig) -> Self {
51 self.config = config;
52 self
53 }
54}
55
56fn best_face(dice: Dice) -> u8 {
58 (1..=6).rev().max_by_key(|&f| dice.count(f)).expect("faces")
59}
60
61fn 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
74fn made_a_monster(view: &View<'_>) -> bool {
76 let dice = view.dice();
77 let open = !view.state().scored();
78 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
87fn 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 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 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 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 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 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}