use crate::{Categories, Category, Dice, Keep, Strategy, TurnAction, View};
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub struct HeuristicConfig {
pub par: [f32; 13],
}
impl Default for HeuristicConfig {
fn default() -> Self {
Self {
par: [
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,
],
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct HeuristicBot {
config: HeuristicConfig,
}
impl HeuristicBot {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub const fn config(mut self, config: HeuristicConfig) -> Self {
self.config = config;
self
}
}
fn best_face(dice: Dice) -> u8 {
(1..=6).rev().max_by_key(|&f| dice.count(f)).expect("faces")
}
fn longest_run(dice: Dice) -> core::ops::RangeInclusive<u8> {
let (mut best, mut len) = (6, 0u8);
let mut streak = 0u8;
for face in 1..=6 {
streak = if dice.count(face) > 0 { streak + 1 } else { 0 };
if streak >= len {
(best, len) = (face, streak);
}
}
best + 1 - len..=best
}
fn made_a_monster(view: &View<'_>) -> bool {
let dice = view.dice();
let open = !view.state().scored();
dice.yahtzee_face().is_some()
|| (Category::LargeStraight.score(dice) > 0 && open.contains(Category::LargeStraight))
|| (Category::SmallStraight.score(dice) > 0
&& open.contains(Category::SmallStraight)
&& !open.contains(Category::LargeStraight))
|| (Category::FullHouse.score(dice) > 0 && open.contains(Category::FullHouse))
}
fn pick_keep(view: &View<'_>) -> Keep {
let dice = view.dice();
let open = !view.state().scored();
let face = best_face(dice);
let count = dice.count(face);
let set_targets = Category::upper(face)
.map_or(Categories::EMPTY, Category::bit)
.with(Category::ThreeOfAKind)
.with(Category::FourOfAKind)
.with(Category::Yahtzee);
if count >= 3 && !(open & set_targets).is_empty() {
return keep_of_face(face, count);
}
let straights = Category::SmallStraight.bit().with(Category::LargeStraight);
let run = longest_run(dice);
if run.clone().count() >= 4 && !(open & straights).is_empty() {
let mut counts = [0u8; 6];
for f in run {
counts[usize::from(f - 1)] = 1;
}
return Keep::from_counts(counts).expect("at most five run faces");
}
if count == 2
&& let Some(upper) = Category::upper(face)
&& open.contains(upper)
{
return keep_of_face(face, count);
}
if open.contains(Category::Chance) {
let threshold = if view.rolls_left() >= 2 { 5 } else { 4 };
let mut counts = [0u8; 6];
for f in threshold..=6 {
counts[usize::from(f - 1)] = dice.count(f);
}
return Keep::from_counts(counts).expect("a subset of the roll");
}
keep_of_face(face, count)
}
fn keep_of_face(face: u8, count: u8) -> Keep {
let mut counts = [0u8; 6];
counts[usize::from(face - 1)] = count;
Keep::from_counts(counts).expect("at most five dice of one face")
}
impl Strategy for HeuristicBot {
fn choose_action(&mut self, view: &View<'_>) -> TurnAction {
if made_a_monster(view) {
return TurnAction::Score(self.choose_category(view));
}
let keep = pick_keep(view);
if keep.len() == 5 {
TurnAction::Score(self.choose_category(view))
} else {
TurnAction::Reroll(keep)
}
}
fn choose_category(&mut self, view: &View<'_>) -> Category {
let state = view.state();
let dice = view.dice();
state
.legal_categories(dice)
.iter()
.max_by(|&a, &b| {
let margin = |c: Category| {
let reward = state.apply(c, dice).expect("legal").reward();
f32::from(reward) - self.config.par[c as usize]
};
margin(a).total_cmp(&margin(b))
})
.expect("legal categories are non-empty until the card is full")
}
fn name(&self) -> &str {
"heuristic"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Game, Phase};
fn game_with(dice: &str) -> Game {
let mut game = Game::new(1);
game.roll(dice.parse().expect("a valid roll"))
.expect("first roll");
game
}
#[test]
fn banks_a_made_large_straight() {
let game = game_with("23456");
let action = HeuristicBot::new().choose_action(&game.view());
assert_eq!(action, TurnAction::Score(Category::LargeStraight));
}
#[test]
fn keeps_a_matched_set() {
let game = game_with("44412");
let action = HeuristicBot::new().choose_action(&game.view());
assert_eq!(action, TurnAction::Reroll("444".parse().expect("a keep")));
}
#[test]
fn chases_a_straight() {
let game = game_with("12346");
let action = HeuristicBot::new().choose_action(&game.view());
assert_eq!(action, TurnAction::Reroll("1234".parse().expect("a keep")));
}
#[test]
fn plays_a_full_game() {
let mut bot = HeuristicBot::new();
let mut game = Game::new(1);
let mut faces = (1..=6u8).cycle();
while !game.is_over() {
match game.phase() {
Phase::AwaitingRoll => {
let mut counts = game.kept().counts();
for _ in game.kept().len()..5 {
counts[usize::from(faces.next().expect("cycle") - 1)] += 1;
}
let dice = crate::Dice::from_counts(counts).expect("five dice");
game.roll(dice).expect("a legal roll");
}
Phase::AwaitingAction => {
let action = if game.rolls_left() > 0 {
bot.choose_action(&game.view())
} else {
TurnAction::Score(bot.choose_category(&game.view()))
};
game.act(action).expect("a legal action");
}
Phase::Finished => unreachable!(),
}
}
assert!(game.scorecard(0).is_full());
}
}