Granian serves the compiled SPA assets directly in Rust: hashed js/wasm/css under /static (the release build uses --public-url /static/) and the card images under /assets, configured with the GRANIAN_STATIC_PATH_ROUTE/MOUNT/ DIR_TO_FILE env vars in the Dockerfile. The Python catch-all now only serves the SPA shell (index.html) at / and for client-side routes. Every module logs through getLogger(__name__): lifecycle and business events at INFO, per-move and store detail at DEBUG. The built-in default writes DEBUG to the console; LOGGING_CONFIG points at a YAML file in the logging.config.dictConfig schema to take over the configuration. PyYAML becomes a direct dependency.
256 lines
10 KiB
Markdown
256 lines
10 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 |
|
|
| `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.
|
|
|
|
### 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
|
|
```
|