Files
Wizard/docs/03-game-engine.md
2026-09-27 11:26:10 +02:00

239 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.<field>` 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.