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:
+210
-224
@@ -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`. |
|
||||
|
||||
Reference in New Issue
Block a user