Replace requirements.txt with a uv workspace and lockfile, install the five tavolo packages from packages/* in development and non-editable in the image, and build the Docker image with uv. Move dev tools to a PEP 735 dependency group and refresh the locked dependency versions.
tavolo (backend)
The backend for a multiplayer card-game platform, built on the
kaya framework. It is a monorepo of five distributions
sharing the tavolo namespace (see
packages/tavolo-platform/README.md,
packages/tavolo-scopone/README.md,
packages/tavolo-scopa/README.md
and
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 theGameEnginecontract 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)
# 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:
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 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_<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:
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
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):
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.idis a truncated SHA-256 of the session id (the cookie is a credential, so it is never exported verbatim) anduser.idis the OIDCsub, 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 fromopentelemetry-instrumentation-system-metrics:process.cpu.time,process.cpu.utilization,process.memory.usage,process.memory.virtual,cpython.gc.collections,cpython.gc.collected_objectsandcpython.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-specificstateblob, 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'sfire_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 fromGET /api/game-types, indexed so statistics can be scoped per game), start/finish timestamps, and the game-specific outcome as reported by the engine (resultJSON — 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 OIDCsub, 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 (detailsJSON). 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):
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: truemarks the view andjoin_codeis omitted for them.your_turn: trueis present when it is your move.seats_openreports 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):
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
{"action": "play", "card": "07D"}
{"action": "ack"}
{"action": "state"}
cardis the card you play, rendered asRRSUIT(01A..10D; suitsDdenari,Ccoppe,Sspade,Bbastoni — e.g.07Dis the settebello).capturelists the table cards to take. When a capture is legal it is mandatory to provide one; when no capture exists it must be omitted.ackacknowledges the hand-end scoring summary (see below). The next hand is dealt once all four players have acknowledged, or automatically afterHAND_ACK_TIMEOUT_SECONDS.stateasks 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:
{"action": "play", "card": "04C"}
{"action": "ack"}
carduses the sameRRSUITconvention.ackacknowledges the deal-end summary; the next deal is dealt once every player has acknowledged, or automatically afterHAND_ACK_TIMEOUT_SECONDS.
For briscola chiamata the game actions are the calling auction plus play:
{"action": "bid", "rank": 3}
{"action": "bid", "points": 64}
{"action": "pass"}
{"action": "call", "card": "03D"}
{"action": "play", "card": "04C"}
bidwith arankcalls a lower rank than the standing bid (A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2); withpointsit raises the target once the standing bid is the two.passleaves the auction; the last player standing must bid.callnames any card of the won rank; naming one of your own cards means playing alone.playfollows 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
napolacreation 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_pigliatuttocreation 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_scorecreation 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,settebelloandprimiera(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_pigliatuttocreation 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_scorecreation option) with a clear lead; a tie at or above the target is broken by another hand. scopais the classic two-player game;scopa_a_quattroseats 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_scorecreation option) with a clear lead; a tie at or above the target is broken by another deal. briscolais the classic two-player game;briscola_a_quattroseats 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_SECONDSdeadlines: 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 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/:
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 <name>) 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:
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)