Files
Wizard/docs/01-architecture.md
marvinandClaude Opus 5.5 9ffee8b952 Document limits, load control and performance
- 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>
2026-09-27 13:04:21 +02:00

167 lines
9.7 KiB
Markdown

# 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, limits and load control |
| 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, CPU/memory caps
├── .env.example # template for .env (secret, resource limits, capacity)
├── 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, circuit breaker
│ │ ├── limits.py # TokenBucket, rate and connection limiters
│ │ └── load.py # LoadMonitor: detects overload (lag, CPU, cgroup throttling)
│ ├── tests/ # conftest.py/helpers.py (fixtures), test_server.py,
│ │ # test_protection.py (limits/overload), test_limits.py (units)
│ └── 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 (workers, queue, degradation)
│ └── 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. |
| Refuse new games under load, never interrupt running ones | Players in a game are never punished for load; creating/starting games is what adds load, so that is what gets refused ("server busy"). Refusing sheds load by itself. See [chapter 9](09-limits-and-performance.md). |
| Hard Docker caps plus software limits | The caps guarantee the host is protected even if a software limit fails; the software limits make overload visible and graceful instead of just slow. |
| Health check stays "ok" under load | Docker must never restart an overloaded server — that would end every running game. |