Files
marvinandClaude Opus 5.5 21a2b044d9 Trump card: draw from the stack, no trump for wizards and jesters
- 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>
2026-09-27 10:28:53 +02:00

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