Files
Wizard/wizard_server/game_logic/wizard_game/game.py
T
2026-09-27 09:52:00 +02:00

301 lines
11 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, 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