go-fish 0.2.0

A core engine for the classic Go Fish card game, providing game logic, deck management, and player interactions.
Documentation
mod drivers;

use go_fish::*;
use go_fish::bots::{Bot, BotObservation, OpponentView, SimpleBot};
use proptest::prelude::*;
use rstest::rstest;

#[rstest]
fn completes_a_game(#[values(2, 3, 4, 5, 6)] players: u8) {
    let max_turns = 10_000;
    let deck = Deck::new();
    let mut game = Game::new(deck, players);

    for _ in 1..max_turns {
        let hook = drivers::fish_random_rank_and_player(&game);
        game.take_turn(hook).expect("Game state should be valid");

        if game.players.len() <= 1 {
            break;
        }
    }

    assert!(
        game.players.len() <= 1,
        "Game with {} players did not complete in {} turns",
        players,
        max_turns
    );
}

proptest! {
    #![proptest_config(ProptestConfig::with_cases(20))]
    #[test]
    fn prop_simple_bot_game_always_terminates(
        player_count in 2u8..=6u8,
        seeds in proptest::collection::vec(any::<u64>(), 6),
        memory_limits in proptest::collection::vec(0u8..=10u8, 6),
        error_margins in proptest::collection::vec(0.0f32..=1.0f32, 6),
    ) {
        let max_turns = 10_000;
        let deck = Deck::new();
        let mut game = Game::new(deck, player_count);

        let mut bots: Vec<SimpleBot> = (0..player_count as usize)
            .map(|i| SimpleBot::new(
                PlayerId::new(i as u8),
                memory_limits[i],
                error_margins[i],
                seeds[i],
            ))
            .collect();

        let mut last_outcome: Option<HookOutcome> = None;

        for _ in 0..max_turns {
            let current_player = match game.get_current_player() {
                Some(p) => p.clone(),
                None => break,
            };

            let active_id = current_player.id;
            let deck_size = game.deck.cards.len();

            for bot in bots.iter_mut() {
                let bot_player = game.players.iter().find(|p| p.id == bot.my_id());
                let (my_hand, my_completed_books) = match bot_player {
                    Some(p) => (p.hand.books.clone(), p.completed_books.clone()),
                    None => (vec![], vec![]),
                };
                let opponents: Vec<OpponentView> = game.players.iter()
                    .filter(|p| p.id != bot.my_id())
                    .map(|p| OpponentView {
                        id: p.id,
                        hand_size: p.hand.books.iter().map(|b| b.cards.len()).sum(),
                        completed_books: p.completed_books.clone(),
                    })
                    .collect();
                bot.observe(BotObservation {
                    my_hand,
                    my_completed_books,
                    opponents,
                    deck_size,
                    active_player_id: active_id,
                    last_hook_outcome: last_outcome.clone(),
                });
            }

            let active_bot = bots.iter_mut().find(|b| b.my_id() == active_id).unwrap();
            let valid_targets: Vec<PlayerId> = game.players.iter()
                .filter(|p| p.id != active_id)
                .map(|p| p.id)
                .collect();

            let hook = active_bot.generate_hook(&valid_targets);
            let outcome = game.take_turn(hook).expect("Bot generated an invalid hook");
            last_outcome = Some(outcome);

            if game.players.len() <= 1 {
                break;
            }
        }

        prop_assert!(
            game.players.len() <= 1,
            "Bot game with {} players did not complete in {} turns",
            player_count,
            max_turns
        );
        prop_assert!(game.get_game_result().is_some(), "Game should have a result");
    }
}