- Deal the hands from the shuffled stack and draw the trump card from it, so the trump card is in nobody's hand and cannot be played - A wizard or jester as trump card means no trump suit (previously a wizard let the dealer choose); remove the trump selection phase, the ChooseTrump action, the trump picker UI and the AI trump actions - Completed tricks carry their round's trump card, so the table keeps showing the matching trump card while the last trick of a round is displayed - Card corners are no longer rotated, so 6 and 9 cannot be confused - Trump label explains why there is no trump (wizard, jester, last round) - Tests: trump card never dealt, special cards give no trump, tricks keep their trump card Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
197 lines
6.9 KiB
Python
197 lines
6.9 KiB
Python
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
|