added docs

This commit is contained in:
2026-09-27 11:26:10 +02:00
parent 87151c2cc9
commit 905574e8f1
10 changed files with 1736 additions and 1 deletions
+238
View File
@@ -0,0 +1,238 @@
# 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.