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
+4 -1
View File
@@ -2,6 +2,8 @@
An online version of the card game Wizard (rules: `wizard_instructions/`). An online version of the card game Wizard (rules: `wizard_instructions/`).
**Detailed documentation of the whole codebase: [`docs/`](docs/README.md).**
| Component | Path | Tech | | Component | Path | Tech |
|-----------|------|------| |-----------|------|------|
| Frontend | `wizard_website/` | React + TypeScript + Vite, CSS modules, served by nginx | | Frontend | `wizard_website/` | React + TypeScript + Vite, CSS modules, served by nginx |
@@ -18,7 +20,8 @@ browser ──wss /wizard/api/ws──────▶ Caddy ──/ws──▶ w
1. A player opens the website, enters a name and creates a lobby (and joins it). 1. A player opens the website, enters a name and creates a lobby (and joins it).
2. The lobby shows a code (and an invite link, `?lobby=CODE`) others use to join. 2. The lobby shows a code (and an invite link, `?lobby=CODE`) others use to join.
3. The host can add AI players, toggle the *not equal* / *hidden bids* rules, and start the game (3–6 players). 3. The host can add AI players, toggle the *hidden bids* option, and start the game (3–6 players).
The sum of all bids may never equal the number of cards in hand (always on).
4. The server runs the game with `wizard_game` and sends every player a personal view 4. The server runs the game with `wizard_game` and sends every player a personal view
(`Observation`) after each move. Reloading the page reconnects to the same seat. (`Observation`) after each move. Reloading the page reconnects to the same seat.
A player who leaves a running game is replaced by an AI player. A player who leaves a running game is replaced by an AI player.
+160
View File
@@ -0,0 +1,160 @@
# 1. Architecture overview
## Components
| Component | Folder | Language / stack | Runs as | Responsibility |
|-----------|--------|------------------|---------|----------------|
| Website | `wizard_website/` | React 19, TypeScript, Vite, CSS modules | static files served by nginx (container port 8082) | UI: start page, lobby, game table |
| Game server | `wizard_server/app/` | Python 3.13, FastAPI, uvicorn | one process (container port 8083) | sessions, lobbies, running games, asking the AI for moves |
| Game engine | `wizard_server/game_logic/` (package `wizard_game`) | pure Python, no dependencies | library, installed into server and AI images | the rules of Wizard |
| AI service | `wizard_ai/` | Python, `websockets` | one process, no listening port | chooses moves for AI seats; training tools |
The game engine is deliberately a separate installable package. The server
uses it to run games, and the AI uses the *same* code to train, so a model
learns exactly the rules it will later play by.
## Network layout (production)
```mermaid
flowchart TB
subgraph internet
U[Browser]
end
subgraph net1 ["network wizard_website"]
CADDY1((Caddy))
WEB["wizard_website :8082"]
end
subgraph net2 ["network wizard_api"]
CADDY2((Caddy))
SRV["wizard_server :8083"]
end
subgraph net3 ["network wizard_internal (no internet)"]
SRV2[wizard_server]
AI[wizard_ai]
end
U -->|"https://game.marvinkrausser.com/wizard/website/"| CADDY1 --> WEB
U -->|"wss://game.marvinkrausser.com/wizard/api/ws"| CADDY2 --> SRV
AI -->|"ws://wizard_server:8083/internal/ai"| SRV2
```
- Caddy is one existing container attached to the external networks
`wizard_website` and `wizard_api`; it is drawn twice only for readability.
- `wizard_server` is attached to two networks (`wizard_api` and
`wizard_internal`); `SRV` and `SRV2` are the same container.
- Caddy **strips the path prefix**: the website container sees `/assets/…`,
the server sees `/ws` and `/health`.
- The AI service connects *to* the server. It has no open port, and its
network (`wizard_internal`) has no internet access.
## How one move travels through the system
A human plays a card in a game that also has an AI player:
```mermaid
sequenceDiagram
participant B as Browser (useWizard)
participant S as Server (WizardService / Lobby)
participant E as WizardGame (engine)
participant A as AI service
B->>S: {"type":"action","action":{"type":"play","card":17}}
S->>E: game.apply(seat, PlayCard(card 17))
E-->>S: events (e.g. TrickCompleted)
S-->>B: {"type":"state", lobby, game (personal observation), events}
Note over S: next seat is an AI → Lobby._ai_loop
S->>A: {"type":"decide","request_id":"…","observation":{…}}
A->>A: agent.act(observation)
A-->>S: {"type":"action","request_id":"…","action":{…}}
Note over S: waits at least AI_MOVE_DELAY<br/>(and TRICK_PAUSE after a trick)
S->>E: game.apply(ai_seat, action)
S-->>B: {"type":"state", …}
```
Important properties of this flow:
1. **The server is authoritative.** Browsers and the AI only *propose*
actions; the engine validates every action (`WizardGame.apply` raises
`IllegalActionError` for anything illegal).
2. **Every state message is complete.** The server does not send diffs. Each
`state` message contains the full lobby and the full personal view of the
game. The frontend simply replaces its state, which makes reconnects and
reloads trivial.
3. **Each player receives their own view.** The server builds an
`Observation` per seat, which never contains other players' hands (and hides
bids while "hidden bids" is active).
4. **The AI sees exactly what a human sees.** The AI service receives the same
`Observation` JSON a browser gets for that seat.
## Repository layout
```
.
├── compose.yaml # all three containers + networks
├── .env.example # template for .env (AI_SHARED_SECRET, …)
├── README.md # short project readme
├── docs/ # this documentation
├── instructions.txt # original task description
├── wizard_instructions/ # original rules (saved web page)
│
├── wizard_server/
│ ├── Dockerfile
│ ├── requirements.txt # fastapi, uvicorn
│ ├── requirements-dev.txt # + pytest, httpx, editable game_logic
│ ├── app/ # the FastAPI server
│ │ ├── main.py # app factory + routes
│ │ ├── config.py # Settings from environment variables
│ │ ├── protocol.py # browser → server message schema (pydantic)
│ │ ├── service.py # WizardService: dispatches messages, sessions, cleanup
│ │ ├── sessions.py # Session, Connection (ordered send queue), SessionRegistry
│ │ ├── lobby.py # Lobby, Seat, LobbyManager, AI turn loop
│ │ └── ai_bridge.py # request/response with the AI service + fallback
│ ├── tests/test_server.py # websocket-level tests of the server
│ └── game_logic/ # installable package "wizard-game"
│ ├── pyproject.toml
│ ├── wizard_game/
│ │ ├── cards.py # Card, Suit, deck, card ids
│ │ ├── actions.py # Bid, PlayCard, JSON (de)serialization
│ │ ├── rules.py # stateless rule functions
│ │ ├── state.py # Phase, GameOptions, results, events, RoundState
│ │ ├── observation.py # Observation (a player's view) + JSON
│ │ └── game.py # WizardGame state machine
│ └── tests/ # rules + full random games
│
├── wizard_ai/
│ ├── Dockerfile # needs game_logic as extra build context
│ ├── requirements.txt # websockets
│ ├── models/ # trained checkpoints (mounted read-only)
│ ├── wizard_ai/
│ │ ├── __main__.py # CLI: serve | train | evaluate
│ │ ├── config.py
│ │ ├── agents/ # Agent interface, random, heuristic, model agent
│ │ ├── model/ # encoding.py (features/actions), policy.py (model interface)
│ │ ├── training/ # env.py, self_play.py, evaluate.py
│ │ └── service/client.py # production websocket client
│ └── tests/test_ai.py
│
└── wizard_website/
├── Dockerfile # node build → nginx
├── nginx.conf # listens on 8082, SPA fallback
├── vite.config.ts # base './', dev proxy for /api
└── src/
├── main.tsx, App.tsx, App.module.css
├── api/ # types.ts (protocol types), useWizard.ts (connection hook)
├── components/ # Button, PlayingCard, Modal, Toasts
├── screens/ # HomeScreen, LobbyScreen, GameScreen (+ its parts)
└── util/cards.ts # sorting, labels, legal-action helpers
```
## Key design decisions (and why)
| Decision | Reason |
|----------|--------|
| Game rules in a dependency-free package | The AI trains on the exact same rules the server enforces; the engine is fast and easy to test in isolation. |
| Engine works with seat indices only | Keeps it independent of users, names and networking. The server maps indices ↔ players. |
| One `Observation` type for browser *and* AI | A single, well-tested definition of "what a player may know"; no risk of the AI accidentally seeing hidden information. |
| Full-state messages instead of diffs | Simple client, trivial reconnects, no sync bugs. Messages are small (a few KB). |
| Server asks the AI (request/response), AI seats live in the server | The AI service is stateless per request, can be scaled to several instances (round robin), and a missing AI never blocks a game (random fallback). |
| All state in memory, single process | No database needed for a casual game. Consequence: a server restart ends all games, and the server must run with **one worker**. |
| Session token in `sessionStorage` | Survives page reloads (reconnect to the same seat) but is per tab, so you can test several players in several tabs. |
| Per-connection send queue | Websocket messages are always sent in order and never concurrently (see `sessions.Connection`). |
| Relative asset paths + relative API URL in the frontend | The app works under any path prefix (`/wizard/website/` in production, `/` in development) without rebuilding. |
+140
View File
@@ -0,0 +1,140 @@
# 2. Game rules as implemented
This chapter lists every rule the code enforces and where. The original rules
are in `wizard_instructions/`; where this implementation deliberately differs
from them, it is marked **(house rule)**.
All rule code lives in the game engine: stateless helpers in
`wizard_server/game_logic/wizard_game/rules.py`, flow in `game.py`.
## Cards
60 cards (`cards.py`):
| Cards | Count | Notes |
|-------|-------|-------|
| Number cards 1–13 in red, yellow, green, blue | 52 | the only cards with a suit |
| Wizards | 4 | always highest |
| Jesters | 4 | always lowest |
Every card has a fixed **id 0–59**, used in JSON and in the AI encoding:
| Ids | Cards |
|-----|-------|
| 0–12 | red 1–13 |
| 13–25 | yellow 1–13 |
| 26–38 | green 1–13 |
| 39–51 | blue 1–13 |
| 52–55 | wizards |
| 56–59 | jesters |
Formula for number cards: `id = suit_index * 13 + (rank - 1)` with suit order
red, yellow, green, blue.
## Players and rounds
- 3 to 6 players (`rules.MIN_PLAYERS`, `rules.MAX_PLAYERS`).
- Number of rounds = `60 // players` (`rules.total_rounds`): 3 → 20, 4 → 15,
5 → 12, 6 → 10.
- In round *n* every player gets *n* cards, so round *n* has *n* tricks.
## Dealing, dealer and starting player
`WizardGame._deal`:
1. A fresh 60-card stack is shuffled every round.
2. Cards are dealt one at a time from the top of the stack, starting with the
player left of the dealer.
3. The **next card of the stack is turned up as the trump card**. It is taken
off the stack: it is in nobody's hand and can never be played.
4. In the last round all 60 cards are dealt, so there is **no trump card**.
The first dealer is random (or `first_dealer=` in tests). The dealer moves one
seat to the left every round. The player left of the dealer (the *starter*)
bids first and leads the first trick.
## Trump
`rules.trump_suit(trump_card)`:
| Trump card | Trump suit |
|------------|------------|
| number card | its suit |
| wizard | **no trump** (house rule — the original rules let the dealer choose) |
| jester | no trump |
| none (last round) | no trump |
## Bidding
`WizardGame._allowed_bids` and `_bid`:
- Players bid in turn, starting with the starter; the dealer bids last.
- A bid is any number from 0 to the number of cards in hand.
- **The sum of all bids may never equal the number of cards in hand**
(house rule — in the original rules this is the optional "notequal"
variant; here it is always on). Only the last bidder is restricted: the one
value that would make the sum equal the round number is removed from their
legal bids. Example: round 5, the others bid 1, 2 and 0 → the last player may
bid anything except 2.
- Option **hidden bids** ("hiddentip", `GameOptions.hidden_bids`): while
bidding is in progress, each player only sees their own bid; everyone can
see *who* has already bid (`Observation.has_bid`). All bids are revealed as
soon as the last bid is placed (`WizardGame.observation`).
Note: because of the sum rule, the last bidder can deduce the sum of the
hidden bids from the forbidden value.
## Playing a trick
The starter leads the first trick; afterwards the winner of the previous
trick leads.
### Which suit must be followed — `rules.lead_suit(trick)`
Look at the cards of the trick in playing order:
- Jesters are skipped.
- If the first non-jester card is a **wizard**, there is no suit to follow for
the whole trick.
- If the first non-jester card is a **number card**, its suit must be
followed.
- If only jesters were played so far, there is no suit yet (the next number
card will set it).
### Which cards may be played — `rules.legal_cards(hand, trick)`
- Wizards and jesters may always be played.
- If there is a lead suit and the player holds at least one number card of
that suit, they must play a card of that suit (or a wizard/jester).
- Otherwise any card may be played.
### Who wins the trick — `rules.trick_winner(trick, trump)`
In this order:
1. The **first wizard** played wins.
2. Otherwise the **highest trump** wins.
3. Otherwise the **highest card of the lead suit** wins.
4. If only jesters were played, the **first jester** wins.
## Scoring
`rules.round_score(bid, tricks)` at the end of each round:
| Result | Points |
|--------|--------|
| tricks == bid | 20 + 10 × tricks |
| tricks ≠ bid | −10 × \|tricks − bid\| |
Examples: bid 0, took 0 → +20. Bid 3, took 3 → +50. Bid 2, took 0 → −20.
## End of the game
After the last round (`WizardGame._complete_round`) the phase becomes
`finished`. All players with the highest total score win (ties are possible,
`WizardGame.winners`).
## Rule variants from the original that are not implemented
- `dmmixed` and `wiztournament` (tournament card distributions).
- The dealer choosing trump when a wizard is turned up (replaced by the house
rule "wizard = no trump").
+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.
+291
View File
@@ -0,0 +1,291 @@
# 4. Game server (`wizard_server/app`)
A FastAPI application running in a single uvicorn process. It keeps
**everything in memory**: sessions, lobbies and running games.
## Module overview
```mermaid
flowchart TB
main["main.py<br/>routes, auth"] --> service["service.py<br/>WizardService"]
service --> sessions["sessions.py<br/>Session, Connection, SessionRegistry"]
service --> lobby["lobby.py<br/>LobbyManager, Lobby, Seat"]
service --> protocol["protocol.py<br/>message schema"]
service --> bridge["ai_bridge.py<br/>AIBridge"]
lobby --> bridge
lobby --> engine[("wizard_game")]
bridge --> engine
main --> config["config.py<br/>Settings"]
```
| Module | Main types | Role |
|--------|-----------|------|
| `main.py` | `create_app()` | builds the FastAPI app, defines the three routes, starts the cleanup task |
| `config.py` | `Settings`, `settings` | configuration from environment variables |
| `protocol.py` | pydantic models, `client_message_adapter` | validates messages from browsers |
| `service.py` | `WizardService` | the "controller": handles a browser connection, dispatches messages, manages sessions and cleanup |
| `sessions.py` | `Session`, `Connection`, `SessionRegistry` | player identity across reconnects; ordered sending |
| `lobby.py` | `LobbyManager`, `Lobby`, `Seat`, `LobbyError` | lobby membership, host actions, running the game, AI turns, broadcasting |
| `ai_bridge.py` | `AIBridge`, `AIConnection` | talks to the AI service; random fallback |
## Routes (`main.py`)
Caddy strips `/wizard/api`, so these are the paths the server sees:
| Route | Used by | Purpose |
|-------|---------|---------|
| `GET /health` | Docker health check, you | `{"status":"ok","lobbies":…,"sessions":…,"ai_available":…}` |
| `WS /ws` | browsers | the game protocol (see [protocol](05-protocol.md)) |
| `WS /internal/ai` | AI service | decision requests |
`/internal/ai` is protected twice:
1. `_via_proxy()` rejects the connection if it carries forwarding headers
(`X-Forwarded-For`, `Forwarded`, `Via`). Caddy always adds them, so
requests from the internet via `…/wizard/api/internal/ai` are refused. The
AI service connects directly over the internal network and has none.
2. `_ai_authorized()` requires `Authorization: Bearer <AI_SHARED_SECRET>`
(compared with `secrets.compare_digest`). If the secret is empty,
authentication is disabled — only for development; a warning is logged at
startup.
`create_app(config)` takes a `Settings` object so tests can build apps with
different configuration (e.g. zero delays).
## Core concepts
### Session — "who is this browser tab?"
`sessions.Session`:
| Field | Meaning |
|-------|---------|
| `player_id` | public id (12 hex chars), shown to other players, used as seat id |
| `token` | secret (`secrets.token_urlsafe(24)`), stored by the browser in `sessionStorage`, used to reconnect |
| `name` | display name, set when creating/joining a lobby |
| `lobby_code` | lobby the player is in, or `None` |
| `connection` | the current `Connection`, or `None` while disconnected |
| `last_seen` | monotonic time of the last disconnect/connect, for expiry |
`SessionRegistry` maps token → session. Sessions without a connection for
`SESSION_TTL` seconds (default 30 min) are removed by the cleanup task; if they
were in a lobby they leave it (see below).
### Connection — ordered sending
`sessions.Connection` wraps one websocket and owns an `asyncio.Queue`. A
background writer task sends queued messages one by one. Therefore:
- `session.send(msg)` is **synchronous** (just enqueues), so game logic never
awaits while mutating state;
- messages to one client are always in order and never sent concurrently.
### Lobby and seats
`lobby.Seat` = one place at the table:
| Field | Meaning |
|-------|---------|
| `player_id` | session's player id, or `ai-xxxxxxxx` for AI seats |
| `name` | unique within the lobby (`"Ann"`, `"Ann (2)"`, …) |
| `is_ai` | AI seats are played via the `AIBridge` |
| `session` | the human's session (or `None`) |
The **seat index in `lobby.seats` is the player index in the game**. Seats are
never removed or reordered while a game runs, so indices stay valid.
`lobby.Lobby` fields: `code` (6 chars from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`, no
0/O/1/I), `seats`, `host_id`, `options` (`GameOptions`), `status`
(`waiting` → `playing` → `finished` → back to `waiting`), `game` (`WizardGame`
or `None`).
`LobbyManager` creates lobbies (up to `MAX_LOBBIES`), finds them by code
(case-insensitive) and deletes a lobby as soon as no human seat is left
(`discard_if_abandoned`).
## Lifecycle of a browser connection (`WizardService.handle_client`)
```mermaid
sequenceDiagram
participant B as Browser
participant S as WizardService
B->>S: websocket connect /ws
Note over S: Origin check (ALLOWED_ORIGINS), accept,<br/>start Connection writer
B->>S: {"type":"hello","token": <stored token or null>}
alt token known
S->>S: reuse session (kick an older tab with close code 4000)
else
S->>S: create new session
end
S-->>B: {"type":"welcome","player_id","token","name"}
S-->>B: {"type":"state", …} (lobby + game if still seated, else nulls)
loop every message
B->>S: create_lobby / join_lobby / action / …
S->>S: validate (pydantic) → _dispatch → Lobby method
S-->>B: state broadcast, or {"type":"error"}
end
B--xS: disconnect
Note over S: session.connection = None, lobby broadcast<br/>(seat shows "offline"), the seat is kept
```
Rules applied in `_hello` and `_dispatch`:
- Every message except `hello` requires a completed `hello` on *this*
connection.
- Invalid JSON/fields → `error` message with the pydantic reason; the
connection stays open.
- `LobbyError` (rule violations like "Only the host can do that") → `error`
message to that client only.
- Opening the same session in a second tab takes it over: the old tab gets an
`error` and is closed with code **4000**; the frontend then stops
reconnecting and shows "opened in another window".
### Message handling (`_dispatch`)
| Message | What happens |
|---------|-------------|
| `create_lobby {name}` | leave current lobby, set name, create lobby, join it (creator becomes host) |
| `join_lobby {code, name}` | lobby must exist; leave another lobby first; join. Joining is only possible while the lobby is `waiting` and has < 6 seats — **except** re-joining your own seat |
| `leave_lobby` | leave (see below), receive empty state |
| `add_ai` | host only, lobby waiting and not full; adds an AI seat named after a famous wizard (Merlin, Morgana, …) |
| `remove_player {player_id}` | host only, lobby waiting; the removed human gets an error + empty state |
| `set_options {hidden_bids}` | host only, lobby waiting |
| `start_game` | host only, 3–6 seats; creates `WizardGame(len(seats), options)` |
| `action {action}` | lobby must be `playing`; the engine validates the action |
| `return_to_lobby` | host only, game must be finished; status back to `waiting`, game discarded |
### Leaving
`Lobby.leave(session)`:
- In a **waiting** lobby the seat is removed.
- During a **playing or finished** game the seat is kept and **converted to an
AI seat** (new `ai-…` id, same name), so the game continues.
- If the host leaves, the next human seat becomes host.
- If no human seat remains, the lobby is deleted (its AI task is cancelled).
Disconnecting (closing the tab, network loss) is *not* leaving: the seat stays
human and the game waits for that player. After `SESSION_TTL` without
reconnect, the cleanup task makes the session leave (→ AI takes over).
## Running a game
### Broadcasting state
`Lobby.broadcast(events)` sends every seated human their own message built by
`Lobby.state_for(session, events)`:
```json
{
"type": "state",
"lobby": { "code": "…", "status": "playing", "host_id": "…", "options": {…},
"players": [{"id", "name", "is_ai", "connected"}, …],
"min_players": 3, "max_players": 6, "ai_available": true },
"game": { "you": 1, "observation": { … personal Observation … } },
"events": [ … events caused by the last action … ]
}
```
It is called after every change (join, leave, options, start, every move, AI
service connecting/disconnecting, a player going offline).
### Human moves
`Lobby.apply_action(session, action_dict)` → `action_from_dict` →
`game.apply(seat, action)`. Errors become `LobbyError` → error toast in the
browser. On success `_after_action(events)` runs.
### `_after_action` and the AI loop
```python
def _after_action(self, events):
if TrickCompleted in events: self._pause_until = now + TRICK_PAUSE
if GameFinished in events: self.status = FINISHED
self.broadcast(events)
self._schedule_ai()
```
`_schedule_ai()` starts `_ai_loop()` as an asyncio task if the current player
is an AI seat and no loop is running. The loop:
```mermaid
flowchart TD
A{"current player<br/>is an AI seat?"} -- no --> Z[stop]
A -- yes --> B["obs = game.observation(seat)"]
B --> C["action = await bridge.decide(obs)"]
C --> D["wait until max(start + AI_MOVE_DELAY, pause_until)"]
D --> E{"same game, same seat,<br/>action still legal?"}
E -- no --> A
E -- yes --> F["game.apply → _after_action"]
F --> A
```
- The AI "thinks" in parallel with the minimum delay, so a slow AI does not add
to the delay.
- `TRICK_PAUSE` gives humans time to see a completed trick before AI players
continue.
- The re-validation step makes the loop safe against state changes while it was
awaiting (e.g. the host returned to the lobby).
- Several consecutive AI seats are handled by the same loop.
### Concurrency model
Everything runs on one asyncio event loop. State changes (join, apply action,
…) are **synchronous** — there is no `await` between checking and mutating —
so no locks are needed. The only awaits are in the AI loop (decide, sleep), and
it re-validates afterwards. This is also why the server must run with **one
worker process**: a second process would have its own, separate lobbies.
## AI bridge (`ai_bridge.py`)
The AI service keeps a websocket open to `/internal/ai`. `AIBridge`:
- keeps a list of connected AI services (`available` = at least one);
- `decide(observation)`: picks a connection round robin, sends
`{"type":"decide","request_id","observation"}`, and awaits a future that
`serve()` resolves when the matching reply arrives;
- falls back to a **random legal action** if no service is connected, the reply
does not arrive within `AI_DECISION_TIMEOUT`, the service reports an error,
the action is malformed or illegal (logged as warning, except the
"no service connected" case which is logged at debug level);
- calls `on_change` when a service connects/disconnects, and the service then
re-broadcasts all lobbies so the lobby screen shows whether the AI is online.
## Configuration (`config.py`)
All values are read from environment variables when the module is imported.
| Variable | Default | Meaning |
|----------|---------|---------|
| `AI_SHARED_SECRET` | empty | secret the AI service must send; empty = no auth (dev only) |
| `AI_DECISION_TIMEOUT` | 5.0 s | wait for the AI before playing a random move |
| `AI_MOVE_DELAY` | 0.8 s | minimum time an AI move takes, so humans can follow |
| `TRICK_PAUSE` | 1.8 s | pause after a completed trick before AIs continue |
| `SESSION_TTL` | 1800 s | disconnected sessions are dropped after this |
| `MAX_LOBBIES` | 1000 | limit of simultaneously open lobbies |
| `ALLOWED_ORIGINS` | empty (= all) | comma separated allowed `Origin` headers for `/ws` |
| `LOG_LEVEL` | INFO | Python logging level |
## Housekeeping
`WizardService.cleanup_loop()` runs every 30 s (started in the app lifespan)
and removes expired sessions (leaving their lobby first).
## Tests (`wizard_server/tests/test_server.py`)
Uses FastAPI's `TestClient` websockets with zero delays. Covers: health,
hello requirement, validation errors, the full lobby flow including a complete
20-round game between two humans and an AI, reconnect by token, leaving
mid-game (seat becomes AI, host moves), the AI endpoint's secret and proxy
checks, and a fake AI service answering decide requests.
Note for writing new tests: enter websocket sessions with
`client.sockets.enter_context(client.websocket_connect(...))` (see the fixture),
otherwise the test client hangs on shutdown.
## Limitations to be aware of
- In-memory state: restarting the server ends all lobbies and games.
- Must run as a single process (no `--workers`).
- No spectators: only seated players receive game state.
- No rate limiting on websocket messages.
+168
View File
@@ -0,0 +1,168 @@
# 5. Protocol reference
All messages are JSON objects with a `type` field, sent as websocket text
frames. There are two protocols:
- **Client protocol** — browser ↔ server on `/ws`
(public URL `wss://game.marvinkrausser.com/wizard/api/ws`).
Schema: `wizard_server/app/protocol.py` (Python) and
`wizard_website/src/api/types.ts` (TypeScript). Keep both in sync.
- **AI protocol** — AI service ↔ server on `/internal/ai`.
Implemented in `wizard_server/app/ai_bridge.py` and
`wizard_ai/wizard_ai/service/client.py`.
Game objects inside messages (cards, actions, observations, events) use the
engine's JSON format described in [the engine chapter](03-game-engine.md#json-format).
---
## Client protocol
### Browser → server
| `type` | Fields | Allowed when | Effect |
|--------|--------|--------------|--------|
| `hello` | `token: string \| null` | always; must be the first message | starts or resumes a session |
| `create_lobby` | `name` | after hello | creates a lobby and joins it as host |
| `join_lobby` | `code`, `name` | after hello | joins an existing lobby |
| `leave_lobby` | – | in a lobby | leaves (during a game an AI takes the seat) |
| `add_ai` | – | host, lobby waiting, < 6 seats | adds an AI seat |
| `remove_player` | `player_id` | host, lobby waiting | removes a seat (human or AI) |
| `set_options` | `hidden_bids: bool` | host, lobby waiting | changes game options |
| `start_game` | – | host, lobby waiting, 3–6 seats | starts the game |
| `action` | `action: {"type":"bid","value":n}` or `{"type":"play","card":id}` | your turn in a running game | performs a move |
| `return_to_lobby` | – | host, game finished | back to the waiting lobby |
Validation (`protocol.py`):
- `name`: whitespace stripped, 1–20 characters.
- `code`: whitespace stripped, upper-cased, 4–12 characters.
- Unknown `type` or wrong field types → `error` message, connection stays open.
### Server → browser
#### `welcome`
Sent after every `hello`.
```json
{"type": "welcome", "player_id": "3f9c0a1b2d4e", "token": "Zq…", "name": "Marvin"}
```
The browser stores `token` (in `sessionStorage`) and sends it in the next
`hello` to resume the same session. `player_id` identifies you in
`lobby.players` and `lobby.host_id`.
#### `state`
Sent after `hello` and whenever anything in your lobby changes. It always
contains the **complete** current state.
```json
{
"type": "state",
"lobby": {
"code": "7C7TAJ",
"status": "playing",
"host_id": "3f9c0a1b2d4e",
"options": {"hidden_bids": false},
"players": [
{"id": "3f9c0a1b2d4e", "name": "Marvin", "is_ai": false, "connected": true},
{"id": "ai-1a2b3c4d", "name": "Merlin", "is_ai": true, "connected": true},
{"id": "ai-5e6f7a8b", "name": "Morgana", "is_ai": true, "connected": true}
],
"min_players": 3,
"max_players": 6,
"ai_available": true
},
"game": {
"you": 0,
"observation": { "…": "the personal Observation of seat 0" }
},
"events": [
{"type": "trick_completed", "trick": {"…": "…"}}
]
}
```
- `lobby: null, game: null` → you are not in a lobby (start page).
- `game: null` → lobby is waiting (no game running).
- `players[i]` is seat `i` in the game (`observation` uses the same indices).
- `events` lists what the last action caused: `trick_completed`,
`round_completed`, `round_started`, `game_finished`. Usually empty.
- `ai_available` is `false` when no AI service is connected; AI seats then play
random moves.
#### `error`
```json
{"type": "error", "message": "Only the host can do that"}
```
Sent only to the client that caused it. Typical messages: `Send 'hello' first`,
`Invalid message: …` (followed by the pydantic reason),
`Lobby ABC123 does not exist`, `The game in this lobby has already started`,
`This lobby is full`, `Only the host can do that`,
`Wizard needs 3 to 6 players`, `Illegal action Bid(value=2) for player 0`,
`It is not player 1's turn`, `You were removed from the lobby`,
`This session was opened in another window`.
### Close codes
| Code | Meaning | Frontend reaction |
|------|---------|-------------------|
| (handshake rejected) | origin not in `ALLOWED_ORIGINS`; the server closes with 1008 before accepting, so the browser sees a failed connection | reconnects with backoff |
| 4000 | the session was opened in another tab | stops reconnecting, shows a banner |
| other | network problem, server restart | reconnects (1 s, 2 s, 4 s … max 10 s) |
### Example conversation
```mermaid
sequenceDiagram
participant H as Host browser
participant S as Server
participant G as Guest browser
H->>S: hello {token:null}
S-->>H: welcome, state {lobby:null}
H->>S: create_lobby {name:"Ann"}
S-->>H: state {lobby: code "K7M2QX", players:[Ann]}
G->>S: hello {token:null}
S-->>G: welcome, state {lobby:null}
G->>S: join_lobby {code:"k7m2qx", name:"Ben"}
S-->>H: state {players:[Ann, Ben]}
S-->>G: state {players:[Ann, Ben]}
H->>S: add_ai
S-->>H: state {players:[Ann, Ben, Merlin]}
S-->>G: state {…}
H->>S: start_game
S-->>H: state {game:{you:0, observation{phase:"bidding", current_player:1}}}
S-->>G: state {game:{you:1, …}}
G->>S: action {type:"bid", value:0}
S-->>H: state {…}
S-->>G: state {…}
```
---
## AI protocol
The AI service connects to `ws://wizard_server:8083/internal/ai` with the
header `Authorization: Bearer <AI_SHARED_SECRET>` and without forwarding
headers (see "Routes" in [the server chapter](04-server.md)).
| Direction | Message | Meaning |
|-----------|---------|---------|
| AI → server | `{"type":"hello","agent":"heuristic"}` | informational, logged |
| server → AI | `{"type":"decide","request_id":"<hex>","observation":{…}}` | choose an action for `observation.player` |
| AI → server | `{"type":"action","request_id":"<same>","action":{"type":"play","card":17}}` | the chosen action |
| AI → server | `{"type":"error","request_id":"<same>","message":"…"}` | the AI failed; the server plays a random move |
Rules:
- Requests are independent; the AI service may answer them in any order
(matched by `request_id`) and may handle several concurrently.
- The answer must be one of `observation.legal_actions`; otherwise the server
plays a random legal move.
- No answer within `AI_DECISION_TIMEOUT` (5 s) → random move.
- Several AI service instances may connect; the server distributes requests
round robin.
+199
View File
@@ -0,0 +1,199 @@
# 6. Frontend (`wizard_website`)
React 19 + TypeScript, built with Vite, styled with CSS modules, served by
nginx. No router and no state library: the server state is the app state.
## Structure
```
src/
├── main.tsx # mounts <App/> in StrictMode
├── App.tsx # picks the screen, connection banner, toasts, URL sync
├── App.module.css # design tokens (CSS variables) + global resets
├── api/
│ ├── types.ts # TypeScript mirror of the protocol and engine JSON
│ └── useWizard.ts # THE connection hook: websocket, state, actions
├── components/ # reusable, game-agnostic-ish UI
│ ├── Button/ # variants primary | secondary | ghost | danger, sizes sm | md | lg
│ ├── PlayingCard/ # PlayingCard (face) and CardBack
│ ├── Modal/ # dialog with backdrop, Escape to close
│ └── Toasts/ # error messages bottom right
├── screens/
│ ├── HomeScreen/ # name, create lobby, join by code
│ ├── LobbyScreen/ # code, players, AI, options, start
│ └── GameScreen/ # the table and its parts (see below)
└── util/cards.ts # suit names, card labels, hand sorting, legal-action helpers
```
Every component has its own `*.module.css` next to it.
## Data flow
```mermaid
flowchart LR
WS[(WebSocket)] -- state / welcome / error --> H[useWizard]
H -- lobby, game, completedTrick, toasts --> App
App --> Home[HomeScreen]
App --> Lobby[LobbyScreen]
App --> Game[GameScreen]
Home -- actions.createLobby / joinLobby --> H
Lobby -- actions.addAI / startGame / … --> H
Game -- actions.play({type:'bid'|'play'}) --> H
H -- JSON messages --> WS
```
Components never talk to the websocket directly. They receive data as props
and call functions from `actions`.
## `useWizard` (`src/api/useWizard.ts`)
The only stateful piece of the app.
**State it exposes**
| Value | Meaning |
|-------|---------|
| `status` | `connecting` \| `open` \| `closed` (will retry) \| `replaced` (opened in another tab, no retry) |
| `playerId` | from `welcome` |
| `lobby`, `game` | exactly what the last `state` message contained |
| `completedTrick` | the last `trick_completed` event, kept for 2.2 s so the trick stays visible |
| `toasts`, `dismissToast` | error messages, auto-removed after 4 s |
| `actions` | `createLobby`, `joinLobby`, `leaveLobby`, `addAI`, `removePlayer`, `setOptions`, `startGame`, `play(action)`, `returnToLobby` |
**Connection behaviour**
- URL: `VITE_WS_URL` if set at build time, otherwise `new URL('../api/ws',
location.href)` with `ws:`/`wss:`. On
`https://game.marvinkrausser.com/wizard/website/` this gives
`wss://game.marvinkrausser.com/wizard/api/ws`; in development
(`http://localhost:5173/`) it gives `ws://localhost:5173/api/ws`.
- On open it sends `hello` with the token from `sessionStorage`
(key `wizard.token`); the `welcome` reply stores the (possibly new) token.
Because `sessionStorage` is per tab, a reload resumes the same seat, while a
second tab is a different player.
- On close it reconnects with exponential backoff (1, 2, 4, 8, 10, 10 … s),
except after close code 4000.
- `send()` shows a toast if the socket is not open.
## Screen selection (`App.tsx`)
| Condition | Screen |
|-----------|--------|
| no lobby (or no player id yet) | `HomeScreen` |
| lobby `waiting` or no game | `LobbyScreen` |
| otherwise | `GameScreen` |
`App` also keeps `?lobby=CODE` in the address bar while you are in a lobby
(so the URL is an invite link) and removes it when you are not. A red banner
shows the connection status when it is not `open`.
## Screens
### HomeScreen
- Name input (max 20 chars), remembered in `localStorage` (`wizard.name`).
- "Create lobby" → `actions.createLobby(name)`.
- Code input + "Join" → `actions.joinLobby(code, name)`. The code is prefilled
from `?lobby=` so invite links work.
- Buttons are disabled while disconnected or without a name.
### LobbyScreen
- Big lobby code with "Copy code" / "Copy invite link" (clipboard API).
- Player list with badges (Host, AI, offline, "(you)") and empty "Free seat"
rows up to 6.
- Host only: "+ Add AI player", "Remove" per player, the "Hidden bids"
checkbox, and "Start game" (disabled with "Need N more players" until 3
seats are filled). Others see the options read-only and "Waiting for the
host…".
- Shows a warning if AI players are present but the AI service is offline.
- Explains the fixed bidding rule (bids may not add up to the cards in hand).
### GameScreen (`screens/GameScreen/`)
```
┌──────────────────────────────────────────────────────────────┐
│ Round 3 / 20 Lobby 7C7TAJ [Scoreboard] [Leave]│ top bar
│ ┌ Round 2 complete ┐ │ RoundSummary (while bidding)
│ [PlayerSeat] [PlayerSeat] … │ opponents in play order
│ ┌────────────────────────────────────────────────────────┐ │
│ │ [trump card] [TrickArea: cards + owner names] │ │ table
│ │ Trump: Blue │ │
│ └────────────────────────────────────────────────────────┘ │
│ Your turn to bid │ status line
│ [BidPanel 0 1 2 3] │ only when you bid
│ [your PlayerSeat] [Hand: clickable cards] │
└──────────────────────────────────────────────────────────────┘
```
| Part | File | Details |
|------|------|---------|
| `GameScreen` | `GameScreen.tsx` | layout, status text, trump display, which panels/modals to show |
| `PlayerSeat` | `PlayerSeat.tsx` | name, AI/dealer(D)/offline chips, score, "tricks / bid" (green when on target, red when over), card backs for the hand size; gold glow when it is that player's turn |
| `TrickArea` | `TrickArea.tsx` | cards of the current trick with owner names; right after a trick is complete it shows the completed trick with the winner highlighted and "X wins the trick" |
| `Hand` | `Hand.tsx` | your cards sorted (wizards, trump suit, other suits, jesters); clickable only on your turn in the playing phase; illegal cards are dimmed and disabled |
| `BidPanel` | `BidPanel.tsx` | buttons 0…round number; illegal bids disabled; shows the running sum of bids and explains a forbidden value |
| `RoundSummary` | `RoundSummary.tsx` | result of the previous round (tricks/bid and score change per player) |
| `Scoreboard` | `Scoreboard.tsx` | modal "block of truth": every round with total, change and tricks/bid, plus the current round |
| `GameOver` | `GameOver.tsx` | ranking, winner, "Back to lobby" (host), "Leave", "Scoreboard" |
Derived display logic worth knowing (all in `GameScreen.tsx`):
- **Completed trick**: `TrickArea` shows `completedTrick` while the table is
empty and the 2.2 s timer has not expired.
- **Trump display**: normally `observation.trump_card`. While the last trick of
the *previous* round is still displayed (`completedTrick.round_number !==
round_number`), the trick's own `trump_card` is shown instead, so the table
never shows a trick next to the wrong trump. The trump suit is derived with
the same rule as the engine (only number cards define a suit), and the label
says "Wizard turned up — no trump", "Jester turned up — no trump" or
"Last round — no trump" when there is none.
- **Round summary**: shown while `phase === 'bidding'` and the last entry of
`round_history` is the previous round — i.e. from the start of a round until
the last bid is placed. Derived from server state, so it survives reloads.
- **Leaving** asks for confirmation (an AI takes the seat mid-game).
## Styling
- **CSS modules only.** Each component imports `styles` from its
`*.module.css`; class names are scoped automatically.
- **Design tokens** are CSS variables on the `.app` root element in
`App.module.css` (`--bg`, `--surface`, `--accent`, `--gold`, `--suit-red`, …).
All components use them, so the theme can be changed in one place.
- The only global rules (`box-sizing`, `body { margin: 0 }`) are written with
`:global(...)` inside `App.module.css`.
- Responsive breakpoint: 720 px (cards overlap in the hand, the round summary
moves into the page flow, the table stacks vertically).
- Card corners are **not** rotated, so 6 and 9 cannot be confused.
## Build and configuration
| File | Purpose |
|------|---------|
| `vite.config.ts` | `base: './'` → relative asset URLs, so the build works under any path prefix; dev server proxies `/api/*` to `WIZARD_SERVER_URL` (default `http://localhost:8000`) and strips `/api`, like Caddy does in production |
| `tsconfig.app.json` | strict TypeScript for `src/` (no unused locals/params, erasable syntax only → no enums) |
| `eslint.config.js` | typescript-eslint + react-hooks + react-refresh |
| `Dockerfile` | `node:22-alpine` runs `npm ci && npm run build` (optional build arg `VITE_WS_URL`), then `nginx:1.27-alpine` serves `dist/` |
| `nginx.conf` | listens on **8082**; `/assets/` cached forever (hashed names), everything else falls back to `index.html` with `no-cache` |
Commands (in `wizard_website/`):
```bash
npm install
npm run dev # http://localhost:5173
npm run build # type-check (tsc -b) + production build into dist/
npm run lint
```
## Keeping types in sync
`src/api/types.ts` mirrors:
- `wizard_server/app/protocol.py` (client messages),
- `Lobby.view()` / `Lobby.state_for()` in `wizard_server/app/lobby.py` (server
messages),
- the `to_dict()` methods in `wizard_game` (cards, actions, observation,
events).
When you change one of those, update `types.ts`; `npm run build` then shows
every place in the UI that needs to follow.
+280
View File
@@ -0,0 +1,280 @@
# 7. AI player (`wizard_ai`)
The AI package contains everything around a reinforcement-learning player:
the agent interface, a feature encoding, a pluggable policy (model) interface,
a training environment with a self-play loop, an evaluation tool, and the
production websocket service. **The actual learning model is not implemented
yet**: `UniformPolicy` is a random placeholder, and until a trained checkpoint
exists production uses the rule-based `HeuristicAgent`.
## Package layout
```
wizard_ai/wizard_ai/
├── __main__.py # CLI: serve | train | evaluate
├── config.py # env vars: SERVER_URL, AI_SHARED_SECRET, AGENT, MODEL_PATH, LOG_LEVEL
├── agents/
│ ├── base.py # Agent interface
│ ├── random_agent.py # RandomAgent
│ ├── heuristic_agent.py # HeuristicAgent (baseline + production fallback)
│ ├── model_agent.py # ModelAgent: Agent backed by a Policy
│ └── __init__.py # make_agent(name, model_path)
├── model/
│ ├── encoding.py # encode_observation(), FEATURE_SIZE, ActionSpace
│ └── policy.py # Policy interface, Transition, checkpoints, UniformPolicy
├── training/
│ ├── env.py # WizardEnv (multi-agent environment)
│ ├── self_play.py # TrainingConfig, SelfPlayTrainer
│ └── evaluate.py # evaluate(agents, games)
└── service/client.py # AIService: production websocket client
```
## How the pieces fit together
```mermaid
flowchart TB
subgraph production
SRV[game server] -- "decide (observation JSON)" --> SVC[AIService]
SVC -- "Observation.from_dict" --> AG["Agent.act"]
AG -- Action --> SVC -- "action JSON" --> SRV
end
subgraph training
ENV["WizardEnv<br/>wraps WizardGame"] -- Observation --> MA[ModelAgent]
MA -- "encode_observation + ActionSpace.mask" --> POL["Policy.select"]
POL -- "action index" --> MA
MA -- Action --> ENV
MA -- "records Transition" --> TR[SelfPlayTrainer]
ENV -- rewards --> TR
TR -- trajectories --> UPD["Policy.update"]
TR -- save --> CK[("models/latest/")]
end
CK -- load_policy --> AG
```
The key idea: **an `Agent` maps an `Observation` to an `Action`**, and the
observation is identical in training (straight from `WizardGame`) and in
production (JSON from the server, decoded with `Observation.from_dict`). A
trained agent can be deployed without any change.
## Agents (`agents/`)
```python
class Agent(ABC):
name: str
def act(self, observation: Observation) -> Action: ... # must return one of observation.legal_actions
def reset(self) -> None: ... # optional, called before each game in evaluation
```
| Agent | Behaviour |
|-------|-----------|
| `RandomAgent` | uniformly random legal action |
| `HeuristicAgent` | **Bidding:** estimates expected tricks (wizard ≈ 0.95, trump ≈ 0.35 + 0.05·rank, high side cards 12–13 count less with more opponents), bids the closest legal value. **Playing:** if it still needs tricks, it wins with the cheapest winning card when last to play, otherwise with the strongest (and plays its weakest card if it cannot win); if it needs no more tricks, it throws the strongest card that still loses, else its weakest card. |
| `ModelAgent(policy, explore=False, recorder=None)` | encodes the observation, asks the policy for an action index, converts it back to an `Action`; optionally records every decision (used by the trainer) |
`make_agent(name, model_path)`: `"random"`, `"heuristic"` or `"model"`. For
`"model"` it loads the checkpoint at `model_path` if it exists, otherwise it
logs a warning and returns a `HeuristicAgent`.
Baseline strength (100 games, `evaluate`): heuristic vs. three random agents →
heuristic ≈ +220 average score, 100 % win rate; random agents ≈ −350.
## Encoding (`model/encoding.py`)
### Features — `encode_observation(obs) -> list[float]` (length `FEATURE_SIZE = 312`)
Plain Python floats (convert to numpy/torch in the model). Opponents are
ordered relative to the observer (next player first) and zero-padded to 5, so
**one model works for 3–6 players**.
| Index | Size | Feature |
|-------|------|---------|
| 0–3 | 4 | number of players one-hot (3, 4, 5, 6) |
| 4–5 | 2 | phase one-hot (bidding, playing) |
| 6 | 1 | round number / 20 |
| 7 | 1 | round number / total rounds (game progress) |
| 8 | 1 | hidden bids option |
| 9–14 | 6 | own seat relative to the dealer, one-hot |
| 15–20 | 6 | own position in the current trick (0 = leading), one-hot |
| 21–80 | 60 | own hand (card id one-hot) |
| 81–140 | 60 | all cards played this round |
| 141–200 | 60 | cards in the current trick |
| 201–205 | 5 | trump suit one-hot (4 suits + none) |
| 206–210 | 5 | lead suit one-hot (4 suits + none) |
| 211–270 | 60 | trump card one-hot (none = all zero) |
| 271–275 | 5 | own: has bid, bid/20, tricks/20, (bid − tricks)/20, score/100 |
| 276–310 | 35 | 5 opponent slots × 7: present, has bid, bid visible, bid/20, tricks/20, (their score − my score)/100, already played in this trick |
| 311 | 1 | (sum of known bids − round number)/20 |
If you change the encoding, `FEATURE_SIZE` must be updated (a test checks the
length for all player counts) and existing checkpoints become incompatible.
### Actions — `ActionSpace` (size 81)
| Index | Action |
|-------|--------|
| 0–20 | `Bid(0)` … `Bid(20)` |
| 21–80 | `PlayCard(card id 0…59)` |
`ActionSpace.index(action)`, `ActionSpace.action(index)` and
`ActionSpace.mask(observation)` (list of 81 booleans, `True` = legal). A model
should give illegal actions zero probability using the mask.
## Policy interface (`model/policy.py`) — where the model goes
```python
@dataclass
class Transition:
features: list[float] # encode_observation(obs)
mask: list[bool] # ActionSpace.mask(obs)
action: int # chosen action index
reward: float = 0.0 # scaled score change, attached to the player's last decision of a round
done: bool = False # True for the player's last decision of the game
class Policy(ABC):
type_name: ClassVar[str] # key in the checkpoint metadata
def select(self, features, mask, *, explore=False) -> int: ...
def update(self, episodes: list[list[Transition]]) -> dict[str, float]: ...
def config(self) -> dict: ... # constructor kwargs stored in the checkpoint
def save_weights(self, directory: Path): ... # write parameters
@classmethod
def load(cls, directory: Path, config: dict) -> Policy: ...
```
Checkpoints are **directories**: `policy.save(dir)` writes
`dir/policy.json` = `{"type": type_name, "config": {...}}` and calls
`save_weights(dir)`. `load_policy(dir)` reads the type, finds the class
registered with `@register_policy`, and calls its `load`.
### Implementing the real model — step by step
1. Add `torch` (or another library) to `wizard_ai/requirements.txt`.
2. Create e.g. `wizard_ai/wizard_ai/model/ppo_policy.py`:
```python
@register_policy
class PPOPolicy(Policy):
type_name = "ppo"
def __init__(self, hidden: int = 256, lr: float = 3e-4):
self.hidden, self.lr = hidden, lr
self.net = ... # FEATURE_SIZE -> hidden -> (ActionSpace.SIZE logits, value)
def select(self, features, mask, *, explore=False):
logits = self.net(torch.tensor(features))[0]
logits[~torch.tensor(mask)] = -inf # only legal actions
dist = Categorical(logits=logits)
return int(dist.sample() if explore else logits.argmax())
def update(self, episodes):
# compute returns/advantages from Transition.reward/done, run PPO epochs
return {"loss": ..., "mean_episode_reward": ...}
def config(self):
return {"hidden": self.hidden, "lr": self.lr}
def save_weights(self, directory):
torch.save(self.net.state_dict(), directory / "weights.pt")
@classmethod
def load(cls, directory, config):
policy = cls(**config)
policy.net.load_state_dict(torch.load(directory / "weights.pt"))
return policy
```
For PPO you will likely want to store log-probabilities and value estimates
per decision; extend `Transition` (or keep them inside the policy keyed by
decision order) as needed.
3. Import the module in `model/__init__.py` so `@register_policy` runs.
4. In `__main__.py` (`train` command) replace `UniformPolicy(args.seed)` with
your policy (the line is marked `TODO`).
5. Train: `python -m wizard_ai train --episodes 50000 --out models/latest`.
6. Compare: `python -m wizard_ai evaluate --agents model,heuristic,heuristic,heuristic`.
7. Deploy: the checkpoint directory `wizard_ai/models/latest/` is mounted into
the container (`compose.yaml`); restart `wizard_ai`. With `AGENT=model`
(default) it is loaded automatically.
Model size matters: the service answers every AI move in a worker thread and
the server waits at most `AI_DECISION_TIMEOUT` (5 s).
## Training (`training/`)
### `WizardEnv`
A turn-based multi-agent environment around `WizardGame`:
```python
env = WizardEnv(num_players=(3, 4, 5, 6), options=GameOptions(), seed=1)
obs = env.reset() # observation of the player to act; samples a player count
while obs is not None:
result = env.step(action) # action for obs.player
result.rewards # per seat: score change / 100 when a round ended, else 0
result.done, result.events
obs = result.observation # next player to act (None when the game is over)
```
The sum of all rewards of a seat over a game equals its final score / 100
(tested).
### `SelfPlayTrainer`
`TrainingConfig` fields: `episodes`, `player_counts`, `options`,
`opponents` (`"self"` = every seat is the learning policy, `"heuristic"` /
`"random"` = one learning seat against those), `episodes_per_update`,
`checkpoint_dir`, `checkpoint_every`, `log_every`, `seed`.
Per episode (= one full game) it:
1. resets the env (random player count),
2. lets a `ModelAgent(policy, explore=True, recorder=…)` play the learning
seats, recording one `Transition` per decision,
3. adds each round's reward to that seat's most recent transition, and marks
each seat's last transition `done`,
4. collects the trajectories (one list per learning seat and game).
Every `episodes_per_update` episodes it calls `policy.update(batch)`, logs the
returned metrics, and saves a checkpoint every `checkpoint_every` episodes and
at the end.
### `evaluate(agents, games, options=None, seed=None)`
Plays `games` games with `len(agents)` players, rotating seats so every agent
starts in every position, and returns mean score and win rate per agent (ties
count as a win for everyone tied). Seeded for fair comparisons.
## Production service (`service/client.py`)
`AIService(server_url, agent, secret).run_forever()`:
- connects to `SERVER_URL` with `Authorization: Bearer <AI_SHARED_SECRET>`,
sends `hello`, then answers `decide` requests (protocol in
[chapter 5](05-protocol.md#ai-protocol));
- each request runs `agent.act` in a worker thread (`asyncio.to_thread`) behind
a lock, so a slow model does not block the websocket and non-thread-safe
agents stay consistent;
- errors while deciding are reported back as `error` (the server then plays a
random move) instead of crashing;
- reconnects forever with exponential backoff (1 s … 30 s); logs a hint if the
server rejects the secret.
## Command line
```bash
cd wizard_ai
python -m wizard_ai serve [--server-url …] [--agent model|heuristic|random] [--model-path …]
python -m wizard_ai train --episodes 5000 [--players 3,4,5,6] [--opponents self|heuristic|random]
[--hidden-bids] [--out models/latest] [--resume] [--seed N]
python -m wizard_ai evaluate --agents model,heuristic,random,random --games 200 [--seed N]
```
Environment variables (`config.py`): `SERVER_URL`
(default `ws://localhost:8000/internal/ai`; in Docker
`ws://wizard_server:8083/internal/ai`), `AI_SHARED_SECRET`, `AGENT`
(default `model`), `MODEL_PATH` (default `models/latest`, in Docker
`/srv/models/latest`), `LOG_LEVEL`.
## Tests (`wizard_ai/tests/test_ai.py`)
Feature length and masks for 3–6 players, action-space round trip, all agents
play legal full games, env rewards add up to scores, trainer runs and writes a
loadable checkpoint, `make_agent` fallback, heuristic beats random.
+209
View File
@@ -0,0 +1,209 @@
# 8. Deployment, development & operations
## Docker
### Images
| Service | Dockerfile | Build context | Listens on | Notes |
|---------|-----------|---------------|------------|-------|
| `wizard_website` | `wizard_website/Dockerfile` | `wizard_website/` | 8082 (nginx) | multi-stage: node build → nginx; build arg `VITE_WS_URL` (optional) |
| `wizard_server` | `wizard_server/Dockerfile` | `wizard_server/` | 8083 (uvicorn) | installs `requirements.txt`, then `game_logic/`; runs as non-root; health check on `/health` |
| `wizard_ai` | `wizard_ai/Dockerfile` | `wizard_ai/` **plus** named context `game_logic` | – | installs the game engine from the extra context; runs `python -m wizard_ai serve` |
The AI image needs the game engine, which lives in the server folder. Compose
passes it as an additional build context (requires docker compose ≥ 2.17):
```yaml
build:
context: ./wizard_ai
additional_contexts:
game_logic: ./wizard_server/game_logic
```
Building it by hand: `docker build --build-context
game_logic=wizard_server/game_logic wizard_ai`.
### Networks
| Network | Type | Members |
|---------|------|---------|
| `wizard_website` | external (shared with Caddy) | `wizard_website` |
| `wizard_api` | external (shared with Caddy) | `wizard_server` |
| `wizard_internal` | created by compose, `internal: true` (no internet) | `wizard_server`, `wizard_ai` |
No ports are published to the host; only Caddy reaches the containers.
### Environment (`.env` next to `compose.yaml`)
Compose reads `.env` automatically; `.env.example` is only a template and is
not read by anything.
| Variable | Used by | Required | Meaning |
|----------|---------|----------|---------|
| `AI_SHARED_SECRET` | server + AI | **yes** (compose refuses to start without it) | shared secret for `/internal/ai` |
| `ALLOWED_ORIGINS` | server | no | e.g. `https://game.marvinkrausser.com` |
| `AI_AGENT` | AI | no (default `model`) | `model`, `heuristic` or `random` |
| `LOG_LEVEL` | server + AI | no | `INFO`, `DEBUG`, … |
Further server settings (`AI_MOVE_DELAY`, `TRICK_PAUSE`, …, see
"Configuration" in [the server chapter](04-server.md)) can be added to
the `environment:` section of `wizard_server` in `compose.yaml`.
### AI models
`./wizard_ai/models` is mounted read-only to `/srv/models`; the service loads
`/srv/models/latest/` (a policy checkpoint directory). Put a new checkpoint
there and restart `wizard_ai` — no rebuild needed. Without a checkpoint the
heuristic agent is used.
### Deploying
```bash
cp .env.example .env # once; set AI_SHARED_SECRET
docker compose up -d --build # build and (re)start everything
docker compose up -d --build wizard_website # only one service
docker compose logs -f wizard_server
docker compose ps # server shows "healthy" when /health answers
```
## Reverse proxy (Caddy)
The existing Caddy container serves the game under `game.marvinkrausser.com`:
```caddy
game.marvinkrausser.com {
import common
redir /wizard /wizard/website/ permanent
redir /wizard/ /wizard/website/ permanent
redir /wizard/website /wizard/website/ permanent
@wizard_api path /wizard/api /wizard/api/*
handle @wizard_api {
uri strip_prefix /wizard/api
reverse_proxy wizard_server:8083
}
handle_path /wizard/website/* {
reverse_proxy wizard_website:8082
}
handle {
respond 404
}
}
```
What each part means for the app:
- The website must be opened with a trailing slash (`/wizard/website/`) so
relative asset paths resolve — the redirects take care of that.
- Both prefixes are stripped, so the containers do not know about `/wizard/…`.
The frontend finds the API through the relative URL `../api/ws`.
- Websockets are proxied automatically by Caddy.
- `/wizard/api/internal/ai` is reachable through this block, but the server
refuses it because the request carries Caddy's forwarding headers.
URLs: game `https://game.marvinkrausser.com/wizard/website/`, health check
`https://game.marvinkrausser.com/wizard/api/health`.
## Local development
One Python virtual environment for server and AI:
```bash
python -m venv .venv && . .venv/bin/activate
pip install -r wizard_server/requirements-dev.txt -r wizard_ai/requirements-dev.txt
```
`requirements-dev.txt` installs the game engine **editable** (`-e`), so changes
to `wizard_game` are picked up immediately.
Run the three parts in three terminals:
```bash
# 1. server on :8000 (add AI_MOVE_DELAY=0 TRICK_PAUSE=0 to make AI players instant)
cd wizard_server && AI_SHARED_SECRET=dev uvicorn app.main:app --reload
# 2. AI service
cd wizard_ai && AI_SHARED_SECRET=dev SERVER_URL=ws://localhost:8000/internal/ai python -m wizard_ai serve
# 3. website on :5173 (proxies /api → localhost:8000 without the /api prefix)
cd wizard_website && npm install && npm run dev
```
Open <http://localhost:5173>. To play as several humans, use several browser
tabs (each tab is its own session). Without step 2 the game still works; AI
seats then play random moves.
## Tests and checks
```bash
cd wizard_server && pytest # game engine (game_logic/tests) + server (tests/)
cd wizard_ai && pytest
cd wizard_website && npm run lint && npm run build
```
| Suite | Count | Covers |
|-------|-------|--------|
| game engine | 52 | rules, full random games for 3–6 players, invariants, JSON |
| server | 9 | websocket flows incl. a complete game, reconnect, AI endpoint security |
| AI | 13 | encoding, agents, env, trainer, checkpoints |
## Troubleshooting
| Symptom | Cause / fix |
|---------|-------------|
| Browser console: *Refused to apply style … MIME type ('')*, 404 for `/assets/…` | The page loads assets from the wrong path. The build must use relative paths (`base: './'` in `vite.config.ts`) and the page must be opened with a trailing slash. Rebuild the website image after changing the Vite config. |
| Red banner "Connection lost. Reconnecting…" forever | The websocket URL does not reach the server. Check `https://…/wizard/api/health`; check that the Caddy block strips `/wizard/api`; check `ALLOWED_ORIGINS` (must contain the exact page origin, e.g. `https://game.marvinkrausser.com`). |
| Banner "This game was opened in another window" | The same session (token) was opened in a second tab; reload this tab to take it back. |
| Lobby shows "The AI service is offline" | `wizard_ai` is not connected. `docker compose logs wizard_ai`: *Server rejected the connection, check AI_SHARED_SECRET* → secrets differ; connection refused → server not running or wrong `SERVER_URL`. |
| AI players only make weird/random moves | Same as above (random fallback), or the AI answers too slowly (`AI_DECISION_TIMEOUT`), see server logs "Playing random move for player …". |
| Game waits and nothing happens | It is a disconnected human's turn (seat shows "offline"). The game waits until they reconnect or their session expires (`SESSION_TTL`, then an AI takes over). |
| All lobbies gone | The server restarted — state is in memory only. |
| Tests pass but behaviour does not change after editing `wizard_game` | A non-editable copy of `wizard_game` is installed in your virtualenv and shadows the source. `pip uninstall wizard-game && pip install -e wizard_server/game_logic`. Check with `python -c "import wizard_game; print(wizard_game.__file__)"`. |
| Server tests hang | A test opened a websocket without a context manager; use `client.sockets.enter_context(client.websocket_connect(...))`. |
## Recipes
### Add a new game option (e.g. a rule variant)
1. `wizard_game/state.py`: add a field to `GameOptions` and to `to_dict` /
`from_dict`.
2. `wizard_game/game.py`: implement the behaviour using
`self.options.<field>`; add tests in `game_logic/tests/test_game.py`.
3. `wizard_server/app/protocol.py`: add the field to `SetOptions`;
`service.py`: pass it into `GameOptions(...)`.
4. `wizard_website/src/api/types.ts`: add it to `GameOptions` and the
`set_options` message; `LobbyScreen.tsx`: add a checkbox (copy the
"Hidden bids" one).
5. `wizard_ai/wizard_ai/model/encoding.py`: add a feature (and update
`FEATURE_SIZE`) if the AI should know about it; `__main__.py`: add a
`--flag` to `train`.
### Add a new client message
1. `protocol.py`: new pydantic model, add it to the `ClientMessage` union.
2. `service.py`: handle it in `_dispatch` (usually by calling a new `Lobby`
method and then `lobby.broadcast()`).
3. `types.ts`: add it to `ClientMessage`; `useWizard.ts`: add a function to
`actions`.
4. Add a test in `wizard_server/tests/test_server.py`.
### Change how fast AI players move
Set `AI_MOVE_DELAY` (per move) and `TRICK_PAUSE` (after a trick) for
`wizard_server` in `compose.yaml`.
### Add another AI agent
Subclass `Agent` in `wizard_ai/wizard_ai/agents/`, add it to `AGENT_NAMES`
and `make_agent` in `agents/__init__.py`, then use it via `AI_AGENT=<name>` or
in `evaluate --agents`.
### Change container ports
Website: `listen` in `wizard_website/nginx.conf` and `EXPOSE` in its
Dockerfile. Server: `--port`, `EXPOSE` and the health check URL in
`wizard_server/Dockerfile`, and `SERVER_URL` of `wizard_ai` in `compose.yaml`.
Then update the Caddy block.
+47
View File
@@ -0,0 +1,47 @@
# Wizard — developer documentation
This folder explains the whole codebase: what each part does, how the parts
talk to each other, and why things are built the way they are. It is written
to be read top to bottom the first time and used as a reference afterwards.
## Reading order
| # | Document | What you learn |
|---|----------|----------------|
| 1 | [Architecture overview](01-architecture.md) | The four components, how a move travels through the system, repository layout, key design decisions |
| 2 | [Game rules as implemented](02-game-rules.md) | Every rule of Wizard as the code implements it, with the exact function that enforces it |
| 3 | [Game engine (`wizard_game`)](03-game-engine.md) | The rules package: cards, the `WizardGame` state machine, observations, actions, events |
| 4 | [Game server (`wizard_server/app`)](04-server.md) | Sessions, reconnects, lobbies, running games, AI turns, broadcasting, configuration |
| 5 | [Protocol reference](05-protocol.md) | Every websocket message between browser ↔ server and server ↔ AI, with examples |
| 6 | [Frontend (`wizard_website`)](06-frontend.md) | The React app: connection hook, screens, components, styling, build |
| 7 | [AI player (`wizard_ai`)](07-ai.md) | Agents, feature encoding, policy interface, training, evaluation, production service — and how to plug in a real model |
| 8 | [Deployment, development & operations](08-operations.md) | Docker, networks, Caddy, environment variables, local setup, tests, troubleshooting, how-to recipes |
## The system in one paragraph
A **React website** lets players create or join a lobby and play. It talks to
the **Python game server** over one websocket per browser tab. The server keeps
all lobbies and games in memory and uses the **game engine** (`wizard_game`, a
dependency-free Python package) to enforce the rules. For every AI seat, the
server asks the **AI service** over an internal websocket which move to make;
the AI service answers using an `Agent` (today a heuristic, later a
reinforcement-learning model trained with the same game engine).
```mermaid
flowchart LR
B["Browser<br/>React app"] -- "wss /wizard/api/ws" --> C[Caddy]
B -- "https /wizard/website/" --> C
C -- "/ws, /health" --> S["wizard_server<br/>FastAPI :8083"]
C -- "/assets, /index.html" --> W["wizard_website<br/>nginx :8082"]
A["wizard_ai<br/>AI service"] -- "ws /internal/ai" --> S
S -. uses .-> G[("wizard_game<br/>rules engine")]
A -. uses .-> G
```
## Conventions used in these docs
- File references are relative to the repository root, e.g.
`wizard_server/app/lobby.py`.
- "Seat" or "player index" means the position `0 … n-1` of a player at the
table. The game engine only knows indices; names and ids live in the server.
- Code excerpts are shortened; the source files are the final truth.