Turn auto-play and hand-end auto-continue were process-local asyncio tasks armed only by client connects and state broadcasts: with no sockets connected the next turn's timer was never armed, a hand-end timer died with its worker, and neither survived a pod restart. Deadlines are now driven by the absolute timestamps persisted on the game state and enqueued in a shared Redis sorted set. Every worker runs a consumer that fires due entries under the per-game lock after revalidating them against the live state, so timeouts no longer depend on any player being connected and survive the death of any worker. Delivery is at-least-once: entries are removed only after processing, and revalidation makes duplicate deliveries no-ops. Queue entries carry the deadline as integer epoch milliseconds, which also serves as the revalidation token, and the score derives from the same value.
281 lines
12 KiB
Markdown
281 lines
12 KiB
Markdown
# tavolo
|
|
|
|
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.
|
|
|
|
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.
|
|
|
|
## Quick start (Docker Compose)
|
|
|
|
```sh
|
|
# From the repository root (docker-compose.yml lives there):
|
|
docker compose up --build
|
|
```
|
|
|
|
The stack starts Postgres, Redis, a mock OIDC provider
|
|
(navikt/mock-oauth2-server) with four ready-made players
|
|
(`alice`, `bob`, `carol`, `dave`), a one-shot database migration service,
|
|
and the app itself on `http://127.0.0.1:8080`. The same container also
|
|
serves the Sycamore/WASM frontend (built from `../web/` by the Docker
|
|
image), so the UI is available at that address.
|
|
|
|
Because the browser and the app both talk to the OIDC issuer at
|
|
`http://mockoauth:8180/tavolo`, add a host entry once:
|
|
|
|
```sh
|
|
echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
|
|
```
|
|
|
|
Then log in: `GET http://localhost:8080/auth/login`, type any username
|
|
(e.g. `alice`) in the interactive login, and you are redirected back with a
|
|
session cookie. That cookie authenticates both the REST API and the
|
|
WebSocket endpoint.
|
|
|
|
OpenAPI documentation is served at `/api/docs` (`/api/openapi.json`).
|
|
|
|
## Configuration
|
|
|
|
All configuration comes from environment variables (see `.env.example`):
|
|
|
|
| Variable | Default | Description |
|
|
|---|---|---|
|
|
| `DATABASE_ENGINE` | `postgres` | Database DSN scheme/driver |
|
|
| `DATABASE_HOST` | `localhost` | Postgres host |
|
|
| `DATABASE_PORT` | unset | Postgres port; omitted from the DSN when empty (driver default, 5432 for Postgres) |
|
|
| `DATABASE_NAME` | `tavolo` | Postgres database name |
|
|
| `DATABASE_USER` | `tavolo` | Postgres user |
|
|
| `DATABASE_PASSWORD` | `password` | Postgres password |
|
|
| `DATABASE_OPTIONS` | unset | Extra DSN query parameters, e.g. `ssl=require` |
|
|
| `DATABASE_URL` | unset | Full-DSN override; when set, the `DATABASE_*` parts above are ignored (used for sqlite in tests and for managed-DB URLs) |
|
|
| `REDIS_URL` | unset | Redis DSN for sessions + live games. Unset falls back to in-memory stores |
|
|
| `OIDC_ISSUER` | `http://localhost:8180/tavolo` | OIDC issuer URL |
|
|
| `OIDC_CLIENT_ID` | `tavolo` | OIDC client id |
|
|
| `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 |
|
|
| `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 |
|
|
| `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address |
|
|
|
|
## Logging
|
|
|
|
The application logs through the Python stdlib `logging` module, one
|
|
`getLogger(__name__)` per module: lifecycle and business events at INFO
|
|
(game created/joined, websocket connections, match results, auto-plays),
|
|
per-move and store detail at DEBUG.
|
|
|
|
By default everything at DEBUG level goes to the console. Set
|
|
`LOGGING_CONFIG` to the path of a YAML file to take over the
|
|
configuration; the file follows the
|
|
[`logging.config.dictConfig`](https://docs.python.org/3/library/logging.config.html#logging-config-dictschema)
|
|
schema. Keep `disable_existing_loggers: false` — Granian configures its own
|
|
loggers before importing the app, and disabling them would silence the
|
|
server. Example for quieter production logs (WARNING for third parties,
|
|
INFO for the application):
|
|
|
|
```yaml
|
|
version: 1
|
|
disable_existing_loggers: false
|
|
formatters:
|
|
default:
|
|
format: "{asctime} [{levelname}] ({processName:s}/{threadName:s}) - {name} - {message}"
|
|
style: "{"
|
|
handlers:
|
|
console:
|
|
class: logging.StreamHandler
|
|
formatter: default
|
|
root:
|
|
level: WARNING
|
|
handlers: [console]
|
|
loggers:
|
|
tavolo:
|
|
level: INFO
|
|
```
|
|
|
|
## Data model
|
|
|
|
### 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: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
|
|
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).
|
|
|
|
### 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.
|
|
- `match_player` — one row per participant: the OIDC `sub`, display name,
|
|
seat, team and whether they won. Unique per `(match, user_sub)`.
|
|
|
|
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.
|
|
|
|
## REST API
|
|
|
|
All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
|
|
`/api/game-types` and `/api/leaderboard` require authentication.
|
|
|
|
| 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/games/{id}` | Personalized snapshot (only your own hand is visible) |
|
|
| `GET` | `/api/me/matches` | Cursor-paginated match history with final scores (`?limit=&cursor=&game_type=`) |
|
|
| `GET` | `/api/leaderboard` | Aggregated wins / matches / team points per player (`?game_type=`) |
|
|
|
|
## WebSocket protocol
|
|
|
|
Connect to `/ws/games/{game_id}` with your session cookie. Only seated
|
|
players are accepted; unauthenticated, unknown-game and non-player
|
|
connections are closed with codes `4401`, `4404` and `4403` respectively.
|
|
|
|
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": "game_over", "scores": {"A": 11, "B": 7}, "winner": "A"}`
|
|
- `{"type": "error", "code": "illegal_move", "message": "..."}`
|
|
|
|
Client → server messages:
|
|
|
|
```json
|
|
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
|
|
{"action": "play", "card": "07D"}
|
|
{"action": "ack"}
|
|
{"action": "state"}
|
|
```
|
|
|
|
- `card` is the card you play, rendered as `RRSUIT` (`01A`..`10D`; suits
|
|
`D` denari, `C` coppe, `S` spade, `B` bastoni — e.g. `07D` is the
|
|
settebello).
|
|
- `capture` lists the table cards to take. When a capture is legal it is
|
|
mandatory to provide one; when no capture exists it must be omitted.
|
|
- `ack` acknowledges the hand-end scoring summary (see below). The next
|
|
hand is dealt once all four players have acknowledged, or automatically
|
|
after `HAND_ACK_TIMEOUT_SECONDS`.
|
|
- `state` asks for a fresh snapshot.
|
|
|
|
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
|
|
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.
|
|
|
|
### 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.
|
|
|
|
## Rules implemented
|
|
|
|
- 40-card Italian deck, ten cards per player, empty table at hand start.
|
|
- A card captures a **single card of equal rank** (mandatory when present)
|
|
or a **combination of table cards whose ranks sum to its own**.
|
|
- Emptying the table scores a *scopa* (+1), except on the last play of a
|
|
hand. Remaining table cards go to the last player who captured.
|
|
- Hand points: `carte` (most captured cards), `denara` (most diamonds),
|
|
`settebello` (7♦), `primiera` (best 7/6/5/4 per suit, all four suits
|
|
required), plus one point per scopa. Ties award nothing.
|
|
- Optional *napola* rule (per-game `napola` 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.
|
|
- 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.
|
|
|
|
## Development
|
|
|
|
```sh
|
|
python3 -m venv .venv
|
|
.venv/bin/pip install -r requirements.txt
|
|
.venv/bin/pip install -e '.[dev]'
|
|
.venv/bin/python -m unittest discover -s tests -t .
|
|
.venv/bin/mypy src
|
|
```
|
|
|
|
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.
|
|
|
|
### 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`:
|
|
|
|
```sh
|
|
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo .venv/bin/aerich migrate
|
|
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo .venv/bin/aerich upgrade
|
|
```
|
|
|
|
(`aerich init-db` produces sqlite-flavored DDL when pointed at sqlite;
|
|
adjust `UUID`/`TIMESTAMPTZ`/`BOOL`/`JSONB` for Postgres like the existing
|
|
baseline.)
|
|
|
|
## Layout
|
|
|
|
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 (Postgres)
|
|
├── stats.py # finished match -> Postgres persistence
|
|
├── 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
|
|
```
|