first prototype
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user