woggioni e9ddb82e9a Initial scopone scientifico backend
Multiplayer scopone scientifico backend on the kaya framework:

- OIDC login (kaya-oidc), session-backed WebSocket auth
- Pure rules engine (forced captures, scopa, primiera scoring) with
  full-match simulation tests
- Live game state in Redis (JSON + TTL, join codes, per-game locks,
  pub/sub state push); in-memory fallback for tests
- WebSocket /ws/games/{id} for real-time play; REST lobby endpoints
  (create/join/snapshot) with hidden-hand views
- Finished matches persisted to Postgres (Tortoise + aerich) for match
  history and leaderboard endpoints
- Docker Compose stack: postgres, redis, mock-oauth2-server, db-migrate, app
- 45 tests passing; mypy clean
2026-09-16 13:20:05 +08:00
2026-09-16 13:20:05 +08:00
2026-09-16 13:20:05 +08:00
2026-09-16 13:20:05 +08:00
2026-09-16 13:20:05 +08:00

scopa

A multiplayer backend for scopone scientifico (the four-player, fixed-partnership variant of the classic Italian card game), built on the kaya framework.

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)

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.

Because the browser and the app both talk to the OIDC issuer at http://mockoauth:8180/scopa, 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_URL postgres://scopa:scopa@localhost:5432/scopa 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/scopa OIDC issuer URL
OIDC_CLIENT_ID scopa 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
APP_HOST / APP_PORT 0.0.0.0 / 8080 Bind address

Data model

Redis (live games)

  • scopa: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).
  • scopa:code:<JOINCODE> — the 6-character join code → game id index.
  • scopa:game:<uuid>:lock — a short-lived lock serializing every mutation.
  • scopa: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:

{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
{"action": "play", "card": "07D"}
{"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.
  • state asks for a fresh snapshot.

After every accepted move the new state is broadcast to all four players.

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

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/scopa/models.py:

DATABASE_URL=postgres://scopa:scopa@localhost:5432/scopa .venv/bin/aerich migrate
DATABASE_URL=postgres://scopa:scopa@localhost:5432/scopa .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

src/scopa/
├── 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
S
Description
Scopone scientifico multiplayer backend built on the kaya framework
Readme
4 MiB
Languages
Python 72.7%
Rust 21.3%
CSS 3.8%
Dockerfile 1.7%
Shell 0.4%
Other 0.1%