added docs
This commit is contained in:
@@ -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. |
|
||||
@@ -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").
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user