Files
tavolo/server/README.md
T
woggioni 6932a3272c
CI / Build and push docker image (push) Successful in 3m12s
Rename the app from scopa to tavolo
The platform now hosts multiple card games, with scopone scientifico as
the first one. Rename the brand wherever it is not a game rule:

- move the Python package to server/src/tavolo and update imports
- rename the Postgres database/user, OIDC issuer path, client id and
  Redis key prefixes to tavolo (clean break: existing pgdata volumes and
  live games are not migrated)
- rename the Cargo package to tavolo-web and set the page title to Tavolo
- update docs and the Docker image path to woggioni/tavolo

The scopa game term (clearing the table) in the engine, state and web UI
is intentionally left untouched.
2026-09-16 21:46:06 +08:00

220 lines
9.3 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_URL` | `postgres://tavolo:tavolo@localhost:5432/tavolo` | Postgres DSN for match statistics |
| `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 |
| `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address |
## 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.
### Postgres (statistics, via Tortoise ORM + aerich migrations)
- `match` — one row per finished match: 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` and `/api/openapi.json`
require authentication.
| Method | Path | Description |
|---|---|---|
| `POST` | `/api/games` | Create a lobby game. Optional body `{"target_score": 11}`. 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=`) |
| `GET` | `/api/leaderboard` | Aggregated wins / matches / team points per player |
## 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.
### 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.
- 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
├── 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
```