Files
Wizard/docs/04-server.md
T
2026-09-27 11:26:10 +02:00

12 KiB
Raw Blame History

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

flowchart TB
    main["main.py<br/>routes, auth"] --> service["service.py<br/>WizardService"]
    service --> sessions["sessions.py<br/>Session, Connection, SessionRegistry"]
    service --> lobby["lobby.py<br/>LobbyManager, Lobby, Seat"]
    service --> protocol["protocol.py<br/>message schema"]
    service --> bridge["ai_bridge.py<br/>AIBridge"]
    lobby --> bridge
    lobby --> engine[("wizard_game")]
    bridge --> engine
    main --> config["config.py<br/>Settings"]
Module Main types Role
main.py create_app() builds the FastAPI app, defines the three routes, starts the cleanup task
config.py Settings, settings configuration from environment variables
protocol.py pydantic models, client_message_adapter validates messages from browsers
service.py WizardService the "controller": handles a browser connection, dispatches messages, manages sessions and cleanup
sessions.py Session, Connection, SessionRegistry player identity across reconnects; ordered sending
lobby.py LobbyManager, Lobby, Seat, LobbyError lobby membership, host actions, running the game, AI turns, broadcasting
ai_bridge.py AIBridge, AIConnection talks to the AI service; random fallback

Routes (main.py)

Caddy strips /wizard/api, so these are the paths the server sees:

Route Used by Purpose
GET /health Docker health check, you {"status":"ok","lobbies":…,"sessions":…,"ai_available":…}
WS /ws browsers the game protocol (see protocol)
WS /internal/ai AI service decision requests

/internal/ai is protected twice:

  1. _via_proxy() rejects the connection if it carries forwarding headers (X-Forwarded-For, Forwarded, Via). Caddy always adds them, so requests from the internet via …/wizard/api/internal/ai are refused. The AI service connects directly over the internal network and has none.
  2. _ai_authorized() requires Authorization: Bearer <AI_SHARED_SECRET> (compared with secrets.compare_digest). If the secret is empty, authentication is disabled — only for development; a warning is logged at startup.

create_app(config) takes a Settings object so tests can build apps with different configuration (e.g. zero delays).

Core concepts

Session — "who is this browser tab?"

sessions.Session:

Field Meaning
player_id public id (12 hex chars), shown to other players, used as seat id
token secret (secrets.token_urlsafe(24)), stored by the browser in sessionStorage, used to reconnect
name display name, set when creating/joining a lobby
lobby_code lobby the player is in, or None
connection the current Connection, or None while disconnected
last_seen monotonic time of the last disconnect/connect, for expiry

SessionRegistry maps token → session. Sessions without a connection for SESSION_TTL seconds (default 30 min) are removed by the cleanup task; if they were in a lobby they leave it (see below).

Connection — ordered sending

sessions.Connection wraps one websocket and owns an asyncio.Queue. A background writer task sends queued messages one by one. Therefore:

  • session.send(msg) is synchronous (just enqueues), so game logic never awaits while mutating state;
  • messages to one client are always in order and never sent concurrently.

Lobby and seats

lobby.Seat = one place at the table:

Field Meaning
player_id session's player id, or ai-xxxxxxxx for AI seats
name unique within the lobby ("Ann", "Ann (2)", …)
is_ai AI seats are played via the AIBridge
session the human's session (or None)

The seat index in lobby.seats is the player index in the game. Seats are never removed or reordered while a game runs, so indices stay valid.

lobby.Lobby fields: code (6 chars from ABCDEFGHJKLMNPQRSTUVWXYZ23456789, no 0/O/1/I), seats, host_id, options (GameOptions), status (waiting → playing → finished → back to waiting), game (WizardGame or None).

LobbyManager creates lobbies (up to MAX_LOBBIES), finds them by code (case-insensitive) and deletes a lobby as soon as no human seat is left (discard_if_abandoned).

Lifecycle of a browser connection (WizardService.handle_client)

sequenceDiagram
    participant B as Browser
    participant S as WizardService
    B->>S: websocket connect /ws
    Note over S: Origin check (ALLOWED_ORIGINS), accept,<br/>start Connection writer
    B->>S: {"type":"hello","token": <stored token or null>}
    alt token known
        S->>S: reuse session (kick an older tab with close code 4000)
    else
        S->>S: create new session
    end
    S-->>B: {"type":"welcome","player_id","token","name"}
    S-->>B: {"type":"state", …} (lobby + game if still seated, else nulls)
    loop every message
        B->>S: create_lobby / join_lobby / action / …
        S->>S: validate (pydantic) → _dispatch → Lobby method
        S-->>B: state broadcast, or {"type":"error"}
    end
    B--xS: disconnect
    Note over S: session.connection = None, lobby broadcast<br/>(seat shows "offline"), the seat is kept

Rules applied in _hello and _dispatch:

  • Every message except hello requires a completed hello on this connection.
  • Invalid JSON/fields → error message with the pydantic reason; the connection stays open.
  • LobbyError (rule violations like "Only the host can do that") → error message to that client only.
  • Opening the same session in a second tab takes it over: the old tab gets an error and is closed with code 4000; the frontend then stops reconnecting and shows "opened in another window".

Message handling (_dispatch)

Message What happens
create_lobby {name} leave current lobby, set name, create lobby, join it (creator becomes host)
join_lobby {code, name} lobby must exist; leave another lobby first; join. Joining is only possible while the lobby is waiting and has < 6 seats — except re-joining your own seat
leave_lobby leave (see below), receive empty state
add_ai host only, lobby waiting and not full; adds an AI seat named after a famous wizard (Merlin, Morgana, …)
remove_player {player_id} host only, lobby waiting; the removed human gets an error + empty state
set_options {hidden_bids} host only, lobby waiting
start_game host only, 3–6 seats; creates WizardGame(len(seats), options)
action {action} lobby must be playing; the engine validates the action
return_to_lobby host only, game must be finished; status back to waiting, game discarded

Leaving

Lobby.leave(session):

  • In a waiting lobby the seat is removed.
  • During a playing or finished game the seat is kept and converted to an AI seat (new ai-… id, same name), so the game continues.
  • If the host leaves, the next human seat becomes host.
  • If no human seat remains, the lobby is deleted (its AI task is cancelled).

Disconnecting (closing the tab, network loss) is not leaving: the seat stays human and the game waits for that player. After SESSION_TTL without reconnect, the cleanup task makes the session leave (→ AI takes over).

Running a game

Broadcasting state

Lobby.broadcast(events) sends every seated human their own message built by Lobby.state_for(session, events):

{
  "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

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:

flowchart TD
    A{"current player<br/>is an AI seat?"} -- no --> Z[stop]
    A -- yes --> B["obs = game.observation(seat)"]
    B --> C["action = await bridge.decide(obs)"]
    C --> D["wait until max(start + AI_MOVE_DELAY, pause_until)"]
    D --> E{"same game, same seat,<br/>action still legal?"}
    E -- no --> A
    E -- yes --> F["game.apply → _after_action"]
    F --> A
  • The AI "thinks" in parallel with the minimum delay, so a slow AI does not add to the delay.
  • TRICK_PAUSE gives humans time to see a completed trick before AI players continue.
  • The re-validation step makes the loop safe against state changes while it was awaiting (e.g. the host returned to the lobby).
  • Several consecutive AI seats are handled by the same loop.

Concurrency model

Everything runs on one asyncio event loop. State changes (join, apply action, …) are synchronous — there is no await between checking and mutating — so no locks are needed. The only awaits are in the AI loop (decide, sleep), and it re-validates afterwards. This is also why the server must run with one worker process: a second process would have its own, separate lobbies.

AI bridge (ai_bridge.py)

The AI service keeps a websocket open to /internal/ai. AIBridge:

  • keeps a list of connected AI services (available = at least one);
  • decide(observation): picks a connection round robin, sends {"type":"decide","request_id","observation"}, and awaits a future that serve() resolves when the matching reply arrives;
  • falls back to a random legal action if no service is connected, the reply does not arrive within AI_DECISION_TIMEOUT, the service reports an error, the action is malformed or illegal (logged as warning, except the "no service connected" case which is logged at debug level);
  • calls on_change when a service connects/disconnects, and the service then re-broadcasts all lobbies so the lobby screen shows whether the AI is online.

Configuration (config.py)

All values are read from environment variables when the module is imported.

Variable Default Meaning
AI_SHARED_SECRET empty secret the AI service must send; empty = no auth (dev only)
AI_DECISION_TIMEOUT 5.0 s wait for the AI before playing a random move
AI_MOVE_DELAY 0.8 s minimum time an AI move takes, so humans can follow
TRICK_PAUSE 1.8 s pause after a completed trick before AIs continue
SESSION_TTL 1800 s disconnected sessions are dropped after this
MAX_LOBBIES 1000 limit of simultaneously open lobbies
ALLOWED_ORIGINS empty (= all) comma separated allowed Origin headers for /ws
LOG_LEVEL INFO Python logging level

Housekeeping

WizardService.cleanup_loop() runs every 30 s (started in the app lifespan) and removes expired sessions (leaving their lobby first).

Tests (wizard_server/tests/test_server.py)

Uses FastAPI's TestClient websockets with zero delays. Covers: health, hello requirement, validation errors, the full lobby flow including a complete 20-round game between two humans and an AI, reconnect by token, leaving mid-game (seat becomes AI, host moves), the AI endpoint's secret and proxy checks, and a fake AI service answering decide requests.

Note for writing new tests: enter websocket sessions with client.sockets.enter_context(client.websocket_connect(...)) (see the fixture), otherwise the test client hangs on shutdown.

Limitations to be aware of

  • In-memory state: restarting the server ends all lobbies and games.
  • Must run as a single process (no --workers).
  • No spectators: only seated players receive game state.
  • No rate limiting on websocket messages.