Rewrite the backend in Rust with Axum
CI / Build and push docker image (push) Successful in 4m20s

Replace the Python/kaya server in server/ with a Rust Cargo workspace of
three crates mirroring the old layout: tavolo-platform (lobby API, live-game
store, websocket endpoint, deadline scheduler, stats/Elo, OIDC sessions),
tavolo-scopone (pure scopone scientifico rules plus the platform adapter)
and tavolo-app (composition root and the `tavolo` binary with
serve/migrate/backfill-elo).

The HTTP and WebSocket contracts, error messages, Redis key layout and
Postgres schema are unchanged, so the existing web/ frontend and in-flight
games keep working. Notable differences: sessions are JSON under
tavolo:session: (Python pickle sessions invalidate once), LOGGING_CONFIG is
ignored in favour of RUST_LOG, DATABASE_URL must be Postgres, sqlx migrations
replace aerich, and OpenTelemetry exports Rust process metrics instead of
CPython gc metrics (no Postgres/Redis client instrumentation).

Docker Compose and the Kubernetes manifest now run the single Rust image
(embedded migrations, /static and /assets served by the app); the release CI
keeps building server/Dockerfile. The full test suite (scopone rules,
platform store/deadlines/stats/websockets, OIDC login flow, scopone e2e) is
ported and runs with cargo test --workspace; Redis/Postgres integration tests
are gated on TEST_REDIS_URL/TEST_DATABASE_URL.
This commit is contained in:
2026-10-02 13:54:55 +00:00
parent adc3d340b1
commit 7b3b38ccbc
136 changed files with 16821 additions and 11777 deletions
+210 -224
View File
@@ -1,26 +1,25 @@
# tavolo (backend)
The backend for a multiplayer card-game platform, built on the
[kaya](../kaya) framework. It is a monorepo of three distributions
sharing the `tavolo` namespace (see
[`packages/tavolo-platform/README.md`](packages/tavolo-platform/README.md)
and [`packages/tavolo-scopone/README.md`](packages/tavolo-scopone/README.md)):
The backend for a multiplayer card-game platform, written in Rust with
[Axum](https://github.com/tokio-rs/axum). It is a Cargo workspace of three
crates mirroring the original Python layout:
- **`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-app`** (`packages/tavolo-app/`) — the
composition root wiring the platform to the scopone game, with the
environment-driven settings and the SPA shell.
- **`crates/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.
- **`crates/tavolo-scopone/`** — scopone scientifico, the four-player,
fixed-partnership variant of the classic Italian card game: pure rules plus
the platform adapter.
- **`crates/tavolo-app/`** — the composition root: environment-driven
settings, OIDC login, sessions, CORS, OpenTelemetry, SPA hosting and the
`tavolo` binary (`serve` / `migrate` / `backfill-elo`).
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.
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. The HTTP/WebSocket
contract is byte-compatible with the SPA in `../web/`.
## Quick start (Docker Compose)
@@ -31,10 +30,10 @@ 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.
(`alice`, `bob`, `carol`, `dave`), a one-shot database migration service
(`tavolo migrate`) 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:
@@ -57,14 +56,14 @@ All configuration comes from environment variables (see `.env.example`):
| Variable | Default | Description |
|---|---|---|
| `DATABASE_ENGINE` | `postgres` | Database DSN scheme/driver |
| `DATABASE_ENGINE` | `postgres` | Database DSN scheme/driver (Postgres only) |
| `DATABASE_HOST` | `localhost` | Postgres host |
| `DATABASE_PORT` | unset | Postgres port; omitted from the DSN when empty (driver default, 5432 for Postgres) |
| `DATABASE_PORT` | unset | Postgres port; omitted from the DSN when empty (driver default, 5432) |
| `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) |
| `DATABASE_URL` | unset | Full-DSN override; when set, the `DATABASE_*` parts above are ignored (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 |
@@ -77,7 +76,8 @@ All configuration comes from environment variables (see `.env.example`):
| `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the scopone between-hands scoring summary waits for acknowledgements (wired into the `ScoponeEngine`) |
| `TURN_TIMEOUT_SECONDS` | `30` | Seconds a scopone player has to play before the server plays a random legal card for them (wired into the `ScoponeEngine`) |
| `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 |
| `LOGGING_CONFIG` | unset | Accepted for compatibility; **ignored** (the Rust server logs via `RUST_LOG`) |
| `RUST_LOG` | `debug` (app), INFO+ deps | tracing-subscriber env filter, e.g. `tavolo=info,sqlx=warn` |
| `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 |
@@ -85,7 +85,7 @@ All configuration comes from environment variables (see `.env.example`):
| `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_ENABLED` | `false` | `1`/`true`/`yes`/`on` enable OpenTelemetry traces, metrics and logs (compiled in by default) |
| `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) |
@@ -93,6 +93,7 @@ All configuration comes from environment variables (see `.env.example`):
| `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 |
| `STATIC_DIR` | `web/dist` | Directory holding the compiled frontend (the Docker image sets `/app/web/dist`) |
¹ 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
@@ -100,9 +101,8 @@ 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:
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
@@ -120,83 +120,46 @@ 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.
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.
## 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.
The Rust server logs through `tracing`, configured with `RUST_LOG`
(`tracing-subscriber` env-filter syntax). The default filter is `debug` for
the application with noisy dependencies capped (`sqlx`, `redis`, `hyper`,
`reqwest` at WARN). `LOGGING_CONFIG` (a Python `logging.config.dictConfig`
YAML path) is accepted so existing deployments do not break, but its value is
ignored with a warning.
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
```
Lifecycle and business events are logged at INFO (game created/joined,
websocket connections, match results, auto-plays); per-move and store detail
at DEBUG.
## OpenTelemetry
With `OTEL_ENABLED` set, the app exports over OTLP/HTTP (kaya-otel plus
`tavolo.telemetry`):
With `OTEL_ENABLED` set, the app exports over OTLP/HTTP:
- **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
- **Traces**: one span per HTTP request and one per WebSocket connection,
tagged with the request's session and user: `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.
Database-client auto-instrumentation is not included.
- **Metrics**: the `http.server.request.duration` histogram, 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).
`tavolo.ongoing_games` (live matches not finished yet), and process metrics:
`process.cpu.time`, `process.cpu.utilization`, `process.memory.usage`,
`process.memory.virtual`. The Python build's CPython gc metrics have no
Rust equivalent.
- **Logs**: tracing events forwarded to OTLP (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`.
cluster-wide counts, in-memory stores the process's own). With multiple
replicas each process reports the same global counts: aggregate with `max`,
not `sum`.
## Data model
@@ -204,59 +167,64 @@ aggregate with `max`, not `sum`.
- `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`).
game-specific `state` blob, which only the registered engine interprets.
Sliding TTL (`GAME_TTL_SECONDS`). The layout is identical to the original
Python implementation, so in-flight games survive the backend swap.
- `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. scopone's 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).
timeouts (scopone's 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.
- `tavolo:games:ongoing` — sorted set of running games for the
`tavolo.ongoing_games` gauge.
### Postgres (statistics, via Tortoise ORM + aerich migrations)
Sessions live under `tavolo:session:<id>` as JSON (the Python server used
pickled values under `kaya:session:`; existing sessions are invalidated once
on cutover, users log in again).
- `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).
### Postgres (statistics, via sqlx migrations)
- `match` — one row per finished match: the game played (`game_type`,
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).
- `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.
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.
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):
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, with Python-compatible
banker's rounding. Ratings update in the same transaction as the match
result. To recompute every rating from the recorded match history:
```sh
.venv/bin/python -m tavolo.platform.backfill_elo \
--database-url postgres://tavolo:tavolo@localhost:5432/tavolo
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo \
cargo run -p tavolo-app -- backfill-elo
# or, in the container image:
docker compose run --rm tavolo tavolo backfill-elo
```
## REST API
@@ -278,24 +246,23 @@ All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
## WebSocket protocol
Connect to `/ws/games/{game_id}` with your session cookie. Only seated
players are accepted; the upgrade is refused before the handshake
completes with an HTTP status mirroring `GET /api/games/{id}`: `401`
unauthenticated, `404` unknown game, `403` not seated. (Browsers report
a refused handshake as an abnormal close — there is no close code to
read.)
players are accepted; the upgrade is refused before the handshake completes
with an HTTP status mirroring `GET /api/games/{id}`: `401` unauthenticated,
`404` unknown game, `403` not seated. (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.
`your_turn: true` is present when it is your move.
- `{"type": "state", "game": {...}}` — personalized game view. Your own hand
is included; other players expose only their card counts. `your_turn: true`
is present when it is your move.
- `{"type": "game_over", "scores": {"A": 11, "B": 7}, "winner": "A"}`
- `{"type": "error", "code": "illegal_move", "message": "..."}`
Client → server messages 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. For scopone scientifico:
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. For scopone scientifico:
```json
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
@@ -309,9 +276,9 @@ as its payload. For scopone scientifico:
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`.
- `ack` acknowledges the hand-end scoring summary (see below). The next hand
is dealt once all four players have acknowledged, or automatically after
`HAND_ACK_TIMEOUT_SECONDS`.
- `state` asks for a fresh snapshot.
After every accepted move the new state is broadcast to all four players.
@@ -319,23 +286,22 @@ After every accepted move the new state is broadcast to all four players.
### Turn timeout
The state carries a `turn_deadline` while a hand is being played. If the
player on turn does not move before it, the server plays a random legal
card for them (picking one of the legal captures at random when a capture
is required), so a disconnected or idle player cannot stall the match. The
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.
queue, not from timers tied to client connections, so the match keeps
progressing even with every player disconnected.
### Hand-end summary
When a hand finishes but the match continues, the game enters the
`hand_end` phase instead of dealing immediately: the state carries
`last_hand` (a full scoring breakdown with an `award` map naming the team
that won each category), the `acknowledged` seats and a
`hand_end_deadline`. The frontend renders this as a screen every player
must dismiss. A play attempted in this phase is rejected with an
`illegal_move` error.
that won each category), the `acknowledged` seats and a `hand_end_deadline`.
The frontend renders this as a screen every player must dismiss. A play
attempted in this phase is rejected with an `illegal_move` error.
## Rules implemented
@@ -364,84 +330,104 @@ must dismiss. A play attempted in this phase is rejected with an
## Development
Backend (from `server/`):
```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-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
# 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
cargo test --workspace # unit + integration suites
cargo run -p tavolo-app # = tavolo serve (needs the env below)
cargo run -p tavolo-app -- migrate
```
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.
For a local run without Docker:
```sh
DATABASE_URL=postgres://tavolo:password@localhost:5432/tavolo \
REDIS_URL=redis://localhost:6379/0 \
cargo run -p tavolo-app
```
Tests run fully in-process: in-memory stores, a `DummyEngine` toy game for
the platform suite, a mock OIDC provider for the login flow, and ephemeral
TCP servers for WebSocket coverage. The Redis- and Postgres-backed tests are
gated on `TEST_REDIS_URL` / `TEST_DATABASE_URL` and skipped when unset.
Frontend (from `web/`), with the backend running on :8080:
```sh
cd web
trunk serve # SPA on http://localhost:8000, /api /auth /ws proxied
```
For the login redirect to land back on the dev server, run the backend with:
```sh
OIDC_0_PROVIDER=default \
OIDC_0_ISSUER=http://localhost:8180/tavolo \
OIDC_0_CLIENT_ID=tavolo \
OIDC_0_REDIRECT_URI=http://localhost:8080/auth/default/callback \
OIDC_0_POST_LOGIN_REDIRECT=http://localhost:8000/ \
OIDC_0_POST_LOGOUT_REDIRECT=http://localhost:8000/ \
cargo run -p tavolo-app
```
Card images are committed under `web/assets/cards/`; `web/fetch-cards.sh`
re-downloads them if needed.
### 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.)
The Postgres schema is owned by embedded sqlx migrations in
`crates/tavolo-app/migrations/`, applied by `tavolo migrate` (the
`db-migrate` compose service and the Kubernetes init container). The
migration is idempotent (`CREATE TABLE IF NOT EXISTS`) and matches the
original aerich-created schema, so it can be applied to an existing database
without touching data; the `aerich` bookkeeping table is left alone. To
change the schema, add a new numbered `.sql` file next to the baseline and
run `cargo test -p tavolo-app` (the migration is embedded at compile time).
## 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-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 + scopone wired together)
crates/tavolo-platform/ # game-independent platform (own crate + tests)
src/
├── engine.rs # the platform<->game contract
├── registry.rs # game id -> engine lookup
├── errors.rs # GameError + platform Error
├── store.rs # Redis / in-memory live-session store (+ deadline queue)
├── deadlines.rs # connection-independent timeout scheduler
├── session.rs # HTTP sessions (in-memory / Redis) + middleware
├── oidc.rs # OIDC discovery, PKCE, JWKS validation, logout
├── auth.rs # session -> user helpers
├── stats.rs # match persistence + history/leaderboard queries + Elo
├── models.rs # sqlx row models
├── elo.rs # chess-style Elo math (1500 start, K=32)
├── pagination.rs # keyset (cursor) pagination
├── http.rs # JSON request/response helpers
├── platform.rs # the collaborators shared by all endpoints
├── routes/ # health / me / auth / games / stats / openapi
└── ws.rs # WebSocket live-play endpoint (generic envelope)
crates/tavolo-scopone/ # scopone scientifico game (own crate + tests)
src/{state,engine,plugin,errors}.rs
crates/tavolo-app/ # deployable app: composition root (own crate)
src/{main,app,config,cors,logging,static_files,telemetry}.rs
migrations/ # embedded sqlx migrations
dev/mockoauth/ # dev OIDC provider image (compose)
Dockerfile # web-builder + cargo + slim runtime
.env.example
```
## Differences from the original Python server
The Python implementation that lived in this directory was replaced by the
Rust one; it remains available in git history. Behavioral compatibility is
intentional and tested (same JSON payloads, status codes, error messages,
Redis layout and Postgres schema), with these documented differences:
| Area | Behavior |
|---|---|
| Sessions | Stored as JSON under `tavolo:session:` (Python: pickle under `kaya:session:`). Existing sessions are invalidated once on cutover; users log in again. |
| Logging | `LOGGING_CONFIG` is accepted but ignored; use `RUST_LOG`. |
| Database | Postgres only (`DATABASE_URL=sqlite://...` is rejected). |
| OpenTelemetry | Rust process metrics replace the CPython gc metrics; no Postgres/Redis client auto-instrumentation. |
| Deadline members | Compact JSON instead of Python's spaced `json.dumps`; stale duplicates are harmless (engines revalidate tokens). |
| CORS | Hand-written middleware replicating kaya-cors / Starlette `CORSMiddleware` semantics, including `400 Disallowed CORS ...` preflight rejections. |
| CLI | `tavolo migrate` / `tavolo backfill-elo` replace `aerich upgrade` / `python -m tavolo.platform.backfill_elo`. |