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

288 lines
10 KiB
Python

"""The Wizard game state machine.
``WizardGame`` implements the complete rules of Wizard and has no
dependencies besides the Python standard library, so it can be used by the
game server as well as for (reinforcement learning) training of AI players.
Typical usage::
game = WizardGame(num_players=4, seed=42)
while not game.is_finished:
player = game.current_player
action = choose(game.observation(player)) # any Action from legal_actions
events = game.apply(player, action)
"""
from __future__ import annotations
import copy
import random
from . import rules
from .actions import Action, Bid, PlayCard
from .cards import Card, new_shuffled_deck
from .observation import Observation
from .state import (
Event,
GameFinished,
GameOptions,
Phase,
PlayedCard,
RoundCompleted,
RoundResult,
RoundStarted,
RoundState,
TrickCompleted,
TrickResult,
)
class IllegalActionError(ValueError):
"""Raised when a player attempts an action the rules do not allow."""
class WizardGame:
def __init__(
self,
num_players: int,
options: GameOptions | None = None,
*,
seed: int | None = None,
rng: random.Random | None = None,
first_dealer: int | None = None,
) -> None:
rules.validate_player_count(num_players)
self.num_players = num_players
self.options = options or GameOptions()
self.total_rounds = rules.total_rounds(num_players)
self._rng = rng or random.Random(seed)
self.scores: list[int] = [0] * num_players
self.round_history: list[RoundResult] = []
self.last_trick: TrickResult | None = None
self.phase: Phase = Phase.BIDDING
self.current_player: int | None = None
if first_dealer is None:
first_dealer = self._rng.randrange(num_players)
elif not 0 <= first_dealer < num_players:
raise ValueError(f"Invalid first dealer: {first_dealer}")
self.round: RoundState = self._deal(1, first_dealer)
# --- Public API ---------------------------------------------------------
@property
def is_finished(self) -> bool:
return self.phase is Phase.FINISHED
@property
def round_number(self) -> int:
return self.round.round_number
@property
def dealer(self) -> int:
return self.round.dealer
def hand(self, player: int) -> list[Card]:
return list(self.round.hands[player])
def winners(self) -> list[int]:
"""Players with the highest score (only meaningful once finished)."""
best = max(self.scores)
return [p for p, score in enumerate(self.scores) if score == best]
def legal_actions(self, player: int | None = None) -> list[Action]:
"""Legal actions of ``player`` (defaults to the current player)."""
if player is None:
player = self.current_player
if player is None or player != self.current_player:
return []
if self.phase is Phase.BIDDING:
return [Bid(value) for value in self._allowed_bids(player)]
if self.phase is Phase.PLAYING:
trick = [played.card for played in self.round.current_trick]
return [PlayCard(card) for card in rules.legal_cards(self.round.hands[player], trick)]
return []
def is_legal(self, player: int, action: Action) -> bool:
return action in self.legal_actions(player)
def apply(self, player: int, action: Action) -> list[Event]:
"""Perform ``action`` for ``player`` and advance the game.
Returns the events caused by the action. Raises
``IllegalActionError`` if the action is not allowed.
"""
if self.phase is Phase.FINISHED:
raise IllegalActionError("The game is already finished")
if player != self.current_player:
raise IllegalActionError(f"It is not player {player}'s turn")
if not self.is_legal(player, action):
raise IllegalActionError(f"Illegal action {action} for player {player}")
if isinstance(action, Bid):
return self._bid(player, action)
if isinstance(action, PlayCard):
return self._play(player, action)
raise IllegalActionError(f"Unknown action {action!r}") # pragma: no cover
def observation(self, player: int) -> Observation:
"""Everything ``player`` is allowed to know about the game."""
rnd = self.round
all_bids_placed = all(bid is not None for bid in rnd.bids)
reveal_bids = not self.options.hidden_bids or all_bids_placed
bids = tuple(
bid if reveal_bids or p == player else None
for p, bid in enumerate(rnd.bids)
)
trick = [played.card for played in rnd.current_trick]
return Observation(
player=player,
num_players=self.num_players,
options=self.options,
phase=self.phase,
round_number=rnd.round_number,
total_rounds=self.total_rounds,
dealer=rnd.dealer,
current_player=self.current_player,
hand=tuple(sorted(rnd.hands[player], key=lambda card: card.id)),
hand_sizes=tuple(len(hand) for hand in rnd.hands),
trump_card=rnd.trump_card,
trump_suit=rnd.trump_suit,
bids=bids,
has_bid=tuple(bid is not None for bid in rnd.bids),
tricks_won=tuple(rnd.tricks_won),
scores=tuple(self.scores),
trick_number=rnd.trick_number,
trick_leader=rnd.trick_leader,
current_trick=tuple(rnd.current_trick),
lead_suit=rules.lead_suit(trick),
played_cards=tuple(rnd.played_cards),
last_trick=self.last_trick,
round_history=tuple(self.round_history),
legal_actions=tuple(self.legal_actions(player)),
winners=tuple(self.winners()) if self.is_finished else (),
)
def clone(self) -> WizardGame:
"""Deep copy of the game, e.g. for search based AI players."""
return copy.deepcopy(self)
# --- Internals ----------------------------------------------------------
def _next(self, player: int) -> int:
return (player + 1) % self.num_players
def _deal(self, round_number: int, dealer: int) -> RoundState:
stack = new_shuffled_deck(self._rng)
hands: list[list[Card]] = [[] for _ in range(self.num_players)]
# Deal one card at a time from the top of the stack, starting left of
# the dealer.
for i in range(round_number * self.num_players):
hands[(dealer + 1 + i) % self.num_players].append(stack.pop())
# The next card of the stack is turned up as trump card. It is taken
# off the stack and belongs to nobody's hand. In the last round the
# stack is empty, so there is no trump card.
trump_card = stack.pop() if stack else None
starter = self._next(dealer)
self.phase = Phase.BIDDING
self.current_player = starter
return RoundState(
round_number=round_number,
dealer=dealer,
hands=hands,
trump_card=trump_card,
trump_suit=rules.trump_suit(trump_card),
bids=[None] * self.num_players,
tricks_won=[0] * self.num_players,
trick_leader=starter,
)
def _allowed_bids(self, player: int) -> list[int]:
"""Bids 0..cards in hand, except that the last bidder may not make the
sum of all bids equal the number of cards each player holds this round.
"""
rnd = self.round
allowed = list(range(rnd.round_number + 1))
placed = [bid for bid in rnd.bids if bid is not None]
is_last_bidder = len(placed) == self.num_players - 1 and rnd.bids[player] is None
if is_last_bidder:
forbidden = rnd.round_number - sum(placed)
allowed = [bid for bid in allowed if bid != forbidden]
return allowed
def _bid(self, player: int, action: Bid) -> list[Event]:
rnd = self.round
rnd.bids[player] = action.value
if all(bid is not None for bid in rnd.bids):
self.phase = Phase.PLAYING
self.current_player = rnd.trick_leader
else:
self.current_player = self._next(player)
return []
def _play(self, player: int, action: PlayCard) -> list[Event]:
rnd = self.round
rnd.hands[player].remove(action.card)
rnd.current_trick.append(PlayedCard(player, action.card))
rnd.played_cards.append(action.card)
if len(rnd.current_trick) < self.num_players:
self.current_player = self._next(player)
return []
return self._complete_trick()
def _complete_trick(self) -> list[Event]:
rnd = self.round
cards = [played.card for played in rnd.current_trick]
winner = rnd.current_trick[rules.trick_winner(cards, rnd.trump_suit)].player
rnd.tricks_won[winner] += 1
result = TrickResult(
rnd.round_number, rnd.trick_number, tuple(rnd.current_trick), winner, rnd.trump_card
)
self.last_trick = result
events: list[Event] = [TrickCompleted(result)]
rnd.current_trick = []
if rnd.trick_number < rnd.round_number:
rnd.trick_number += 1
rnd.trick_leader = winner
self.current_player = winner
return events
events.extend(self._complete_round())
return events
def _complete_round(self) -> list[Event]:
rnd = self.round
bids = [bid if bid is not None else 0 for bid in rnd.bids]
changes = [rules.round_score(bid, tricks) for bid, tricks in zip(bids, rnd.tricks_won)]
self.scores = [score + change for score, change in zip(self.scores, changes)]
result = RoundResult(
round_number=rnd.round_number,
bids=tuple(bids),
tricks=tuple(rnd.tricks_won),
score_changes=tuple(changes),
scores=tuple(self.scores),
)
self.round_history.append(result)
events: list[Event] = [RoundCompleted(result)]
if rnd.round_number >= self.total_rounds:
self.phase = Phase.FINISHED
self.current_player = None
events.append(GameFinished(tuple(self.scores), tuple(self.winners())))
return events
self.round = self._deal(rnd.round_number + 1, self._next(rnd.dealer))
events.append(RoundStarted(self.round.round_number, self.round.dealer, self.round.trump_card))
return events