# tavolo (backend) The backend for a multiplayer card-game platform, built on the [kaya](../kaya) framework. It is a monorepo of five distributions sharing the `tavolo` namespace (see [`packages/tavolo-platform/README.md`](packages/tavolo-platform/README.md), [`packages/tavolo-scopone/README.md`](packages/tavolo-scopone/README.md), [`packages/tavolo-scopa/README.md`](packages/tavolo-scopa/README.md) and [`packages/tavolo-briscola/README.md`](packages/tavolo-briscola/README.md)): - **`tavolo-platform`** (`packages/tavolo-platform/`) — everything game-independent: the lobby API, the live-game store, the WebSocket endpoint, the deadline scheduler, match persistence and the Elo leaderboards, plus the `GameEngine` contract games implement. - **`tavolo-scopone`** (`packages/tavolo-scopone/`) — scopone scientifico, the four-player, fixed-partnership variant of the classic Italian card game: pure rules plus the platform adapter. - **`tavolo-scopa`** (`packages/tavolo-scopa/`) — scopa, the classic Italian fishing game, in its two-player (`scopa`) and four-player fixed-partnership (`scopa_a_quattro`) variants, with the optional asso pigliatutto rule (scopa d'assi): pure rules plus the platform adapters. - **`tavolo-briscola`** (`packages/tavolo-briscola/`) — briscola, the Italian trick-taking game, in its classic two-player (`briscola`), four-player partnership (`briscola_a_quattro`) and five-player called-partner (`briscola_chiamata`) variants: pure rules plus the platform adapter. - **`tavolo-app`** (`packages/tavolo-app/`) — the composition root wiring the platform to the games, with the environment-driven settings and the SPA shell. 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 game-specific result — 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` (a provider-agnostic alias that redirects to the first configured provider), 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`); every endpoint documents its request body and its response bodies as named component schemas. ## 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__PROVIDER` | `default` ¹ | Provider name (required when OIDC is configured): used in the login URL `/auth//login` and to namespace its session keys. Unique, `[A-Za-z0-9_-]+` | | `OIDC__ISSUER` | `http://localhost:8180/tavolo` ¹ | OIDC issuer URL | | `OIDC__CLIENT_ID` | `tavolo` ¹ | OIDC client id | | `OIDC__CLIENT_SECRET` | unset | OIDC client secret; leave unset for public clients | | `OIDC__REDIRECT_URI` | `http://localhost:8080/auth//callback` | Login callback URL; must be registered at the provider | | `OIDC__POST_LOGIN_REDIRECT` | `/` | Where the browser is sent after login | | `OIDC__POST_LOGOUT_REDIRECT` | `/` | Where the browser is sent after logout | | `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 (wired into the game engines) | | `TURN_TIMEOUT_SECONDS` | `30` | Seconds a player has to play before the server plays a random legal card for them (wired into the game engines) | | `DEADLINE_HEARTBEAT_MS` | `30000` | Backstop poll interval of the deadline consumer; enqueued deadlines are announced over pub/sub and wake consumers immediately, so this only bounds recovery from lost notifications or a dead worker | | `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 | | `OTEL_ENABLED` | `false` | `1`/`true`/`yes`/`on` enable OpenTelemetry traces, metrics and logs (the Docker image bundles the `otel` extra; non-image installs need `uv sync --package tavolo-app --extra otel`) | | `OTEL_SERVICE_NAME` | `tavolo` | `service.name` resource attribute of the exported telemetry | | `OTEL_EXPORTER_OTLP_ENDPOINT` | unset | Base URL of an OTLP/HTTP collector (e.g. `http://localhost:4318`); unset uses the exporter default | | `OTEL_EXPORTER_OTLP_HEADERS` | unset | Comma-separated `key=value` headers sent to the collector (e.g. authentication) | | `OTEL_EXCLUDED_PATHS` | `/api/health` | Comma-separated paths excluded from tracing and metrics (exact matches) | | `OTEL_METRIC_EXPORT_INTERVAL_MILLIS` | `60000` | How often the OTLP metric reader exports | | `OTEL_METRICS_POLL_INTERVAL_SECONDS` | `15` | How often the session/game count gauges are refreshed | | `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address | ¹ When no `OIDC__*` variable is set at all, a single provider named `default` pointing at the dev mock IdP is used. Indices must be contiguous from 0 (`OIDC_0_*`, `OIDC_1_*`, ...). ### Multiple OIDC providers Providers are configured in indexed blocks; each becomes its own login route `/auth//login` with its own callback URL and session namespace: ```sh OIDC_0_PROVIDER=google OIDC_0_ISSUER=https://accounts.google.com OIDC_0_CLIENT_ID=... OIDC_0_CLIENT_SECRET=... OIDC_0_REDIRECT_URI=https://tavolo.example.com/auth/google/callback OIDC_1_PROVIDER=github OIDC_1_ISSUER=https://github.com/login/oauth OIDC_1_CLIENT_ID=... OIDC_1_CLIENT_SECRET=... OIDC_1_REDIRECT_URI=https://tavolo.example.com/auth/github/callback ``` The login page reads `GET /api/auth/providers` and shows one button per provider. `GET /auth/login` redirects to the first provider (useful for bookmarks and scripts), and `GET /auth/logout` signs out whichever provider the session is logged in with. Sessions hold a single identity: logging in with another provider replaces the previous one. Each provider's `sub` is a separate account, so match history and Elo ratings are per provider identity (someone using two providers has two accounts). Migrating from the pre-0.0.6 unindexed variables: rename `OIDC_ISSUER`/`OIDC_CLIENT_ID`/... to `OIDC_0_...`, add `OIDC_0_PROVIDER`, and register the new callback path (`/auth//callback`) at the provider. Sessions survive only when the provider is named `default`; otherwise users log in once more. ## 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 ``` ## OpenTelemetry With `OTEL_ENABLED` set, the app exports over OTLP/HTTP (kaya-otel plus `tavolo.telemetry`): - **Traces**: HTTP server spans, one span per WebSocket connection, plus database spans for the Postgres (asyncpg, through Tortoise) and Redis clients. Redis command values are redacted by the instrumentation (`GET ? ?`) and asyncpg spans carry the parameterized SQL only — no parameter values. Each HTTP/WebSocket span is tagged with the request's session and user, so all traces of a session can be found together: `session.id` is a truncated SHA-256 of the session id (the cookie is a credential, so it is never exported verbatim) and `user.id` is the OIDC `sub`, present once logged in. - **Metrics**: the HTTP server metrics from kaya-otel, plus `tavolo.active_users` (sessions in the session store), `tavolo.ongoing_games` (live matches not finished yet, maintained by the game store), and process/CPython metrics from `opentelemetry-instrumentation-system-metrics`: `process.cpu.time`, `process.cpu.utilization`, `process.memory.usage`, `process.memory.virtual`, `cpython.gc.collections`, `cpython.gc.collected_objects` and `cpython.gc.uncollectable_objects`. - **Logs**: forwarded from the root logger at the configured console level (OpenTelemetry's own records are filtered out to avoid export loops). The session/game gauges are polled every `OTEL_METRICS_POLL_INTERVAL_SECONDS` (Redis-backed stores report cluster-wide counts, in-memory stores the process's own). Note that with multiple workers or replicas each process reports the same global counts: aggregate with `max`, not `sum`. ## Data model ### Redis (live games) - `tavolo:game:` — the whole session as JSON: the platform-owned envelope (`id`, `game_type`, `join_code`, seats, timestamps) plus the game-specific `state` blob, which only the registered engine interprets. 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 session and pushes the personalized view to its player. - `tavolo:deadlines` — a sorted set (score = due timestamp) of pending timeouts (e.g. the games' turn auto-plays and hand-end auto-continues). Every worker runs a consumer that fires due entries under the per-game lock by calling the engine's `fire_deadline`, so timeouts do not depend on any player being connected and survive the death of any worker (delivery is at-least-once; engines revalidate the entry's token against the live state before firing). - `tavolo:deadlines:events` — a pub/sub channel announcing newly enqueued deadlines; it wakes every worker's consumer immediately, while the heartbeat poll remains as a backstop for lost signals. ### 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), start/finish timestamps, and the game-specific outcome as reported by the engine (`result` JSON — for scopone: both teams' final scores, winner, target score, hands played and the per-hand audit; for scopa: both sides' final scores, winner, target score, the asso pigliatutto flag, hands played and the per-hand audit; for briscola: the final scores, winner, target score, deals played and the per-deal audit; for briscola chiamata: the calling side's points against the defenders', the declared target, the called card and whether the caller played alone). - `match_player` — one row per participant: the OIDC `sub`, display name, seat, game-defined team label (nullable), whether they won, the points they scored, the Elo change the match produced (`elo_delta`) and game-specific extras (`details` JSON). Unique per `(match, user_sub)`. - `player_rating` — current Elo rating and leaderboard totals per `(user_sub, game_type)`: the rating, matches played, wins, accumulated points and the most recent display name. Kept in sync with the match transaction, so the leaderboard is a single indexed table query. When a match ends, the engine's result is written transactionally to Postgres (once, guarded by a flag on the session); the finished session stays in Redis until its TTL expires so clients can still fetch the final board. ### Elo ratings Players carry a chess-style Elo rating per game type (`tavolo.platform.elo`): everyone starts at 1500, a team's rating is the mean of its members, and the standard formula `E = 1 / (1 + 10 ** ((R_opp - R_team) / 400))` with `K = 32` decides how many points the match result moves — the same delta for every member of a team, zero-sum between the two teams. Ratings and the leaderboard aggregates update in the same transaction as the match result. To rebuild every rating and aggregate from the recorded match history (e.g. to backfill matches recorded before ratings existed): ```sh uv run python -m tavolo.platform.backfill_elo \ --database-url postgres://tavolo:tavolo@localhost:5432/tavolo ``` ## REST API All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`, `/api/game-types`, `/api/leaderboard` and the game snapshot (`GET /api/games/{id}`) require authentication. | Method | Path | Description | |---|---|---| | `GET` | `/api/game-types` | The games the platform can host (for the creation dropdown), each with its `options_schema` | | `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "options": {"target_score": 21, "napola": true, "asso_pigliatutto": false}}`; scopa games use `{"game_type": "scopa"}` (target default 11) or `{"game_type": "scopa_a_quattro"}` (target default 21) with an optional `{"target_score": 11, "asso_pigliatutto": false}` (the asso pigliatutto rule makes it scopa d'assi); briscola games use `{"game_type": "briscola"}` or `{"game_type": "briscola_a_quattro"}` with an optional `{"target_score": 61}`, and briscola chiamata uses `{"game_type": "briscola_chiamata"}` with no options. Returns the lobby (`{id, join_code, players, seats_open, ...}`) | | `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The last player triggers the start and gets the game view back | | `GET` | `/api/games/preview?code=ABC123` | Pre-join summary of the lobby behind a code: game name/description, seated players, open seats and the engine-echoed options. `404` when the code is unknown | | `GET` | `/api/games/{id}` | Game snapshot. Seated players get the personalized view (own hand plus the join code); anyone else gets the read-only spectator view: table, scores, captured counts and scope, no hands and no join code | | `GET` | `/api/me/games` | Live games the caller is seated in (waiting lobbies, matches in progress or between hands), newest first, for the lobby's "Continue" list. Finished matches are not included | | `GET` | `/api/me/matches` | Cursor-paginated match history with the engine's `result` and per-player Elo deltas (`?limit=&cursor=&game_type=`) | | `GET` | `/api/me/ratings` | The caller's Elo rating per game type | | `GET` | `/api/leaderboard` | Elo rating, wins / matches / points per rated player, sorted by Elo (`?game_type=`, `?page=`, `?limit=`, `?focus=me`). The response carries the requested page, the page count and — when authenticated — the caller's own rank and page; `focus=me` returns the caller's page directly | ## WebSocket protocol Connect to `/ws/games/{game_id}`; the session cookie is optional. Anyone who knows the game id is accepted: seated players get their personalized view, everyone else — anonymous users and authenticated non-players — is a read-only **spectator**. Only an unknown game id is refused before the handshake completes, with `404`. (Browsers report a refused handshake as an abnormal close — there is no close code to read.) 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. Spectators get no hand at all, `spectator: true` marks the view and `join_code` is omitted for them. `your_turn: true` is present when it is your move. `seats_open` reports the free chairs (0 once the match started). - `{"type": "game_over", "scores": {"A": 11, "B": 7}, "winner": "A"}` - `{"type": "error", "code": "illegal_move", "message": "..."}` Client → server messages are JSON objects with an `action`. The platform handles `state` (resend the snapshot) itself; every other action is dispatched to the session's game engine with the rest of the message as its payload. Spectators may only send `state`/`sync`: any other action is answered with an `error` whose `code` is `forbidden`. For scopone scientifico and scopa (both variants): ```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. For briscola (`briscola`, `briscola_a_quattro`) the game actions are `play` (with `card` only — every card in hand is legal) and `ack`: ```json {"action": "play", "card": "04C"} {"action": "ack"} ``` - `card` uses the same `RRSUIT` convention. - `ack` acknowledges the deal-end summary; the next deal is dealt once every player has acknowledged, or automatically after `HAND_ACK_TIMEOUT_SECONDS`. For briscola chiamata the game actions are the calling auction plus play: ```json {"action": "bid", "rank": 3} {"action": "bid", "points": 64} {"action": "pass"} {"action": "call", "card": "03D"} {"action": "play", "card": "04C"} ``` - `bid` with a `rank` calls a lower rank than the standing bid (`A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2`); with `points` it raises the target once the standing bid is the two. - `pass` leaves the auction; the last player standing must bid. - `call` names any card of the won rank; naming one of your own cards means playing alone. - `play` follows briscola: any card in hand is legal. After every accepted move the new state is broadcast to every connected client, spectators included. ### 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. Briscola chiamata uses the same timeout for its auction turns (an absent bidder passes; the last player standing with no bid is made to bid the ace) and for the called-card choice (a random suit of the won rank). ### 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 the scoring breakdown of the hand just played (`last_hand` with an `award` map naming the side that won each scopone/scopa category; `last_deal` with the deal's points and running totals for briscola), 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 ### Scopone scientifico - 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` creation option, 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. - Optional *asso pigliatutto* rule (per-game `asso_pigliatutto` creation option, default off): an ace played when no equal rank is on the table sweeps every card on the table — or captures itself when the table is empty. The capture never scores a scopa, but it counts as the last capture, and the mandatory equal-rank capture still applies when another ace is on the table. - The match ends when a team reaches the target score (default 21, the traditional partnership target, configurable per game via the `target_score` creation option) with a clear lead; a tie at or above the target is broken by another hand. ### Scopa - 40-card Italian deck; three cards per player and four cards face up on the table at the start of every hand. When every hand is empty the dealer deals three more cards to each player (no new table cards) until the deck is exhausted: two players play six rounds per hand, four players three. - A card captures a **single card of equal rank** (mandatory when present) or a **combination of table cards whose ranks sum to its own**; when any capture exists it must be taken. Remaining table cards at the end of the hand go to the player who made the last capture. - Emptying the table scores a *scopa* (+1), except on the last play of the hand (a sweep on the last play of a round still scores when the deck has cards left). - Hand points: `carte`, `denara`, `settebello` and `primiera` (same values and all-four-suits requirement as scopone), plus one point per scopa. Ties award nothing. There is no napola. - Optional *asso pigliatutto* rule (per-game `asso_pigliatutto` creation option, default off; scopa d'assi): an ace played when no equal rank is on the table sweeps every card on the table — or captures itself when the table is empty. The capture never scores a scopa, but it counts as the last capture, and the mandatory equal-rank capture still applies when another ace is on the table. - The match ends when a side reaches the target score (default 11 points for two-player scopa and 21 for scopa a quattro, the traditional partnership target; configurable per game via the `target_score` creation option) with a clear lead; a tie at or above the target is broken by another hand. - `scopa` is the classic two-player game; `scopa_a_quattro` seats four players in two fixed partnerships (seats 0/2 vs 1/3, partners opposite). ### Briscola - 40-card Italian deck, three cards per player. The card turned up after the deal fixes the trump (*briscola*) suit for the whole deal and is the last card drawn from the stock. - Trick play: the highest trump wins; when no trump was played, the highest card of the led suit wins. The winner takes the trick and leads the next one. - After each trick every player draws back up to three cards, starting with the trick winner; the face-up briscola card goes to whoever's turn to draw it is. - Card points: ace 11, three 10, king 4, horse 3, jack 2 — 120 points per deal. Trick strength is unrelated to points: ace > three > king > horse > jack > 7 > 6 > 5 > 4 > 2. - Match points accumulate across deals; a 60–60 deal is a *patta*: its points are discarded and the deal is replayed. The match ends when a team reaches the target score (default 61, configurable per game via the `target_score` creation option) with a clear lead; a tie at or above the target is broken by another deal. - `briscola` is the classic two-player game; `briscola_a_quattro` seats four players in two fixed partnerships (seats 0/2 vs 1/3, partners opposite). ### Briscola chiamata - 40-card Italian deck, eight cards each for five players: the whole deck is dealt, so there is no stock and no face-up card. - The calling auction starts with the player after the dealer: each player either passes (leaving the auction) or bids a lower rank than the standing bid (*A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2*); once the standing bid is the two, the auction continues on points (62, 64, …). The last player standing must bid, so every deal gets a caller. - The winner names any card of the won rank. If the winner holds it, the caller plays alone against the other four; otherwise its holder is the secret partner and the two form the calling side. The called card's suit is the trump; the partner's identity stays hidden until the called card is played. - Play and trick resolution follow briscola; eight tricks exhaust every hand. The calling side must reach the declared target (61 unless raised); with the plain target a 60–60 deal is a *patta* and is replayed with the next dealer. A match is a single deal, so the caller and partner (or the lone caller) against the other players map onto the platform's two-team result, Elo and history. - Auction turns, the called-card choice and the plays all have their own `TURN_TIMEOUT_SECONDS` deadlines: an absent bidder passes, the last player standing with no bid is made to bid the ace, an absent winner's call names a random suit of the won rank, and plays are auto-played. ## Development The backend is a [uv](https://docs.astral.sh/uv/) workspace: `pyproject.toml` declares the five packages under `packages/` as members, and `uv.lock` pins their full dependency closure. The checked-in `.python-version` selects Python 3.14, matching the Docker image. Install uv, then from `server/`: ```sh uv sync --package tavolo-app --extra otel # app integration suite: uv run python -m unittest discover -s tests -t . # per-package suites (each package is self-contained): uv run python -m unittest discover -s packages/tavolo-platform/tests uv run python -m unittest discover -s packages/tavolo-scopone/tests uv run python -m unittest discover -s packages/tavolo-scopa/tests uv run python -m unittest discover -s packages/tavolo-briscola/tests # type checks (see packages/tavolo-app/README.md for the app modules): uv run mypy -p tavolo.platform uv run mypy -p tavolo.scopone uv run mypy -p tavolo.scopa uv run mypy -p tavolo.briscola ``` `uv sync` installs the workspace's `dev` dependency group (`mypy`, `httpx-ws`) automatically; pass `--no-dev` for a runtime-only environment. The `otel` extra is optional, but without it the telemetry integration tests skip. The lockfile is committed: use `uv lock --upgrade` (or `uv lock --upgrade-package `) to update dependencies deliberately. 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. The platform suite additionally runs against a `DummyEngine` toy game, so the game-independent machinery is covered without importing scopone. ### 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 `packages/tavolo-platform/src/tavolo/platform/models.py`: ```sh DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo uv run aerich migrate DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo uv run 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 ``` packages/tavolo-platform/ # game-independent platform (own pyproject + tests) src/tavolo/platform/ ├── __init__.py # public surface (GameEngine, GameSession, Platform, ...) ├── engine.py # the platform<->game contract ├── registry.py # game id -> engine lookup ├── mixin.py # Platform + PlatformMixin (mounts everything on a kaya app) ├── errors.py # shared GameError hierarchy ├── models.py # Match / MatchPlayer / PlayerRating (Postgres) ├── elo.py # chess-style Elo math (1500 start, K=32) ├── stats.py # MatchResult -> Postgres persistence + Elo update ├── backfill_elo.py # recompute all ratings and aggregates from match history ├── store.py # Redis / in-memory live-session store (+ deadline queue) ├── deadlines.py # connection-independent timeout scheduler ├── ws.py # WebSocket live-play endpoint (generic envelope) ├── 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) └── routes/ ├── health.py # GET /api/health ├── me.py # GET /api/me ├── games.py # lobby: game-types / create / join / snapshot └── stats.py # match history + leaderboard + Elo ratings packages/tavolo-scopone/ # scopone scientifico game (own pyproject + tests) src/tavolo/scopone/ ├── state.py # ScoponeState / PlayerState / Card, JSON (de)serialization ├── engine.py # pure scopone scientifico rules ├── errors.py # scopone-specific rule violations └── plugin.py # ScoponeEngine: the GameEngine adapter packages/tavolo-scopa/ # scopa game (own pyproject + tests) src/tavolo/scopa/ ├── state.py # ScopaState / PlayerState / Card / Move, JSON (de)serialization ├── engine.py # pure scopa rules (two- and four-player) ├── errors.py # scopa-specific rule violations └── plugin.py # ScopaEngine + ScopaQuattroEngine adapters packages/tavolo-briscola/ # briscola game (own pyproject + tests) src/tavolo/briscola/ ├── state.py # BriscolaState / PlayerState / Card / Trick, JSON (de)serialization ├── engine.py # pure briscola rules (2- and 4-player) ├── chiamata.py # pure briscola chiamata rules (5-player, auction, secret partner) ├── errors.py # briscola-specific rule violations └── plugin.py # BriscolaEngine + BriscolaQuattroEngine + BriscolaChiamataEngine packages/tavolo-app/ # deployable app: composition root (own pyproject) src/tavolo/ ├── app.py # session/OIDC/Tortoise/OpenAPI/Platform/DeadlineScheduler mixins ├── config.py # env -> frozen Settings ├── telemetry.py # OTLP metric/log providers, app gauges, count poller ├── aerich_config.py # aerich CLI configuration ├── logging_config.py # logging setup └── static.py # SPA shell hosting pyproject.toml # uv workspace root + [tool.aerich] / [tool.mypy] uv.lock # locked dependency set for the whole workspace migrations/ # aerich migrations for the platform models tests/ # app integration suite (platform + games wired together) ```