import json import random import pytest from wizard_game import ( DECK_SIZE, Bid, GameFinished, GameOptions, IllegalActionError, Observation, Phase, PlayCard, RoundCompleted, TrickCompleted, TrickResult, WizardGame, action_from_dict, card_by_id, ) from wizard_game.cards import WIZARD_ID_OFFSET def play_random_game(num_players: int, seed: int, options: GameOptions | None = None) -> WizardGame: game = WizardGame(num_players, options, seed=seed) rng = random.Random(seed) rounds_completed = 0 while not game.is_finished: player = game.current_player actions = game.legal_actions() assert actions, "current player must always have a legal action" events = game.apply(player, rng.choice(actions)) rounds_completed += sum(isinstance(e, RoundCompleted) for e in events) assert rounds_completed == game.total_rounds return game @pytest.mark.parametrize("num_players", [3, 4, 5, 6]) @pytest.mark.parametrize("seed", range(5)) def test_random_games_complete(num_players, seed): options = GameOptions(hidden_bids=seed % 2 == 0) game = play_random_game(num_players, seed, options) assert game.phase is Phase.FINISHED assert len(game.round_history) == game.total_rounds assert game.scores == list(game.round_history[-1].scores) for result in game.round_history: assert sum(result.tricks) == result.round_number assert sum(result.bids) != result.round_number def test_invalid_player_count(): with pytest.raises(ValueError): WizardGame(2) with pytest.raises(ValueError): WizardGame(7) def test_deal_sizes_and_starter(): game = WizardGame(4, seed=3, first_dealer=2) assert game.round_number == 1 assert all(len(game.hand(p)) == 1 for p in range(4)) assert game.phase is Phase.BIDDING assert game.current_player == 3 def test_wrong_player_rejected(): game = WizardGame(3, seed=1, first_dealer=0) other = (game.current_player + 1) % 3 with pytest.raises(IllegalActionError): game.apply(other, game.legal_actions()[0]) def test_illegal_bid_rejected(): game = WizardGame(3, seed=1, first_dealer=0) with pytest.raises(IllegalActionError): game.apply(game.current_player, Bid(5)) @pytest.mark.parametrize("num_players", [3, 4, 5, 6]) def test_trump_card_is_taken_from_the_stack(num_players): """The trump card is dealt to nobody and every card exists only once.""" game = WizardGame(num_players, seed=num_players) rng = random.Random(num_players) rounds_seen = 0 while not game.is_finished: if game.phase is Phase.BIDDING and all(bid is None for bid in game.round.bids): rounds_seen += 1 hands = [card for player in range(num_players) for card in game.hand(player)] assert len(hands) == len(set(hands)) == game.round_number * num_players trump = game.round.trump_card if game.round_number * num_players < DECK_SIZE: assert trump is not None and trump not in hands else: assert trump is None # last round: every card was dealt game.apply(game.current_player, rng.choice(game.legal_actions())) assert rounds_seen == game.total_rounds def find_game(predicate, num_players=4): for seed in range(500): game = WizardGame(num_players, seed=seed, first_dealer=1) if predicate(game.round.trump_card): return game pytest.fail("no seed produced the wanted trump card") def test_number_trump_card_defines_trump_suit(): game = find_game(lambda card: card is not None and card.is_number) assert game.round.trump_suit is game.round.trump_card.suit assert game.observation(0).trump_suit is game.round.trump_card.suit def test_completed_trick_keeps_its_rounds_trump_card(): game = WizardGame(3, seed=4) rng = random.Random(4) for _ in range(200): trump = game.round.trump_card events = game.apply(game.current_player, rng.choice(game.legal_actions())) tricks = [e.trick for e in events if isinstance(e, TrickCompleted)] for trick in tricks: assert trick.trump_card == trump assert TrickResult.from_dict(trick.to_dict()) == trick @pytest.mark.parametrize("kind", ["wizard", "jester"]) def test_special_trump_card_means_no_trump(kind): game = find_game(lambda card: card is not None and card.kind.value == kind) obs = game.observation(0) assert obs.trump_card == game.round.trump_card assert obs.trump_suit is None # Bidding starts right away with the player left of the dealer. assert game.phase is Phase.BIDDING assert game.current_player == 2 def test_sum_of_bids_never_equals_cards_in_hand(): game = WizardGame(3, seed=11, first_dealer=0) game.apply(1, Bid(0)) game.apply(2, Bid(0)) assert game.legal_actions(0) == [Bid(0)] # bidding 1 would make the sum equal 1 def test_hidden_bids(): game = WizardGame(3, GameOptions(hidden_bids=True), seed=5, first_dealer=0) game.apply(1, Bid(1)) assert game.observation(1).bids[1] == 1 assert game.observation(2).bids[1] is None assert game.observation(2).has_bid[1] is True game.apply(2, Bid(0)) game.apply(0, Bid(1)) # 0 is not allowed: the bids would add up to the 1 card in hand assert game.observation(2).bids == (1, 1, 0) def test_last_round_has_no_trump(): game = play_random_game(3, seed=7) assert game.round.round_number == 20 assert game.round.trump_card is None def test_observation_hides_other_hands_and_round_trips(): game = WizardGame(5, seed=9) obs = game.observation(0) assert obs.hand == tuple(sorted(game.hand(0), key=lambda c: c.id)) data = json.loads(json.dumps(obs.to_dict())) assert Observation.from_dict(data) == obs def test_action_round_trip(): for action in (Bid(3), PlayCard(card_by_id(WIZARD_ID_OFFSET))): assert action_from_dict(action.to_dict()) == action with pytest.raises(ValueError): action_from_dict({"type": "trump", "suit": "red"}) with pytest.raises(ValueError): action_from_dict({"type": "bid", "value": True}) with pytest.raises(ValueError): action_from_dict({"type": "play", "card": 99}) def test_game_finished_event_and_winners(): game = WizardGame(3, seed=2) rng = random.Random(2) last_events = [] while not game.is_finished: last_events = game.apply(game.current_player, rng.choice(game.legal_actions())) finished = [e for e in last_events if isinstance(e, GameFinished)] assert finished and list(finished[0].winners) == game.winners() assert game.observation(0).winners == tuple(game.winners()) assert game.legal_actions() == [] def test_clone_is_independent(): game = WizardGame(4, seed=4) clone = game.clone() clone.apply(clone.current_player, clone.legal_actions()[0]) assert game.observation(0) != clone.observation(0) or game.current_player != clone.current_player