Files
tavolo/server
woggioni c10d45523a Fix deadline consumer startup under granian RSGI
The mixin received the event loop from Kaya but ignored it, calling
asyncio.get_running_loop() instead. Under granian RSGI __rsgi_init__ runs
before the loop starts, so that raised RuntimeError and killed the
worker. Use the loop passed to setup(), falling back to the running loop
for the lazy calls from sync_deadline().
2026-09-18 13:48:34 +08:00
..

tavolo

The backend for a multiplayer card-game platform, built on the 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)

# 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:

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 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):

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 (lobbyplayingfinished). 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:

{"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

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:

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