# 3. Game engine (`wizard_game`) Location: `wizard_server/game_logic/` (distribution name `wizard-game`, import name `wizard_game`). Pure Python ≥ 3.11, **no dependencies**. It is used by - the server, to run games (`wizard_server/app/lobby.py`), and - the AI, to train and to decode observations (`wizard_ai/`). Install it on its own with `pip install ./wizard_server/game_logic` (or `pip install -e …` while developing — see [troubleshooting](08-operations.md#troubleshooting)). ## Modules | Module | Contents | |--------|----------| | `cards.py` | `Suit` (enum), `CardKind` (number/wizard/jester), `Card` (frozen dataclass with `id`, `kind`, `suit`, `rank`), `ALL_CARDS`, `card_by_id`, `new_shuffled_deck` | | `actions.py` | `Bid(value)`, `PlayCard(card)`, `Action = Bid \| PlayCard`, `action_to_dict`, `action_from_dict` | | `rules.py` | stateless rule functions: `total_rounds`, `trump_suit`, `lead_suit`, `legal_cards`, `trick_winner`, `round_score`, scoring constants | | `state.py` | `Phase`, `GameOptions`, `PlayedCard`, `TrickResult`, `RoundResult`, the event classes, and the internal mutable `RoundState` | | `observation.py` | `Observation`: the view of one player, with lossless `to_dict` / `from_dict` | | `game.py` | `WizardGame` (the state machine) and `IllegalActionError` | | `__init__.py` | re-exports everything public, so `from wizard_game import WizardGame, Bid, …` works | ## Using it ```python import random from wizard_game import WizardGame, GameOptions game = WizardGame(num_players=4, options=GameOptions(hidden_bids=False), seed=1) rng = random.Random(1) while not game.is_finished: player = game.current_player # whose turn it is (seat index) obs = game.observation(player) # what that player may know action = rng.choice(obs.legal_actions) # a Bid or PlayCard events = game.apply(player, action) # advances the game print(game.scores, game.winners()) ``` The loop is the whole API: *ask whose turn it is → look at the observation → apply one of the legal actions*. Rounds advance automatically. ## `WizardGame` public API | Member | Description | |--------|-------------| | `WizardGame(num_players, options=None, *, seed=None, rng=None, first_dealer=None)` | Creates a game and immediately deals round 1. Use `seed` or pass your own `random.Random` as `rng` for reproducibility. | | `num_players`, `options`, `total_rounds` | constants of this game | | `phase` | `Phase.BIDDING`, `Phase.PLAYING` or `Phase.FINISHED` | | `current_player` | seat index whose turn it is (`None` when finished) | | `round_number`, `dealer` | current round (1-based) and its dealer | | `scores` | list of total scores per seat | | `round_history` | list of `RoundResult`, one per finished round | | `last_trick` | `TrickResult` of the most recently completed trick | | `hand(player)` | copy of a player's hand (server/training use only — it is private information) | | `legal_actions(player=None)` | legal actions of `player` (default: current player); empty if it is not their turn | | `is_legal(player, action)` | shortcut for `action in legal_actions(player)` | | `apply(player, action) -> list[Event]` | performs the action; raises `IllegalActionError` if it is not that player's turn, the game is over, or the action is illegal | | `observation(player) -> Observation` | the player's view (see below) | | `winners()` | seats with the highest score | | `clone()` | deep copy, e.g. for search-based AIs that simulate moves | ## State machine ```mermaid stateDiagram-v2 [*] --> BIDDING: __init__ deals round 1 BIDDING --> BIDDING: Bid (not everyone has bid yet) BIDDING --> PLAYING: last Bid PLAYING --> PLAYING: PlayCard (trick not full / trick full but round not over) PLAYING --> BIDDING: PlayCard completes the last trick\n(round scored, next round dealt) PLAYING --> FINISHED: PlayCard completes the last trick of the last round FINISHED --> [*] ``` What happens inside `apply` (`game.py`): - `_bid` stores the bid. When all bids are in, the phase becomes `PLAYING` and the starter (`round.trick_leader`) is to move; otherwise the next seat. - `_play` removes the card from the hand, appends it to `current_trick` and to `played_cards`. When every player has played, `_complete_trick` runs: - determines the winner (`rules.trick_winner`), increments `tricks_won`, - stores a `TrickResult` in `last_trick` and emits `TrickCompleted`, - if tricks remain: the winner leads the next trick, - otherwise `_complete_round`: computes score changes, appends a `RoundResult` to `round_history`, emits `RoundCompleted`, and then either finishes the game (`GameFinished`) or deals the next round (`RoundStarted`) with the next dealer. So a single `PlayCard` can produce up to three events, e.g. `[TrickCompleted, RoundCompleted, RoundStarted]`. ### Internal per-round state (`state.RoundState`) Mutable, owned by `WizardGame.round`; not part of the public API. | Field | Meaning | |-------|---------| | `round_number`, `dealer` | | | `hands` | list of lists of `Card` per seat | | `trump_card`, `trump_suit` | the turned-up card and the resulting suit (or `None`) | | `bids` | `int` or `None` (not yet bid) per seat | | `tricks_won` | per seat | | `current_trick` | list of `PlayedCard(player, card)` in play order | | `trick_leader`, `trick_number` | who leads / which trick (1-based) | | `played_cards` | every card played this round, including the current trick | ## `Observation` — what a player may know `observation.py`. Built by `WizardGame.observation(player)`. Immutable (frozen dataclass). This is **the** data structure of the project: the browser renders it and the AI decides on it. | Field | Type | Meaning | |-------|------|---------| | `player` | int | whose view this is | | `num_players`, `options` | | | | `phase` | `Phase` | | | `round_number`, `total_rounds`, `dealer` | int | | | `current_player` | int \| None | | | `hand` | tuple[Card] | own cards, sorted by id | | `hand_sizes` | tuple[int] | number of cards each seat still holds | | `trump_card` | Card \| None | turned-up card of this round | | `trump_suit` | Suit \| None | `None` for wizard/jester/last round | | `bids` | tuple[int \| None] | `None` = not yet bid **or** hidden (hidden bids option) | | `has_bid` | tuple[bool] | who has already bid (visible even when bids are hidden) | | `tricks_won` | tuple[int] | this round | | `scores` | tuple[int] | totals | | `trick_number`, `trick_leader` | int | | | `current_trick` | tuple[PlayedCard] | cards on the table | | `lead_suit` | Suit \| None | suit that must be followed right now | | `played_cards` | tuple[Card] | all cards played this round (for card counting) | | `last_trick` | TrickResult \| None | most recent completed trick | | `round_history` | tuple[RoundResult] | all finished rounds | | `legal_actions` | tuple[Action] | **empty unless it is this player's turn** | | `winners` | tuple[int] | filled when finished | Property `is_my_turn` is `current_player == player`. Privacy guarantees (tested in `game_logic/tests/test_game.py`): - Only the observer's own hand is included. - With hidden bids, other players' bids are `None` until all bids are placed. - Legal actions are only present for the player to move. ## Results and events (`state.py`) | Type | Fields | |------|--------| | `PlayedCard` | `player`, `card` | | `TrickResult` | `round_number`, `trick_number`, `cards` (tuple of `PlayedCard`), `winner`, `trump_card` (trump card of that trick's round) | | `RoundResult` | `round_number`, `bids`, `tricks`, `score_changes`, `scores` (totals after the round) — all tuples indexed by seat | | `RoundStarted` (event) | `round_number`, `dealer`, `trump_card` | | `TrickCompleted` (event) | `trick: TrickResult` | | `RoundCompleted` (event) | `result: RoundResult` | | `GameFinished` (event) | `scores`, `winners` | Events are returned by `apply` and forwarded to browsers so the UI can show, e.g., the completed trick for a moment. ## JSON format Every type above has `to_dict()`; `Observation`, `TrickResult`, `RoundResult`, `PlayedCard`, `GameOptions` also have `from_dict()`. The JSON is what goes over the websockets. Examples: ```json // Card {"id": 17, "kind": "number", "suit": "yellow", "rank": 5} {"id": 53, "kind": "wizard", "suit": null, "rank": 0} // Actions {"type": "bid", "value": 2} {"type": "play", "card": 17} // Event {"type": "trick_completed", "trick": {"round_number": 3, "trick_number": 2, "cards": [{"player": 1, "card": {…}}, …], "winner": 2, "trump_card": {…}}} ``` A full observation looks like this (shortened): ```json { "player": 0, "num_players": 3, "options": {"hidden_bids": false}, "phase": "playing", "round_number": 3, "total_rounds": 20, "dealer": 2, "current_player": 0, "hand": [{"id": 4, "kind": "number", "suit": "red", "rank": 5}, …], "hand_sizes": [2, 2, 2], "trump_card": {"id": 44, "kind": "number", "suit": "blue", "rank": 6}, "trump_suit": "blue", "bids": [1, 0, 1], "has_bid": [true, true, true], "tricks_won": [1, 0, 0], "scores": [40, 30, -10], "trick_number": 2, "trick_leader": 0, "current_trick": [], "lead_suit": null, "played_cards": […], "last_trick": {…}, "round_history": [{"round_number": 1, …}, {"round_number": 2, …}], "legal_actions": [{"type": "play", "card": 4}, …], "winners": [] } ``` `action_from_dict` validates strictly: unknown types, missing fields, non-integer values (booleans are rejected too) and card ids outside 0–59 raise `ValueError`. ## Randomness and reproducibility All randomness (first dealer, shuffling) comes from one `random.Random` (`seed=` or `rng=`). The same seed and the same sequence of actions always produce the same game — useful for tests and for comparing AI agents on identical deals. ## Tests `wizard_server/game_logic/tests/`: - `test_rules.py` — each rule function (lead suit, legal cards, trick winner, trump suit, scoring, round counts). - `test_game.py` — full random games for 3–6 players with and without hidden bids; invariants checked on every round (tricks add up, bids never add up to the cards in hand, the trump card is never in a hand, completed tricks keep their trump card); JSON round trips; illegal actions; hidden bids. ## Extending the engine - **A new rule option**: add a field to `GameOptions` (plus `to_dict` / `from_dict`), read `self.options.` in `game.py`, add it to the server's `SetOptions` message and the lobby UI, and consider adding it to the AI feature encoding. See [recipes](08-operations.md#recipes). - **A new action type**: add a dataclass in `actions.py`, handle it in `action_from_dict`, `WizardGame.legal_actions` and `apply`, and extend `ActionSpace` in `wizard_ai/wizard_ai/model/encoding.py` and the TypeScript `Action` type.