# tavolo The backend for a multiplayer card-game platform, built on the [kaya](../kaya) framework. The first game implemented is **scopone scientifico**, the four-player, fixed-partnership variant of the classic Italian card game. Players authenticate with the configured **OIDC** provider. Live game state is kept in **Redis** (with real-time play over WebSocket), and completed matches — every participant and the final score — are persisted to **Postgres** for history and leaderboard statistics. ## Quick start (Docker Compose) ```sh # From the repository root (docker-compose.yml lives there): docker compose up --build ``` The stack starts Postgres, Redis, a mock OIDC provider (navikt/mock-oauth2-server) with four ready-made players (`alice`, `bob`, `carol`, `dave`), a one-shot database migration service, and the app itself on `http://127.0.0.1:8080`. The same container also serves the Sycamore/WASM frontend (built from `../web/` by the Docker image), so the UI is available at that address. Because the browser and the app both talk to the OIDC issuer at `http://mockoauth:8180/tavolo`, add a host entry once: ```sh echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts ``` Then log in: `GET http://localhost:8080/auth/login`, type any username (e.g. `alice`) in the interactive login, and you are redirected back with a session cookie. That cookie authenticates both the REST API and the WebSocket endpoint. OpenAPI documentation is served at `/api/docs` (`/api/openapi.json`). ## Configuration All configuration comes from environment variables (see `.env.example`): | Variable | Default | Description | |---|---|---| | `DATABASE_ENGINE` | `postgres` | Database DSN scheme/driver | | `DATABASE_HOST` | `localhost` | Postgres host | | `DATABASE_PORT` | unset | Postgres port; omitted from the DSN when empty (driver default, 5432 for Postgres) | | `DATABASE_NAME` | `tavolo` | Postgres database name | | `DATABASE_USER` | `tavolo` | Postgres user | | `DATABASE_PASSWORD` | `password` | Postgres password | | `DATABASE_OPTIONS` | unset | Extra DSN query parameters, e.g. `ssl=require` | | `DATABASE_URL` | unset | Full-DSN override; when set, the `DATABASE_*` parts above are ignored (used for sqlite in tests and for managed-DB URLs) | | `REDIS_URL` | unset | Redis DSN for sessions + live games. Unset falls back to in-memory stores | | `OIDC_ISSUER` | `http://localhost:8180/tavolo` | OIDC issuer URL | | `OIDC_CLIENT_ID` | `tavolo` | OIDC client id | | `OIDC_CLIENT_SECRET` | unset | OIDC client secret | | `OIDC_REDIRECT_URI` | `http://localhost:8080/auth/callback` | Login callback URL | | `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis | | `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the between-hands scoring summary waits for acknowledgements | | `TURN_TIMEOUT_SECONDS` | `30` | Seconds a player has to play before the server plays a random legal card for them | | `DEADLINE_HEARTBEAT_MS` | `1000` | Upper bound on the deadline consumer's poll interval (locally enqueued deadlines fire on time regardless) | | `LOGGING_CONFIG` | unset | Path to a YAML logging configuration file (see below). Unset logs DEBUG to the console | | `CORS_ALLOW_ORIGINS` | unset | Comma-separated origins allowed for cross-origin requests, or `*` for any. CORS is disabled unless this or `CORS_ALLOW_ORIGIN_REGEX` is set | | `CORS_ALLOW_ORIGIN_REGEX` | unset | Regex (fullmatch) additionally matched against request origins, e.g. `https://tavolo-[a-z0-9-]+\.vercel\.app` | | `CORS_ALLOW_METHODS` | `GET` | Comma-separated methods allowed for cross-origin requests, or `*` for all | | `CORS_ALLOW_HEADERS` | unset | Comma-separated request headers allowed in cross-origin requests, or `*` to mirror back the requested ones. The CORS-safelisted headers are always allowed | | `CORS_ALLOW_CREDENTIALS` | `false` | `1`/`true`/`yes`/`on` allow cookies/credentials on cross-origin requests | | `CORS_EXPOSE_HEADERS` | unset | Comma-separated response headers exposed to the browser | | `CORS_MAX_AGE` | `600` | Seconds browsers may cache the preflight response | | `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address | ## Logging The application logs through the Python stdlib `logging` module, one `getLogger(__name__)` per module: lifecycle and business events at INFO (game created/joined, websocket connections, match results, auto-plays), per-move and store detail at DEBUG. By default everything at DEBUG level goes to the console. Set `LOGGING_CONFIG` to the path of a YAML file to take over the configuration; the file follows the [`logging.config.dictConfig`](https://docs.python.org/3/library/logging.config.html#logging-config-dictschema) schema. Keep `disable_existing_loggers: false` — Granian configures its own loggers before importing the app, and disabling them would silence the server. Example for quieter production logs (WARNING for third parties, INFO for the application): ```yaml version: 1 disable_existing_loggers: false formatters: default: format: "{asctime} [{levelname}] ({processName:s}/{threadName:s}) - {name} - {message}" style: "{" handlers: console: class: logging.StreamHandler formatter: default root: level: WARNING handlers: [console] loggers: tavolo: level: INFO ``` ## Data model ### Redis (live games) - `tavolo:game:` — the whole match as JSON: players (seat 0/2 = team A, 1/3 = team B), hands, table, captured piles, scope, current turn, dealer, scores, phase (`lobby` → `playing` → `finished`). Sliding TTL (`GAME_TTL_SECONDS`). - `tavolo:code:` — the 6-character join code → game id index. - `tavolo:game::lock` — a short-lived lock serializing every mutation. - `tavolo:game::events` — a pub/sub channel carrying "state changed" signals; every open WebSocket reloads the state and pushes the personalized view to its player. - `tavolo:deadlines` — a sorted set (score = due timestamp) of pending timeouts: turn auto-plays and hand-end auto-continues. Every worker runs a consumer that fires due entries under the per-game lock, so timeouts do not depend on any player being connected and survive the death of any worker (delivery is at-least-once; entries are revalidated against the live state before firing). ### Postgres (statistics, via Tortoise ORM + aerich migrations) - `match` — one row per finished match: the game played (`game_type`, one of the ids from `GET /api/game-types`, indexed so statistics can be scoped per game), both teams' final scores, winner, target score, hands played, start/finish timestamps. - `match_player` — one row per participant: the OIDC `sub`, display name, seat, team and whether they won. Unique per `(match, user_sub)`. When a match ends, the result is written transactionally to Postgres (once, guarded by a flag on the Redis state); the finished state stays in Redis until its TTL expires so clients can still fetch the final board. ## REST API All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`, `/api/game-types` and `/api/leaderboard` require authentication. | Method | Path | Description | |---|---|---| | `GET` | `/api/game-types` | The card games the platform can host (for the creation dropdown) | | `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "target_score": 11, "napola": true}`. Returns `{id, join_code}` | | `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The fourth player triggers the deal | | `GET` | `/api/games/{id}` | Personalized snapshot (only your own hand is visible) | | `GET` | `/api/me/matches` | Cursor-paginated match history with final scores (`?limit=&cursor=&game_type=`) | | `GET` | `/api/leaderboard` | Aggregated wins / matches / team points per player (`?game_type=`) | ## WebSocket protocol Connect to `/ws/games/{game_id}` with your session cookie. Only seated players are accepted; unauthenticated, unknown-game and non-player connections are closed with codes `4401`, `4404` and `4403` respectively. Server → client messages are JSON objects with a `type`: - `{"type": "state", "game": {...}}` — personalized game view. Your own hand is included; other players expose only their card counts. `your_turn: true` is present when it is your move. - `{"type": "game_over", "scores": {"A": 11, "B": 7}, "winner": "A"}` - `{"type": "error", "code": "illegal_move", "message": "..."}` Client → server messages: ```json {"action": "play", "card": "07D", "capture": ["02D", "05C"]} {"action": "play", "card": "07D"} {"action": "ack"} {"action": "state"} ``` - `card` is the card you play, rendered as `RRSUIT` (`01A`..`10D`; suits `D` denari, `C` coppe, `S` spade, `B` bastoni — e.g. `07D` is the settebello). - `capture` lists the table cards to take. When a capture is legal it is mandatory to provide one; when no capture exists it must be omitted. - `ack` acknowledges the hand-end scoring summary (see below). The next hand is dealt once all four players have acknowledged, or automatically after `HAND_ACK_TIMEOUT_SECONDS`. - `state` asks for a fresh snapshot. After every accepted move the new state is broadcast to all four players. ### Turn timeout The state carries a `turn_deadline` while a hand is being played. If the player on turn does not move before it, the server plays a random legal card for them (picking one of the legal captures at random when a capture is required), so a disconnected or idle player cannot stall the match. The timeout is `TURN_TIMEOUT_SECONDS` (default 30); the auto-played move is broadcast like any other. Deadlines fire from the shared `tavolo:deadlines` queue (see above), not from timers tied to client connections, so the match keeps progressing even with every player disconnected. ### Hand-end summary When a hand finishes but the match continues, the game enters the `hand_end` phase instead of dealing immediately: the state carries `last_hand` (a full scoring breakdown with an `award` map naming the team that won each category), the `acknowledged` seats and a `hand_end_deadline`. The frontend renders this as a screen every player must dismiss. A play attempted in this phase is rejected with an `illegal_move` error. ## Rules implemented - 40-card Italian deck, ten cards per player, empty table at hand start. - A card captures a **single card of equal rank** (mandatory when present) or a **combination of table cards whose ranks sum to its own**. - Emptying the table scores a *scopa* (+1), except on the last play of a hand. Remaining table cards go to the last player who captured. - Hand points: `carte` (most captured cards), `denara` (most diamonds), `settebello` (7♦), `primiera` (best 7/6/5/4 per suit, all four suits required), plus one point per scopa. Ties award nothing. - Optional *napola* rule (per-game `napola` flag on `POST /api/games`, default on): the longest run of consecutive denari starting from the ace scores one point per card once it reaches three cards (A-2-3 = 3, A-2-3-4 = 4, …). A team that captures the whole denari suit (ace to king) wins the match instantly, regardless of the score. - The match ends when a team reaches the target score (default 11, configurable per game) with a clear lead; a tie at or above the target is broken by another hand. ## Development ```sh python3 -m venv .venv .venv/bin/pip install -r requirements.txt .venv/bin/pip install -e '.[dev]' .venv/bin/python -m unittest discover -s tests -t . .venv/bin/mypy src ``` Tests run fully in-process: in-memory sqlite for Postgres, in-memory stores for Redis, a fake OIDC user patched onto the mixins, and `httpx` / `httpx-ws` ASGI transports for HTTP and WebSocket coverage. ### Migrations The Postgres schema is owned by aerich migrations in `migrations/`. The `db-migrate` compose service runs `aerich upgrade` before the app starts. To add a migration after changing `src/tavolo/models.py`: ```sh DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo .venv/bin/aerich migrate DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo .venv/bin/aerich upgrade ``` (`aerich init-db` produces sqlite-flavored DDL when pointed at sqlite; adjust `UUID`/`TIMESTAMPTZ`/`BOOL`/`JSONB` for Postgres like the existing baseline.) ## Layout Everything lives under `server/`: ``` src/tavolo/ ├── app.py # composition root: session/OIDC/Tortoise/OpenAPI mixins ├── config.py # env -> frozen Settings ├── auth.py # auth helpers (HTTP + WebSocket) ├── http.py # JSON request/response helpers ├── pagination.py # keyset (cursor) pagination ├── openapi.py # shared OpenAPI parameter fragments ├── tortoise_mixin.py # TortoiseORM lifecycle (HTTP + WebSocket) ├── aerich_config.py # aerich CLI configuration ├── models.py # Match, MatchPlayer (Postgres) ├── stats.py # finished match -> Postgres persistence ├── store.py # Redis / in-memory live-game store (+ deadline queue) ├── deadlines.py # connection-independent timeout scheduler ├── ws.py # WebSocket live-play endpoint ├── game/ │ ├── state.py # GameState / PlayerState / Card, JSON (de)serialization │ ├── engine.py # pure scopone scientifico rules │ └── errors.py # typed rule violations └── routes/ ├── health.py # GET /api/health ├── games.py # lobby: create / join / snapshot └── stats.py # match history + leaderboard ```