- New docs/09-limits-and-performance.md: principles, all protection layers, what players see, configuration reference, measurements, tuning guide, monitoring and how to test - Server chapter summarises the limits and links to chapter 9; circuit breaker description and AI_MAX_INFLIGHT default corrected - Architecture: new files in the layout, design decisions for load handling; docs index lists chapter 9 - Protocol, AI and operations chapters link to chapter 9; test counts - Root and component READMEs describe the limits and settings Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
201 lines
8.2 KiB
Markdown
201 lines
8.2 KiB
Markdown
# 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.
|
||
|
||
#### `server_status`
|
||
|
||
Sent right after `welcome` and to every connected client whenever the server's
|
||
load level or capacity changes.
|
||
|
||
```json
|
||
{"type": "server_status", "load": "overloaded", "accepting_games": false}
|
||
```
|
||
|
||
- `load`: `normal`, `busy` (informational) or `overloaded`.
|
||
- `accepting_games`: `false` while the server refuses `create_lobby` and
|
||
`start_game` (overloaded, or `MAX_ACTIVE_GAMES` games running). Everything
|
||
else, including running games, keeps working.
|
||
|
||
#### `error`
|
||
|
||
```json
|
||
{"type": "error", "message": "Only the host can do that"}
|
||
{"type": "error", "message": "The server is busy right now, …", "code": "busy"}
|
||
```
|
||
|
||
`code` is present for errors the client may react to:
|
||
|
||
| `code` | Meaning |
|
||
|--------|---------|
|
||
| `busy` | no capacity: connection limit reached (followed by close 1013), or `create_lobby` / `start_game` refused because the server does not accept new games |
|
||
| `rate_limited` | too many messages on this connection, or too many lobbies created from this IP |
|
||
|
||
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`, `Too many messages, slow down.`,
|
||
`You are creating lobbies too quickly, please wait a moment.`
|
||
|
||
### 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 |
|
||
| 1008 | message flood (too many dropped messages) | reconnects with backoff |
|
||
| 1009 | message larger than 64 KB (uvicorn `--ws-max-size`) | reconnects with backoff |
|
||
| 1013 | "Try Again Later": connection limit reached (preceded by an `error` with code `busy`) | banner "The server is full", retries slowly (15 s, 30 s, 60 s …) |
|
||
| 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>","timeout":5.0,"observation":{…}}` | choose an action for `observation.player`; the server waits at most `timeout` seconds |
|
||
| AI → server | `{"type":"action","request_id":"<same>","action":{"type":"play","card":17},"agent":"heuristic"}` | the chosen action; `agent` names the agent that decided (logged) |
|
||
| AI → server | `{"type":"error","request_id":"<same>","message":"…"}` | the AI failed or refused (`overloaded`, `expired`); 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. The AI service
|
||
drops requests whose `timeout` passed before it could start them (`expired`).
|
||
- The server stops asking an AI connection that already has `AI_MAX_INFLIGHT`
|
||
unanswered requests, and pauses the AI after repeated real failures (circuit
|
||
breaker). `overloaded` / `expired` answers are load shedding, not failures,
|
||
and do not trip the breaker. See
|
||
"Layer 4" in [chapter 9](09-limits-and-performance.md).
|
||
- Several AI service instances may connect; the server distributes requests
|
||
round robin.
|