yahtzee-engine 0.1.0

Yahtzee rules, scoring, and bots: a fast heuristic and an exact optimal expected-value solver
Documentation
//! Whole-game properties under randomized legal play.

use proptest::prelude::*;
use yahtzee_engine::{Category, Dice, Game, Keep, Phase, TurnAction};

/// A tiny deterministic generator so proptest shrinks a single seed
/// instead of a tree of dice and decisions.
struct Lcg(u64);

impl Lcg {
    fn next(&mut self) -> u64 {
        self.0 = self
            .0
            .wrapping_mul(6364136223846793005)
            .wrapping_add(1442695040888963407);
        self.0 >> 33
    }

    fn below(&mut self, bound: usize) -> usize {
        self.next() as usize % bound
    }
}

fn random_roll(lcg: &mut Lcg, kept: Keep) -> Dice {
    let mut counts = kept.counts();
    for _ in kept.len()..5 {
        counts[lcg.below(6)] += 1;
    }
    Dice::from_counts(counts).expect("kept dice plus rerolls make five")
}

proptest! {
    /// Any sequence of legal moves fills every card in exactly 13 turns
    /// per player, with totals that re-derive from the boxes and every
    /// write agreeing with the compact state transition.
    #[test]
    fn random_legal_play_reaches_a_consistent_full_card(
        seed in any::<u64>(),
        players in 1usize..=3,
    ) {
        let mut lcg = Lcg(seed);
        let mut game = Game::new(players);
        let mut turns = 0u32;
        while !game.is_over() {
            match game.phase() {
                Phase::AwaitingRoll => {
                    let roll = random_roll(&mut lcg, game.kept());
                    game.roll(roll).expect("a roll is due");
                }
                Phase::AwaitingAction => {
                    let view = game.view();
                    let dice = view.dice();
                    if view.rolls_left() > 0 && lcg.next().is_multiple_of(2) {
                        let keeps: Vec<Keep> = dice.keeps().collect();
                        let keep = keeps[lcg.below(keeps.len())];
                        game.act(TurnAction::Reroll(keep)).expect("keep is legal");
                        continue;
                    }
                    let legal: Vec<Category> = view.legal_categories().iter().collect();
                    prop_assert!(!legal.is_empty());
                    let category = legal[lcg.below(legal.len())];
                    let seat = view.seat();
                    let expected = view.state().apply(category, dice).expect("legal");
                    let delta = game
                        .act(TurnAction::Score(category))
                        .expect("category is legal")
                        .expect("scoring returns a delta");
                    prop_assert_eq!(delta, expected);
                    // The card's projection tracks the incremental state.
                    prop_assert_eq!(game.scorecard(seat).state(), delta.next);
                    turns += 1;
                }
                Phase::Finished => unreachable!("checked by the loop"),
            }
        }
        prop_assert_eq!(turns as usize, 13 * players);
        for card in game.scorecards() {
            prop_assert!(card.is_full());
            let boxes: u16 = Category::ALL
                .iter()
                .map(|&c| u16::from(card.get(c).expect("full card")))
                .sum();
            let rebuilt = boxes
                + u16::from(card.upper_bonus())
                + 100 * u16::from(card.yahtzee_bonuses());
            prop_assert_eq!(card.total(), rebuilt);
        }
    }

    /// The bundled bot and driver play whole games without a single
    /// rules rejection.
    #[cfg(feature = "rand")]
    #[test]
    fn the_heuristic_bot_plays_legal_games(seed in any::<u64>()) {
        use rand::SeedableRng;
        use yahtzee_engine::{HeuristicBot, Strategy, play_game};

        let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
        let mut game = Game::new(2);
        let mut bots = [HeuristicBot::new(), HeuristicBot::new()];
        let [a, b] = &mut bots;
        let mut strategies: [&mut dyn Strategy; 2] = [a, b];
        let totals = play_game(&mut game, &mut strategies, &mut rng)
            .expect("the bot never proposes an illegal action");
        prop_assert_eq!(totals.len(), 2);
        prop_assert!(game.scorecards().iter().all(|card| card.is_full()));
    }
}