301 lines
11 KiB
Python
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
|