diff --git a/README.md b/README.md index 85b826f..256fdeb 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ An online version of the card game Wizard (rules: `wizard_instructions/`). +**Detailed documentation of the whole codebase: [`docs/`](docs/README.md).** + | Component | Path | Tech | |-----------|------|------| | Frontend | `wizard_website/` | React + TypeScript + Vite, CSS modules, served by nginx | @@ -18,7 +20,8 @@ browser ──wss /wizard/api/ws──────▶ Caddy ──/ws──▶ w 1. A player opens the website, enters a name and creates a lobby (and joins it). 2. The lobby shows a code (and an invite link, `?lobby=CODE`) others use to join. -3. The host can add AI players, toggle the *not equal* / *hidden bids* rules, and start the game (3–6 players). +3. The host can add AI players, toggle the *hidden bids* option, and start the game (3–6 players). + The sum of all bids may never equal the number of cards in hand (always on). 4. The server runs the game with `wizard_game` and sends every player a personal view (`Observation`) after each move. Reloading the page reconnects to the same seat. A player who leaves a running game is replaced by an AI player. diff --git a/docs/01-architecture.md b/docs/01-architecture.md new file mode 100644 index 0000000..7d12981 --- /dev/null +++ b/docs/01-architecture.md @@ -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
(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. | diff --git a/docs/02-game-rules.md b/docs/02-game-rules.md new file mode 100644 index 0000000..37dea05 --- /dev/null +++ b/docs/02-game-rules.md @@ -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"). diff --git a/docs/03-game-engine.md b/docs/03-game-engine.md new file mode 100644 index 0000000..5e3121f --- /dev/null +++ b/docs/03-game-engine.md @@ -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.` 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. diff --git a/docs/04-server.md b/docs/04-server.md new file mode 100644 index 0000000..000a129 --- /dev/null +++ b/docs/04-server.md @@ -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
routes, auth"] --> service["service.py
WizardService"] + service --> sessions["sessions.py
Session, Connection, SessionRegistry"] + service --> lobby["lobby.py
LobbyManager, Lobby, Seat"] + service --> protocol["protocol.py
message schema"] + service --> bridge["ai_bridge.py
AIBridge"] + lobby --> bridge + lobby --> engine[("wizard_game")] + bridge --> engine + main --> config["config.py
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 ` + (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,
start Connection writer + B->>S: {"type":"hello","token": } + 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
(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
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,
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. diff --git a/docs/05-protocol.md b/docs/05-protocol.md new file mode 100644 index 0000000..9b0860e --- /dev/null +++ b/docs/05-protocol.md @@ -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 ` 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":"","observation":{…}}` | choose an action for `observation.player` | +| AI → server | `{"type":"action","request_id":"","action":{"type":"play","card":17}}` | the chosen action | +| AI → server | `{"type":"error","request_id":"","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. diff --git a/docs/06-frontend.md b/docs/06-frontend.md new file mode 100644 index 0000000..86bbd29 --- /dev/null +++ b/docs/06-frontend.md @@ -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 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. diff --git a/docs/07-ai.md b/docs/07-ai.md new file mode 100644 index 0000000..cc6e4fd --- /dev/null +++ b/docs/07-ai.md @@ -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
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 `, + 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. diff --git a/docs/08-operations.md b/docs/08-operations.md new file mode 100644 index 0000000..1ac3327 --- /dev/null +++ b/docs/08-operations.md @@ -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 . 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.`; 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=` 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. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..ccf475b --- /dev/null +++ b/docs/README.md @@ -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
React app"] -- "wss /wizard/api/ws" --> C[Caddy] + B -- "https /wizard/website/" --> C + C -- "/ws, /health" --> S["wizard_server
FastAPI :8083"] + C -- "/assets, /index.html" --> W["wizard_website
nginx :8082"] + A["wizard_ai
AI service"] -- "ws /internal/ai" --> S + S -. uses .-> G[("wizard_game
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.