"""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]: """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 _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