yahtzee-engine 0.1.0

Yahtzee rules, scoring, and bots: a fast heuristic and an exact optimal expected-value solver
Documentation
//! A fast rule-of-thumb strategy: no tables, microsecond decisions.

use crate::{Categories, Category, Dice, Keep, Strategy, TurnAction, View};

/// Tuning knobs for [`HeuristicBot`].
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub struct HeuristicConfig {
    /// Par value per category, in card order: what filling that box is
    /// worth on an average game.  The bot scores the category whose
    /// points exceed par by the most, so a box scored below par reads as
    /// a sacrifice and the cheapest one gets zeroed first.
    pub par: [f32; 13],
}

impl Default for HeuristicConfig {
    /// Pars seeded from per-category expectations under optimal play.
    fn default() -> Self {
        Self {
            par: [
                1.9, 3.8, 5.6, 7.5, 9.4, 11.3, // Aces..Sixes
                21.4, 12.9, 22.3, 29.1, 32.5, 16.9, 22.0,
            ],
        }
    }
}

/// A deterministic rule-of-thumb player.
///
/// Keeps follow a fixed ladder — bank made hands, chase the biggest
/// matched set, then straights, then a paired upper face — and category
/// choice maximizes points over [par](HeuristicConfig::par).  Averages
/// 216 points over 10 000 seeded games (`examples/arena.rs`) against
/// the optimal 254.59; use [`OptimalBot`](crate::OptimalBot) when
/// strength matters more than startup time.
#[derive(Debug, Clone, Copy, Default)]
pub struct HeuristicBot {
    config: HeuristicConfig,
}

impl HeuristicBot {
    /// A bot with the default [`HeuristicConfig`].
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Replaces the tuning knobs, builder style.
    #[must_use]
    pub const fn config(mut self, config: HeuristicConfig) -> Self {
        self.config = config;
        self
    }
}

/// The face with the most dice, ties to the higher face.
fn best_face(dice: Dice) -> u8 {
    (1..=6).rev().max_by_key(|&f| dice.count(f)).expect("faces")
}

/// The faces of the longest run, ties to the higher run.
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
}

/// Should we stop rolling and bank the hand now?
fn made_a_monster(view: &View<'_>) -> bool {
    let dice = view.dice();
    let open = !view.state().scored();
    // An extra Yahtzee banks its bonus and joker value immediately.
    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))
}

/// The keep ladder: first matching rule wins.
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);

    // A matched set with somewhere to put it: keep it all.
    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);
    }

    // A budding straight: keep one die of each face in the run.
    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");
    }

    // A pair whose upper box is open: keep the pair.
    if count == 2
        && let Some(upper) = Category::upper(face)
        && open.contains(upper)
    {
        return keep_of_face(face, count);
    }

    // Chaff.  When Chance is the box to fill, keep only high dice
    // (5-6 with two rolls left, 4-6 with one); otherwise chase the
    // most frequent face.
    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);
        // A fixed die cycle exercises the whole pipeline without rand.
        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());
    }
}