kaya-openapi 0.0.8 supports named component schemas, so the routes' description-only responses become $refs: schemas for the core payloads (auth providers, lobby, game view, ongoing games, match history, leaderboard, ratings) live in tavolo.platform.openapi and are registered through the mixin's components. Engine-defined parts (options echo, game views, match results) stay open objects on purpose. Error responses now carry the error envelope schema, and a new suite sweeps the document to keep every /api response typed.
612 lines
33 KiB
Markdown
612 lines
33 KiB
Markdown
# 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_<n>_PROVIDER` | `default` ¹ | Provider name (required when OIDC is configured): used in the login URL `/auth/<provider>/login` and to namespace its session keys. Unique, `[A-Za-z0-9_-]+` |
|
||
| `OIDC_<n>_ISSUER` | `http://localhost:8180/tavolo` ¹ | OIDC issuer URL |
|
||
| `OIDC_<n>_CLIENT_ID` | `tavolo` ¹ | OIDC client id |
|
||
| `OIDC_<n>_CLIENT_SECRET` | unset | OIDC client secret; leave unset for public clients |
|
||
| `OIDC_<n>_REDIRECT_URI` | `http://localhost:8080/auth/<provider>/callback` | Login callback URL; must be registered at the provider |
|
||
| `OIDC_<n>_POST_LOGIN_REDIRECT` | `/` | Where the browser is sent after login |
|
||
| `OIDC_<n>_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 `pip install tavolo-app[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_<n>_*` 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/<provider>/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/<provider>/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:<uuid>` — 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:<JOINCODE>` — the 6-character join code → game id index.
|
||
- `tavolo:game:<uuid>:lock` — a short-lived lock serializing every mutation.
|
||
- `tavolo:game:<uuid>: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
|
||
.venv/bin/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
|
||
|
||
```sh
|
||
python3 -m venv .venv
|
||
.venv/bin/pip install -r requirements.txt
|
||
.venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e ./packages/tavolo-scopa -e ./packages/tavolo-briscola -e './packages/tavolo-app[dev]'
|
||
# app integration suite:
|
||
.venv/bin/python -m unittest discover -s tests -t .
|
||
# per-package suites (each package is self-contained):
|
||
.venv/bin/python -m unittest discover -s packages/tavolo-platform/tests
|
||
.venv/bin/python -m unittest discover -s packages/tavolo-scopone/tests
|
||
.venv/bin/python -m unittest discover -s packages/tavolo-scopa/tests
|
||
.venv/bin/python -m unittest discover -s packages/tavolo-briscola/tests
|
||
# type checks (see packages/tavolo-app/README.md for the app modules):
|
||
.venv/bin/python -m mypy -p tavolo.platform
|
||
.venv/bin/python -m mypy -p tavolo.scopone
|
||
.venv/bin/python -m mypy -p tavolo.scopa
|
||
.venv/bin/python -m mypy -p tavolo.briscola
|
||
```
|
||
|
||
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 .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
|
||
|
||
```
|
||
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 # tooling shim: [tool.aerich] + [tool.mypy] for server/ CWD
|
||
migrations/ # aerich migrations for the platform models
|
||
tests/ # app integration suite (platform + games wired together)
|
||
```
|