CI / Build and push docker image (push) Successful in 5m35s
Five-player briscola with a descending calling auction and a secret partner. Starting with the player after the dealer, each player bids a lower rank (A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2) or passes; once the standing bid is the two the auction continues on points (62, 64, ...). The last player standing must bid, and the winner names any card of the won rank: its holder is the secret partner, or the caller plays alone when it is one of their own cards, and its suit becomes the trump. The calling side must reach 61 (or the raised target); a 60-60 deal with the plain target is a patta and is replayed. 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 match history. The rules live in tavolo.briscola.chiamata with the BriscolaChiamataEngine adapter registered alongside the other briscola variants; the partner's role and the side totals stay hidden until the called card is played. The web client gets a five-seat table, the auction panel with legal bids and point raises, the call picker, the reveal bands and a chiamata-aware final summary.
559 lines
29 KiB
Markdown
559 lines
29 KiB
Markdown
# tavolo (backend)
|
||
|
||
The backend for a multiplayer card-game platform, built on the
|
||
[kaya](../kaya) framework. It is a monorepo of four 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)
|
||
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-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`).
|
||
|
||
## 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 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 per `(user_sub, game_type)`, with
|
||
the number of rated matches played.
|
||
|
||
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 update in the same
|
||
transaction as the match result. To recompute every rating 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": 11, "napola": true, "asso_pigliatutto": false}}`; 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, aggregated wins / matches / points per player, sorted by Elo (`?game_type=`) |
|
||
|
||
## 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:
|
||
|
||
```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 bids (auto-bid or
|
||
auto-pass) 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 team that won each scopone 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 11,
|
||
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.
|
||
|
||
### 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 bids, the called-card choice and the plays all have their own
|
||
`TURN_TIMEOUT_SECONDS` deadlines (an absent player is auto-bid,
|
||
auto-called or auto-played for).
|
||
|
||
## 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-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-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.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 from the 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-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)
|
||
```
|