Split backend into tavolo-platform, tavolo-scopone and tavolo-app packages
Move the game-independent machinery (lobby, live-game store, websocket, deadline scheduler, match history, leaderboards) into a new tavolo-platform distribution behind a GameEngine contract, the scopone scientifico rules plus a platform adapter into tavolo-scopone, and keep only the composition root in tavolo-app. The three distributions share the tavolo namespace (PEP 420, kaya-style monorepo). Match history becomes fully generic: Match carries the engine's result JSON and MatchPlayer points/details instead of scopone-shaped team columns (migration 3 backfills existing rows). Lobby creation takes an opaque per-game options object and websocket actions dispatch to the session's engine. Tests: platform suite runs against a DummyEngine toy game, scopone keeps the rules tests plus new adapter tests, server/tests covers the wired stack end to end (194 tests, was 143).
This commit is contained in:
+126
-81
@@ -1,14 +1,26 @@
|
||||
# tavolo
|
||||
# tavolo (backend)
|
||||
|
||||
The backend for a multiplayer card-game platform, built on the
|
||||
[kaya](../kaya) framework. The first game implemented is **scopone
|
||||
scientifico**, the four-player, fixed-partnership variant of the classic
|
||||
Italian card game.
|
||||
[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)):
|
||||
|
||||
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 final score — are persisted to
|
||||
**Postgres** for history and leaderboard statistics.
|
||||
- **`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`** (`src/tavolo/`, this `pyproject.toml`) — the
|
||||
composition root wiring the platform to the scopone game, 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)
|
||||
|
||||
@@ -58,8 +70,8 @@ All configuration comes from environment variables (see `.env.example`):
|
||||
| `OIDC_CLIENT_SECRET` | unset | OIDC client secret |
|
||||
| `OIDC_REDIRECT_URI` | `http://localhost:8080/auth/callback` | Login callback URL |
|
||||
| `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 |
|
||||
| `TURN_TIMEOUT_SECONDS` | `30` | Seconds a player has to play before the server plays a random legal card for them |
|
||||
| `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` | `1000` | Upper bound on the deadline consumer's poll interval (locally enqueued deadlines fire on time regardless) |
|
||||
| `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 |
|
||||
@@ -69,7 +81,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 and metrics (requires the `otel` extra, i.e. `pip install tavolo[otel]`) |
|
||||
| `OTEL_ENABLED` | `false` | `1`/`true`/`yes`/`on` enable OpenTelemetry traces and metrics (requires the `otel` extra, i.e. `pip install tavolo-app[otel]`) |
|
||||
| `OTEL_SERVICE_NAME` | `tavolo` | `service.name` resource attribute of the exported telemetry |
|
||||
| `OTEL_EXPORTER_OTLP_ENDPOINT` | unset | Base URL of an OTLP/HTTP collector (e.g. `http://localhost:4318`); unset uses the exporter default |
|
||||
| `OTEL_EXPORTER_OTLP_HEADERS` | unset | Comma-separated `key=value` headers sent to the collector (e.g. authentication) |
|
||||
@@ -115,52 +127,58 @@ loggers:
|
||||
|
||||
### Redis (live games)
|
||||
|
||||
- `tavolo:game:<uuid>` — the whole match as JSON: players (seat 0/2 = team A,
|
||||
1/3 = team B), hands, table, captured piles, scope, current turn, dealer,
|
||||
scores, phase (`lobby` → `playing` → `finished`). Sliding TTL
|
||||
(`GAME_TTL_SECONDS`).
|
||||
- `tavolo:game:<uuid>` — the whole session as JSON: the platform-owned
|
||||
envelope (`id`, `game_type`, `join_code`, seats, timestamps) plus the
|
||||
game-specific `state` blob, which only the registered engine
|
||||
interprets. Sliding TTL (`GAME_TTL_SECONDS`).
|
||||
- `tavolo:code:<JOINCODE>` — the 6-character join code → game id index.
|
||||
- `tavolo:game:<uuid>:lock` — a short-lived lock serializing every mutation.
|
||||
- `tavolo:game:<uuid>:events` — a pub/sub channel carrying "state changed"
|
||||
signals; every open WebSocket reloads the state and pushes the
|
||||
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: turn auto-plays and hand-end auto-continues. Every worker runs
|
||||
a consumer that fires due entries under the per-game lock, so timeouts
|
||||
do not depend on any player being connected and survive the death of
|
||||
any worker (delivery is at-least-once; entries are revalidated against
|
||||
the live state before firing).
|
||||
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).
|
||||
|
||||
### Postgres (statistics, via Tortoise ORM + aerich migrations)
|
||||
|
||||
- `match` — one row per finished match: the game played (`game_type`, one
|
||||
of the ids from `GET /api/game-types`, indexed so statistics can be
|
||||
scoped per game), both teams' final scores, winner, target score, hands
|
||||
played, start/finish timestamps.
|
||||
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, team, whether they won and the Elo change the match produced
|
||||
(`elo_delta`). Unique per `(match, user_sub)`.
|
||||
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 result is written transactionally to Postgres
|
||||
(once, guarded by a flag on the Redis state); the finished state stays in
|
||||
Redis until its TTL expires so clients can still fetch the final board.
|
||||
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.elo`):
|
||||
everyone starts at 1500, a team's rating is the mean of its two 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 both members of a team, zero-sum between 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. Ratings update in the same
|
||||
transaction as the match result. To recompute every rating from the
|
||||
recorded match history (e.g. to backfill matches recorded before ratings
|
||||
existed):
|
||||
|
||||
```sh
|
||||
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo \
|
||||
.venv/bin/python -m tavolo.backfill_elo
|
||||
.venv/bin/python -m tavolo.platform.backfill_elo \
|
||||
--database-url postgres://tavolo:tavolo@localhost:5432/tavolo
|
||||
```
|
||||
|
||||
## REST API
|
||||
@@ -170,13 +188,13 @@ All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/game-types` | The card games the platform can host (for the creation dropdown) |
|
||||
| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "target_score": 11, "napola": true}`. Returns `{id, join_code}` |
|
||||
| `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The fourth player triggers the deal |
|
||||
| `GET` | `/api/game-types` | The games the platform can host (for the creation dropdown), each with its `options_schema` |
|
||||
| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "options": {"target_score": 11, "napola": true}}`. 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/{id}` | Personalized snapshot (only your own hand is visible) |
|
||||
| `GET` | `/api/me/matches` | Cursor-paginated match history with final scores and per-player Elo deltas (`?limit=&cursor=&game_type=`) |
|
||||
| `GET` | `/api/me/matches` | Cursor-paginated match history with the engine's `result` and per-player Elo deltas (`?limit=&cursor=&game_type=`) |
|
||||
| `GET` | `/api/me/ratings` | The caller's Elo rating per game type |
|
||||
| `GET` | `/api/leaderboard` | Elo rating, aggregated wins / matches / team points per player, sorted by Elo (`?game_type=`) |
|
||||
| `GET` | `/api/leaderboard` | Elo rating, aggregated wins / matches / points per player, sorted by Elo (`?game_type=`) |
|
||||
|
||||
## WebSocket protocol
|
||||
|
||||
@@ -192,7 +210,10 @@ Server → client messages are JSON objects with a `type`:
|
||||
- `{"type": "game_over", "scores": {"A": 11, "B": 7}, "winner": "A"}`
|
||||
- `{"type": "error", "code": "illegal_move", "message": "..."}`
|
||||
|
||||
Client → server messages:
|
||||
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"]}
|
||||
@@ -244,34 +265,46 @@ must dismiss. A play attempted in this phase is rejected with an
|
||||
- Hand points: `carte` (most captured cards), `denara` (most diamonds),
|
||||
`settebello` (7♦), `primiera` (best 7/6/5/4 per suit, all four suits
|
||||
required), plus one point per scopa. Ties award nothing.
|
||||
- Optional *napola* rule (per-game `napola` flag on `POST /api/games`,
|
||||
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 *napola* rule (per-game `napola` creation option, default on):
|
||||
the longest run of consecutive denari starting from the ace scores one
|
||||
point per card once it reaches three cards (A-2-3 = 3, A-2-3-4 = 4, …).
|
||||
A team that captures the whole denari suit (ace to king) wins the match
|
||||
instantly, regardless of the score.
|
||||
- The match ends when a team reaches the target score (default 11,
|
||||
configurable per game) with a clear lead; a tie at or above the target is
|
||||
broken by another hand.
|
||||
configurable per game via the `target_score` creation option) with a
|
||||
clear lead; a tie at or above the target is broken by another hand.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
.venv/bin/pip install -e '.[dev]'
|
||||
.venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e '.[dev]'
|
||||
# app integration suite:
|
||||
.venv/bin/python -m unittest discover -s tests -t .
|
||||
.venv/bin/mypy src
|
||||
# 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:
|
||||
.venv/bin/python -m mypy -p tavolo.platform
|
||||
.venv/bin/python -m mypy -p tavolo.scopone
|
||||
.venv/bin/python -m mypy --namespace-packages --explicit-package-bases \
|
||||
src/tavolo/app.py src/tavolo/config.py src/tavolo/static.py \
|
||||
src/tavolo/aerich_config.py src/tavolo/logging_config.py
|
||||
```
|
||||
|
||||
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.
|
||||
`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 `src/tavolo/models.py`:
|
||||
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
|
||||
@@ -284,31 +317,43 @@ baseline.)
|
||||
|
||||
## Layout
|
||||
|
||||
Everything lives under `server/`:
|
||||
|
||||
```
|
||||
src/tavolo/
|
||||
├── app.py # composition root: session/OIDC/Tortoise/OpenAPI mixins
|
||||
├── config.py # env -> frozen Settings
|
||||
├── 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)
|
||||
├── aerich_config.py # aerich CLI configuration
|
||||
├── models.py # Match, MatchPlayer, PlayerRating (Postgres)
|
||||
├── elo.py # chess-style Elo math (1500 start, K=32)
|
||||
├── stats.py # finished match -> Postgres persistence + Elo update
|
||||
├── backfill_elo.py # recompute all ratings from the match history
|
||||
├── store.py # Redis / in-memory live-game store (+ deadline queue)
|
||||
├── deadlines.py # connection-independent timeout scheduler
|
||||
├── ws.py # WebSocket live-play endpoint
|
||||
├── game/
|
||||
│ ├── state.py # GameState / PlayerState / Card, JSON (de)serialization
|
||||
│ ├── engine.py # pure scopone scientifico rules
|
||||
│ └── errors.py # typed rule violations
|
||||
└── routes/
|
||||
├── health.py # GET /api/health
|
||||
├── games.py # lobby: create / join / snapshot
|
||||
└── stats.py # match history + leaderboard + Elo ratings
|
||||
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
|
||||
src/tavolo/ # tavolo-app: composition root (this pyproject.toml)
|
||||
├── app.py # session/OIDC/Tortoise/OpenAPI/Platform/DeadlineScheduler mixins
|
||||
├── config.py # env -> frozen Settings
|
||||
├── aerich_config.py # aerich CLI configuration
|
||||
├── logging_config.py # logging setup
|
||||
└── static.py # SPA shell hosting
|
||||
migrations/ # aerich migrations for the platform models
|
||||
tests/ # app integration suite (platform + scopone wired together)
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user