239 lines
11 KiB
Markdown
239 lines
11 KiB
Markdown
# 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.
|