first prototype

This commit is contained in:
2026-09-27 09:52:00 +02:00
parent 4635199a41
commit 6d011d474f
109 changed files with 6354 additions and 507 deletions
@@ -0,0 +1,55 @@
"""Rules engine of the Wizard card game.
This package is independent of the game server so that it can also be used
to train AI players.
"""
from .actions import Action, Bid, ChooseTrump, PlayCard, action_from_dict, action_to_dict
from .cards import ALL_CARDS, DECK_SIZE, SUITS, Card, CardKind, Suit, card_by_id
from .game import IllegalActionError, WizardGame
from .observation import Observation
from .rules import MAX_PLAYERS, MIN_PLAYERS, total_rounds
from .state import (
Event,
GameFinished,
GameOptions,
Phase,
PlayedCard,
RoundCompleted,
RoundResult,
RoundStarted,
TrickCompleted,
TrickResult,
)
__all__ = [
"ALL_CARDS",
"DECK_SIZE",
"MAX_PLAYERS",
"MIN_PLAYERS",
"SUITS",
"Action",
"Bid",
"Card",
"CardKind",
"ChooseTrump",
"Event",
"GameFinished",
"GameOptions",
"IllegalActionError",
"Observation",
"Phase",
"PlayCard",
"PlayedCard",
"RoundCompleted",
"RoundResult",
"RoundStarted",
"Suit",
"TrickCompleted",
"TrickResult",
"WizardGame",
"action_from_dict",
"action_to_dict",
"card_by_id",
"total_rounds",
]
@@ -0,0 +1,73 @@
"""Actions a player can take and their (de)serialization.
Actions are serialized as small JSON objects which are shared by the web
frontend, the game server and the AI player:
- ``{"type": "bid", "value": 2}``
- ``{"type": "play", "card": 17}``
- ``{"type": "trump", "suit": "red"}``
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from .cards import Card, Suit, card_by_id
@dataclass(frozen=True, slots=True)
class Bid:
value: int
def to_dict(self) -> dict:
return {"type": "bid", "value": self.value}
@dataclass(frozen=True, slots=True)
class PlayCard:
card: Card
def to_dict(self) -> dict:
return {"type": "play", "card": self.card.id}
@dataclass(frozen=True, slots=True)
class ChooseTrump:
suit: Suit
def to_dict(self) -> dict:
return {"type": "trump", "suit": self.suit.value}
Action = Bid | PlayCard | ChooseTrump
def action_to_dict(action: Action) -> dict:
return action.to_dict()
def action_from_dict(data: dict[str, Any]) -> Action:
"""Parse an action from its JSON representation.
Raises ``ValueError`` if the data is malformed.
"""
if not isinstance(data, dict):
raise ValueError("Action must be an object")
kind = data.get("type")
try:
if kind == "bid":
value = data["value"]
if not isinstance(value, int) or isinstance(value, bool):
raise ValueError("Bid value must be an integer")
return Bid(value)
if kind == "play":
card_id = data["card"]
if not isinstance(card_id, int) or isinstance(card_id, bool):
raise ValueError("Card must be an integer id")
return PlayCard(card_by_id(card_id))
if kind == "trump":
return ChooseTrump(Suit(data["suit"]))
except KeyError as exc:
raise ValueError(f"Missing field {exc} in action") from exc
raise ValueError(f"Unknown action type: {kind!r}")
@@ -0,0 +1,115 @@
"""Cards and deck of the Wizard card game.
The deck consists of 60 cards:
- 52 number cards: four suits (red, yellow, green, blue) with ranks 1-13
- 4 wizards (highest cards)
- 4 jesters (lowest cards)
Every card has a stable integer id in the range 0-59 which is used for
serialization and as an index for machine learning encodings:
- 0-51: number cards, ``suit_index * 13 + (rank - 1)``
- 52-55: wizards
- 56-59: jesters
"""
from __future__ import annotations
import random
from dataclasses import dataclass
from enum import Enum
class Suit(str, Enum):
RED = "red"
YELLOW = "yellow"
GREEN = "green"
BLUE = "blue"
@property
def index(self) -> int:
return SUITS.index(self)
SUITS: tuple[Suit, ...] = (Suit.RED, Suit.YELLOW, Suit.GREEN, Suit.BLUE)
class CardKind(str, Enum):
NUMBER = "number"
WIZARD = "wizard"
JESTER = "jester"
NUM_RANKS = 13
NUM_NUMBER_CARDS = len(SUITS) * NUM_RANKS
NUM_WIZARDS = 4
NUM_JESTERS = 4
DECK_SIZE = NUM_NUMBER_CARDS + NUM_WIZARDS + NUM_JESTERS
WIZARD_ID_OFFSET = NUM_NUMBER_CARDS
JESTER_ID_OFFSET = NUM_NUMBER_CARDS + NUM_WIZARDS
@dataclass(frozen=True, slots=True)
class Card:
id: int
kind: CardKind
suit: Suit | None = None
rank: int = 0
@property
def is_wizard(self) -> bool:
return self.kind is CardKind.WIZARD
@property
def is_jester(self) -> bool:
return self.kind is CardKind.JESTER
@property
def is_number(self) -> bool:
return self.kind is CardKind.NUMBER
def to_dict(self) -> dict:
return {
"id": self.id,
"kind": self.kind.value,
"suit": self.suit.value if self.suit else None,
"rank": self.rank,
}
def __str__(self) -> str:
if self.is_wizard:
return f"Wizard{self.id - WIZARD_ID_OFFSET + 1}"
if self.is_jester:
return f"Jester{self.id - JESTER_ID_OFFSET + 1}"
assert self.suit is not None
return f"{self.suit.value.capitalize()}{self.rank}"
def __repr__(self) -> str:
return f"Card({self})"
def _build_deck() -> tuple[Card, ...]:
cards: list[Card] = []
for suit in SUITS:
for rank in range(1, NUM_RANKS + 1):
cards.append(Card(len(cards), CardKind.NUMBER, suit, rank))
for _ in range(NUM_WIZARDS):
cards.append(Card(len(cards), CardKind.WIZARD))
for _ in range(NUM_JESTERS):
cards.append(Card(len(cards), CardKind.JESTER))
return tuple(cards)
ALL_CARDS: tuple[Card, ...] = _build_deck()
def card_by_id(card_id: int) -> Card:
if not 0 <= card_id < DECK_SIZE:
raise ValueError(f"Invalid card id: {card_id}")
return ALL_CARDS[card_id]
def new_shuffled_deck(rng: random.Random) -> list[Card]:
deck = list(ALL_CARDS)
rng.shuffle(deck)
return deck
@@ -0,0 +1,300 @@
"""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, ChooseTrump, PlayCard
from .cards import SUITS, 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.TRUMP_SELECTION:
return [ChooseTrump(suit) for suit in SUITS]
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, ChooseTrump):
return self._choose_trump(action)
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:
deck = new_shuffled_deck(self._rng)
hands: list[list[Card]] = [[] for _ in range(self.num_players)]
# Deal one card at a time starting left of the dealer.
for i in range(round_number * self.num_players):
hands[(dealer + 1 + i) % self.num_players].append(deck[i])
remaining = deck[round_number * self.num_players:]
# The next card defines the trump suit. In the last round no cards
# are left, so there is no trump.
trump_card = remaining[0] if remaining else None
starter = self._next(dealer)
rnd = RoundState(
round_number=round_number,
dealer=dealer,
hands=hands,
trump_card=trump_card,
trump_suit=trump_card.suit if trump_card and trump_card.is_number else None,
bids=[None] * self.num_players,
tricks_won=[0] * self.num_players,
trick_leader=starter,
)
if trump_card is not None and trump_card.is_wizard:
# A wizard as trump card: the dealer chooses the trump suit.
self.phase = Phase.TRUMP_SELECTION
self.current_player = dealer
else:
# No trump card or a jester: the round is played without trump.
self.phase = Phase.BIDDING
self.current_player = starter
return rnd
def _allowed_bids(self, player: int) -> list[int]:
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 self.options.not_equal and is_last_bidder:
forbidden = rnd.round_number - sum(placed)
allowed = [bid for bid in allowed if bid != forbidden]
return allowed
def _choose_trump(self, action: ChooseTrump) -> list[Event]:
self.round.trump_suit = action.suit
self.phase = Phase.BIDDING
self.current_player = self._next(self.round.dealer)
return []
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)
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
@@ -0,0 +1,120 @@
"""The view of the game a single player is allowed to see.
An ``Observation`` contains everything a player knows at a point in time and
nothing more (no opponent hands, hidden bids stay hidden). It is used
- by the server to send the game state to a web client, and
- as the input of AI players, both in training and in production.
``to_dict`` / ``from_dict`` provide a lossless JSON representation.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from .actions import Action, action_from_dict
from .cards import Card, Suit, card_by_id
from .state import GameOptions, Phase, PlayedCard, RoundResult, TrickResult
@dataclass(frozen=True, slots=True)
class Observation:
player: int
num_players: int
options: GameOptions
phase: Phase
round_number: int
total_rounds: int
dealer: int
current_player: int | None
hand: tuple[Card, ...]
hand_sizes: tuple[int, ...]
trump_card: Card | None
trump_suit: Suit | None
# ``None`` for players who have not bid yet, or whose bid is still hidden.
bids: tuple[int | None, ...]
has_bid: tuple[bool, ...]
tricks_won: tuple[int, ...]
scores: tuple[int, ...]
trick_number: int
trick_leader: int
current_trick: tuple[PlayedCard, ...]
lead_suit: Suit | None
# All cards played in the current round, including the current trick.
played_cards: tuple[Card, ...]
last_trick: TrickResult | None
round_history: tuple[RoundResult, ...]
# Empty unless it is this player's turn.
legal_actions: tuple[Action, ...]
winners: tuple[int, ...]
@property
def is_my_turn(self) -> bool:
return self.current_player == self.player
def to_dict(self) -> dict:
return {
"player": self.player,
"num_players": self.num_players,
"options": self.options.to_dict(),
"phase": self.phase.value,
"round_number": self.round_number,
"total_rounds": self.total_rounds,
"dealer": self.dealer,
"current_player": self.current_player,
"hand": [card.to_dict() for card in self.hand],
"hand_sizes": list(self.hand_sizes),
"trump_card": self.trump_card.to_dict() if self.trump_card else None,
"trump_suit": self.trump_suit.value if self.trump_suit else None,
"bids": list(self.bids),
"has_bid": list(self.has_bid),
"tricks_won": list(self.tricks_won),
"scores": list(self.scores),
"trick_number": self.trick_number,
"trick_leader": self.trick_leader,
"current_trick": [played.to_dict() for played in self.current_trick],
"lead_suit": self.lead_suit.value if self.lead_suit else None,
"played_cards": [card.to_dict() for card in self.played_cards],
"last_trick": self.last_trick.to_dict() if self.last_trick else None,
"round_history": [result.to_dict() for result in self.round_history],
"legal_actions": [action.to_dict() for action in self.legal_actions],
"winners": list(self.winners),
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Observation:
def card(value: dict | None) -> Card | None:
return card_by_id(value["id"]) if value else None
def suit(value: str | None) -> Suit | None:
return Suit(value) if value else None
return cls(
player=data["player"],
num_players=data["num_players"],
options=GameOptions.from_dict(data["options"]),
phase=Phase(data["phase"]),
round_number=data["round_number"],
total_rounds=data["total_rounds"],
dealer=data["dealer"],
current_player=data["current_player"],
hand=tuple(card_by_id(c["id"]) for c in data["hand"]),
hand_sizes=tuple(data["hand_sizes"]),
trump_card=card(data["trump_card"]),
trump_suit=suit(data["trump_suit"]),
bids=tuple(data["bids"]),
has_bid=tuple(data["has_bid"]),
tricks_won=tuple(data["tricks_won"]),
scores=tuple(data["scores"]),
trick_number=data["trick_number"],
trick_leader=data["trick_leader"],
current_trick=tuple(PlayedCard.from_dict(p) for p in data["current_trick"]),
lead_suit=suit(data["lead_suit"]),
played_cards=tuple(card_by_id(c["id"]) for c in data["played_cards"]),
last_trick=TrickResult.from_dict(data["last_trick"]) if data["last_trick"] else None,
round_history=tuple(RoundResult.from_dict(r) for r in data["round_history"]),
legal_actions=tuple(action_from_dict(a) for a in data["legal_actions"]),
winners=tuple(data["winners"]),
)
@@ -0,0 +1,95 @@
"""Stateless rule helpers of the Wizard card game."""
from __future__ import annotations
from collections.abc import Sequence
from .cards import DECK_SIZE, Card, Suit
MIN_PLAYERS = 3
MAX_PLAYERS = 6
# Points for a correct prediction and per trick (correct) / per trick off (wrong).
CORRECT_BID_BONUS = 20
POINTS_PER_TRICK = 10
PENALTY_PER_TRICK_OFF = 10
def total_rounds(num_players: int) -> int:
"""Number of rounds played: 3/4/5/6 players play 20/15/12/10 rounds."""
validate_player_count(num_players)
return DECK_SIZE // num_players
def validate_player_count(num_players: int) -> None:
if not MIN_PLAYERS <= num_players <= MAX_PLAYERS:
raise ValueError(
f"Wizard is played with {MIN_PLAYERS}-{MAX_PLAYERS} players, got {num_players}"
)
def lead_suit(trick: Sequence[Card]) -> Suit | None:
"""The suit that has to be followed in a trick.
The first number card determines the suit. Jesters played before it are
skipped. If a wizard is played before any number card, the trick has no
suit to follow.
"""
for card in trick:
if card.is_jester:
continue
if card.is_wizard:
return None
return card.suit
return None
def legal_cards(hand: Sequence[Card], trick: Sequence[Card]) -> list[Card]:
"""Cards of ``hand`` that may be played onto ``trick``.
Wizards and jesters may always be played. Number cards must follow the
lead suit if the player holds a number card of that suit.
"""
suit = lead_suit(trick)
if suit is None:
return list(hand)
if not any(card.is_number and card.suit is suit for card in hand):
return list(hand)
return [card for card in hand if not card.is_number or card.suit is suit]
def trick_winner(trick: Sequence[Card], trump: Suit | None) -> int:
"""Index (within ``trick``) of the card that wins the trick.
- the first wizard wins
- otherwise the highest trump wins
- otherwise the highest card of the lead suit wins
- if only jesters were played, the first jester wins
"""
if not trick:
raise ValueError("Cannot determine the winner of an empty trick")
for index, card in enumerate(trick):
if card.is_wizard:
return index
if trump is not None:
trumps = [(card.rank, index) for index, card in enumerate(trick)
if card.is_number and card.suit is trump]
if trumps:
return max(trumps)[1]
suit = lead_suit(trick)
if suit is not None:
followers = [(card.rank, index) for index, card in enumerate(trick)
if card.is_number and card.suit is suit]
return max(followers)[1]
return 0
def round_score(bid: int, tricks: int) -> int:
"""Score change of a player at the end of a round."""
if bid == tricks:
return CORRECT_BID_BONUS + POINTS_PER_TRICK * tricks
return -PENALTY_PER_TRICK_OFF * abs(bid - tricks)
@@ -0,0 +1,166 @@
"""Value types describing game state, options, results and events."""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
from .cards import Card, Suit, card_by_id
class Phase(str, Enum):
TRUMP_SELECTION = "trump_selection"
BIDDING = "bidding"
PLAYING = "playing"
FINISHED = "finished"
@dataclass(frozen=True, slots=True)
class GameOptions:
# "notequal": the sum of all bids must not equal the number of tricks
# of the round. The last bidder has to adjust their bid.
not_equal: bool = False
# "hiddentip": bids are revealed only once every player has bid.
hidden_bids: bool = False
def to_dict(self) -> dict:
return {"not_equal": self.not_equal, "hidden_bids": self.hidden_bids}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> GameOptions:
return cls(
not_equal=bool(data.get("not_equal", False)),
hidden_bids=bool(data.get("hidden_bids", False)),
)
@dataclass(frozen=True, slots=True)
class PlayedCard:
player: int
card: Card
def to_dict(self) -> dict:
return {"player": self.player, "card": self.card.to_dict()}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> PlayedCard:
return cls(player=data["player"], card=card_by_id(data["card"]["id"]))
@dataclass(frozen=True, slots=True)
class TrickResult:
round_number: int
trick_number: int
cards: tuple[PlayedCard, ...]
winner: int
def to_dict(self) -> dict:
return {
"round_number": self.round_number,
"trick_number": self.trick_number,
"cards": [played.to_dict() for played in self.cards],
"winner": self.winner,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> TrickResult:
return cls(
round_number=data["round_number"],
trick_number=data["trick_number"],
cards=tuple(PlayedCard.from_dict(c) for c in data["cards"]),
winner=data["winner"],
)
@dataclass(frozen=True, slots=True)
class RoundResult:
round_number: int
bids: tuple[int, ...]
tricks: tuple[int, ...]
score_changes: tuple[int, ...]
scores: tuple[int, ...]
def to_dict(self) -> dict:
return {
"round_number": self.round_number,
"bids": list(self.bids),
"tricks": list(self.tricks),
"score_changes": list(self.score_changes),
"scores": list(self.scores),
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> RoundResult:
return cls(
round_number=data["round_number"],
bids=tuple(data["bids"]),
tricks=tuple(data["tricks"]),
score_changes=tuple(data["score_changes"]),
scores=tuple(data["scores"]),
)
# --- Events -----------------------------------------------------------------
# ``WizardGame.apply`` returns the events caused by an action. They are handy
# for clients that want to animate what happened (e.g. pause after a trick).
@dataclass(frozen=True, slots=True)
class RoundStarted:
round_number: int
dealer: int
trump_card: Card | None
def to_dict(self) -> dict:
return {
"type": "round_started",
"round_number": self.round_number,
"dealer": self.dealer,
"trump_card": self.trump_card.to_dict() if self.trump_card else None,
}
@dataclass(frozen=True, slots=True)
class TrickCompleted:
trick: TrickResult
def to_dict(self) -> dict:
return {"type": "trick_completed", "trick": self.trick.to_dict()}
@dataclass(frozen=True, slots=True)
class RoundCompleted:
result: RoundResult
def to_dict(self) -> dict:
return {"type": "round_completed", "result": self.result.to_dict()}
@dataclass(frozen=True, slots=True)
class GameFinished:
scores: tuple[int, ...]
winners: tuple[int, ...]
def to_dict(self) -> dict:
return {"type": "game_finished", "scores": list(self.scores), "winners": list(self.winners)}
Event = RoundStarted | TrickCompleted | RoundCompleted | GameFinished
@dataclass(slots=True)
class RoundState:
"""Mutable per-round state, owned by ``WizardGame``."""
round_number: int
dealer: int
hands: list[list[Card]]
trump_card: Card | None
trump_suit: Suit | None
bids: list[int | None]
tricks_won: list[int]
current_trick: list[PlayedCard] = field(default_factory=list)
trick_leader: int = 0
trick_number: int = 1
played_cards: list[Card] = field(default_factory=list)