- 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>
167 lines
9.7 KiB
Markdown
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. |
|