15 Commits
Author SHA1 Message Date
woggioni 0b06f23311 Make the web client game-generic against the platform API
Send creation options as the nested options object the lobby expects,
drive the creation form from each game's options_schema, and render
history/leaderboard from the generic result payload (nullable teams,
float points). The lobby uses the payload's seats_open, and the
scopone-specific table, hand-end and game-over views are gated on its
game_type with an unsupported-game fallback for future games. The
websocket protocol is unchanged.
2026-09-19 08:57:20 +00:00
woggioni 5a73601ddf Split backend into tavolo-platform, tavolo-scopone and tavolo-app packages
Move the game-independent machinery (lobby, live-game store, websocket,
deadline scheduler, match history, leaderboards) into a new
tavolo-platform distribution behind a GameEngine contract, the scopone
scientifico rules plus a platform adapter into tavolo-scopone, and keep
only the composition root in tavolo-app. The three distributions share
the tavolo namespace (PEP 420, kaya-style monorepo).

Match history becomes fully generic: Match carries the engine's result
JSON and MatchPlayer points/details instead of scopone-shaped team
columns (migration 3 backfills existing rows). Lobby creation takes an
opaque per-game options object and websocket actions dispatch to the
session's engine.

Tests: platform suite runs against a DummyEngine toy game, scopone
keeps the rules tests plus new adapter tests, server/tests covers the
wired stack end to end (194 tests, was 143).
2026-09-19 07:28:58 +00:00
woggioni b2b514ab91 Rename Denara label to Denari and drop em-dash from summary button 2026-09-19 06:01:22 +00:00
woggioni 031d933204 Fix hanging test suite by closing per-loop Tortoise contexts
pwo.async_test runs every test in a fresh event loop and TortoiseMixin
builds one TortoiseContext per loop. Dropping the previous context when
the loop changed orphaned its aiosqlite connections; their non-daemon
worker threads then blocked threading._shutdown forever, so the suite
printed OK but the interpreter never exited.

Add TortoiseMixin.aclose() and a tests.helpers.async_test wrapper that
closes the context in a finally on the test's own loop, and switch the
test modules over to it.
2026-09-19 03:20:24 +00:00
woggioni af480bb9b7 Add optional OpenTelemetry integration via kaya-otel
CI / Build and push docker image (push) Successful in 1m31s
Adds an OTEL_ENABLED opt-in that assembles kaya-otel's OTelMixin into
the app, exporting HTTP/WebSocket traces and request metrics to an
OTLP/HTTP collector. Configured through OTEL_SERVICE_NAME,
OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS and
OTEL_EXCLUDED_PATHS (defaults to the /api/health probe endpoint).

kaya-otel is an optional 'otel' extra imported lazily, so default
installs and the test suite do not need the OpenTelemetry packages.
The kaya dependencies are bumped to 0.0.4, which kaya-otel requires.
2026-09-19 10:09:31 +08:00
woggioni 93d041b004 Add chess-style Elo ratings for players
Each player's rating starts at 1500 and updates transactionally with
every finished match: a team's rating is the mean of its two members
and the standard K=32 formula decides the zero-sum delta applied to
both members of a team. Ratings are per game type in a new
player_rating table; match_player records each match's elo_delta.

- GET /api/leaderboard exposes elo and sorts by it
- GET /api/me/matches includes per-player elo deltas
- new GET /api/me/ratings returns the caller's rating per game type
- frontend: Elo column on the leaderboard, per-match delta in the
  history page, current rating in the lobby
- python -m tavolo.backfill_elo recomputes all ratings from the
  recorded match history (one-off backfill for existing matches)
2026-09-19 10:09:24 +08:00
woggioni ebeccc937d optimized dockerfile for caching
CI / Build and push docker image (push) Successful in 1m14s
2026-09-18 19:28:33 +08:00
woggioni e0896b4a95 Configure CORS headers from environment variables
CI / Build and push docker image (push) Successful in 3m24s
2026-09-18 19:18:27 +08:00
woggioni e2091ee4df updated Docker builder 2026-09-18 19:18:04 +08:00
woggioni 61f3539c4e Reconnect the game websocket after connectivity loss
The socket had no recovery path: a mid-game drop left the table showing
stale state with no indication, and plays were silently swallowed by the
dead channel. Surface the server close code from ws::connect and add a
reconnect driver in the game page: transient losses retry with
exponential backoff (capped, then a manual Retry), while deliberate
closes (session expired, game gone) stop retrying. Reconnects are free
resyncs because the server pushes a full state snapshot on connect.

Also gate card clicks while disconnected, show a connection banner, and
drop the ticking interval and pending retries on unmount.
2026-09-18 19:16:39 +08:00
woggioni 8fea4fac74 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 19:16:31 +08:00
woggioni c5b6c84408 Show own captured and scopa counts in game view 2026-09-18 19:16:17 +08:00
woggioni 2d1a13f663 Drive timeouts from a shared Redis deadline queue
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.
2026-09-18 19:16:07 +08:00
woggioni c1e7f70a3b Add configurable napola rule with instant win on a full denari sweep 2026-09-18 19:14:54 +08:00
woggioni bf04a9b38d Auto-dismiss error toasts after 10 seconds 2026-09-18 19:14:49 +08:00
88 changed files with 6203 additions and 1928 deletions
+6 -2
View File
@@ -5,6 +5,9 @@ scientifico** — the four-player, fixed-partnership Italian card game:
- **`server/`** — backend: Python + [kaya](https://github.com/woggioni/kaya) - **`server/`** — backend: Python + [kaya](https://github.com/woggioni/kaya)
framework, OIDC login, live games in Redis, match statistics in Postgres. framework, OIDC login, live games in Redis, match statistics in Postgres.
A monorepo of three distributions sharing the `tavolo` namespace: the
game-independent `tavolo-platform`, the scopone game `tavolo-scopone`,
and the `tavolo-app` composition root wiring them together.
See [server/README.md](server/README.md). See [server/README.md](server/README.md).
- **`web/`** — frontend: Rust + [Sycamore](https://sycamore.dev) compiled to - **`web/`** — frontend: Rust + [Sycamore](https://sycamore.dev) compiled to
WebAssembly, built with [Trunk](https://trunkrs.dev). Card images are the WebAssembly, built with [Trunk](https://trunkrs.dev). Card images are the
@@ -53,9 +56,10 @@ Backend (from `server/`):
cd server cd server
python3 -m venv .venv python3 -m venv .venv
.venv/bin/pip install -r requirements.txt .venv/bin/pip install -r requirements.txt
.venv/bin/pip install -e '.[dev]' .venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e '.[dev]'
.venv/bin/python -m unittest discover -s tests -t . .venv/bin/python -m unittest discover -s tests -t .
.venv/bin/mypy src .venv/bin/python -m unittest discover -s packages/tavolo-platform/tests
.venv/bin/python -m unittest discover -s packages/tavolo-scopone/tests
``` ```
Frontend (from `web/`), with the backend running on :8080: Frontend (from `web/`), with the backend running on :8080:
+17
View File
@@ -61,6 +61,23 @@ data:
GAME_TTL_SECONDS: "86400" GAME_TTL_SECONDS: "86400"
HAND_ACK_TIMEOUT_SECONDS: "30" HAND_ACK_TIMEOUT_SECONDS: "30"
TURN_TIMEOUT_SECONDS: "30" TURN_TIMEOUT_SECONDS: "30"
# CORS (kaya-cors' CorsMixin). Disabled unless CORS_ALLOW_ORIGINS or
# CORS_ALLOW_ORIGIN_REGEX is set — unneeded when the SPA and the API are
# served from the same origin. See server/.env.example for details.
# CORS_ALLOW_ORIGINS: "https://example.com,https://app.example.com" # or "*"
# CORS_ALLOW_ORIGIN_REGEX: 'https://tavolo-[a-z0-9-]+\.vercel\.app'
# CORS_ALLOW_METHODS: "GET,POST" # default: GET; "*" = all
# CORS_ALLOW_HEADERS: "Authorization,Content-Type" # "*" mirrors the request
# CORS_ALLOW_CREDENTIALS: "false"
# CORS_EXPOSE_HEADERS: ""
# CORS_MAX_AGE: "600"
# OpenTelemetry (kaya-otel): traces + metrics via OTLP/HTTP, disabled
# unless OTEL_ENABLED is set. Requires the otel extra in the image.
# OTEL_ENABLED: "true"
# OTEL_SERVICE_NAME: "tavolo"
# OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector.observability:4318"
# OTEL_EXPORTER_OTLP_HEADERS: "Authorization=Bearer ..."
# OTEL_EXCLUDED_PATHS: "/api/health" # default; paths skipped by tracing
# OIDC (provider lives in another namespace). # OIDC (provider lives in another namespace).
OIDC_CLIENT_ID: tavolo OIDC_CLIENT_ID: tavolo
OIDC_POST_LOGIN_REDIRECT: / OIDC_POST_LOGIN_REDIRECT: /
+16
View File
@@ -113,6 +113,22 @@ services:
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30} HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30}
TURN_TIMEOUT_SECONDS: ${TURN_TIMEOUT_SECONDS:-30} TURN_TIMEOUT_SECONDS: ${TURN_TIMEOUT_SECONDS:-30}
# CORS is disabled unless CORS_ALLOW_ORIGINS or CORS_ALLOW_ORIGIN_REGEX
# is set (see server/.env.example for the full list of options).
CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-}
CORS_ALLOW_ORIGIN_REGEX: ${CORS_ALLOW_ORIGIN_REGEX:-}
CORS_ALLOW_METHODS: ${CORS_ALLOW_METHODS:-}
CORS_ALLOW_HEADERS: ${CORS_ALLOW_HEADERS:-}
CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-}
CORS_EXPOSE_HEADERS: ${CORS_EXPOSE_HEADERS:-}
CORS_MAX_AGE: ${CORS_MAX_AGE:-}
# OpenTelemetry (kaya-otel): disabled unless OTEL_ENABLED is set.
# Requires the otel extra in the image (see server/pyproject.toml).
OTEL_ENABLED: ${OTEL_ENABLED:-}
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-}
OTEL_EXCLUDED_PATHS: ${OTEL_EXCLUDED_PATHS:-}
ports: ports:
- "127.0.0.1:${APP_PORT:-8080}:8080" - "127.0.0.1:${APP_PORT:-8080}:8080"
+22
View File
@@ -39,6 +39,28 @@ TURN_TIMEOUT_SECONDS=30
# schema). Unset logs DEBUG to the console. # schema). Unset logs DEBUG to the console.
#LOGGING_CONFIG=/path/to/logging.yaml #LOGGING_CONFIG=/path/to/logging.yaml
# CORS (via kaya-cors' CorsMixin; same semantics as Starlette's
# CORSMiddleware). Disabled unless CORS_ALLOW_ORIGINS or
# CORS_ALLOW_ORIGIN_REGEX is set — the app serves the SPA and the API from
# the same origin, so no CORS headers are needed by default.
# Comma-separated list of origins allowed to make cross-origin requests,
# or "*" for any origin:
#CORS_ALLOW_ORIGINS=https://example.com,https://app.example.com
# Optional regex (fullmatch) allowed origins are additionally checked
# against — handy for dynamic preview URLs:
#CORS_ALLOW_ORIGIN_REGEX=https://tavolo-[a-z0-9-]+\.vercel\.app
# Comma-separated allowed methods, or "*" for all (default GET):
#CORS_ALLOW_METHODS=GET,POST
# Comma-separated allowed request headers, or "*" to mirror back whatever
# the browser requests (default: only the CORS-safelisted headers):
#CORS_ALLOW_HEADERS=Authorization,Content-Type
# Allow cookies/credentials on cross-origin requests (1/true/yes/on):
#CORS_ALLOW_CREDENTIALS=false
# Comma-separated response headers exposed to the browser:
#CORS_EXPOSE_HEADERS=
# Seconds browsers may cache the preflight response (default 600):
#CORS_MAX_AGE=600
# App server # App server
APP_HOST=0.0.0.0 APP_HOST=0.0.0.0
APP_PORT=8080 APP_PORT=8080
+14 -6
View File
@@ -48,16 +48,24 @@ RUN --mount=type=cache,target=/var/cache/apk \
WORKDIR /build WORKDIR /build
COPY server/pyproject.toml server/README.md server/requirements.txt ./
COPY server/src/ ./src/
# aerich migration files are a release artifact: the db-migrate compose
# service runs `aerich upgrade` from this image before the app starts.
COPY server/migrations/ ./migrations/
COPY server/requirements.txt ./
RUN --mount=type=cache,target=/root/.cache/pip \ RUN --mount=type=cache,target=/root/.cache/pip \
python3 -m venv /opt/venv \ python3 -m venv /opt/venv \
&& /opt/venv/bin/pip install --upgrade pip \ && /opt/venv/bin/pip install --upgrade pip \
&& /opt/venv/bin/pip install -r requirements.txt . && /opt/venv/bin/pip install -r requirements.txt
COPY server/pyproject.toml server/README.md ./
COPY server/src/ ./src/
# Sibling distributions, installed from the local tree (PEP 420 namespace
# packages sharing the `tavolo` namespace with the app above). --no-deps:
# every third-party dependency is already pinned in requirements.txt.
COPY server/packages/ ./packages/
RUN --mount=type=cache,target=/root/.cache/pip \
/opt/venv/bin/pip install --no-deps ./packages/tavolo-platform ./packages/tavolo-scopone .
# aerich migration files are a release artifact: the db-migrate compose
# service runs `aerich upgrade` from this image before the app starts.
COPY server/migrations/ ./migrations/
# --- Runtime --------------------------------------------------------------- # --- Runtime ---------------------------------------------------------------
FROM alpine:3.24 FROM alpine:3.24
+139 -60
View File
@@ -1,14 +1,26 @@
# tavolo # tavolo (backend)
The backend for a multiplayer card-game platform, built on the The backend for a multiplayer card-game platform, built on the
[kaya](../kaya) framework. The first game implemented is **scopone [kaya](../kaya) framework. It is a monorepo of three distributions
scientifico**, the four-player, fixed-partnership variant of the classic sharing the `tavolo` namespace (see
Italian card game. [`packages/tavolo-platform/README.md`](packages/tavolo-platform/README.md)
and [`packages/tavolo-scopone/README.md`](packages/tavolo-scopone/README.md)):
Players authenticate with the configured **OIDC** provider. Live game state - **`tavolo-platform`** (`packages/tavolo-platform/`) — everything
is kept in **Redis** (with real-time play over WebSocket), and completed game-independent: the lobby API, the live-game store, the WebSocket
matches — every participant and the final score — are persisted to endpoint, the deadline scheduler, match persistence and the Elo
**Postgres** for history and leaderboard statistics. leaderboards, plus the `GameEngine` contract games implement.
- **`tavolo-scopone`** (`packages/tavolo-scopone/`) — scopone
scientifico, the four-player, fixed-partnership variant of the classic
Italian card game: pure rules plus the platform adapter.
- **`tavolo-app`** (`src/tavolo/`, this `pyproject.toml`) — the
composition root wiring the platform to the scopone game, with the
environment-driven settings and the SPA shell.
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 game-specific result — are
persisted to **Postgres** for history and leaderboard statistics.
## Quick start (Docker Compose) ## Quick start (Docker Compose)
@@ -58,10 +70,22 @@ All configuration comes from environment variables (see `.env.example`):
| `OIDC_CLIENT_SECRET` | unset | OIDC client secret | | `OIDC_CLIENT_SECRET` | unset | OIDC client secret |
| `OIDC_REDIRECT_URI` | `http://localhost:8080/auth/callback` | Login callback URL | | `OIDC_REDIRECT_URI` | `http://localhost:8080/auth/callback` | Login callback URL |
| `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis | | `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 | | `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the scopone between-hands scoring summary waits for acknowledgements (wired into the `ScoponeEngine`) |
| `TURN_TIMEOUT_SECONDS` | `30` | Seconds a player has to play before the server plays a random legal card for them | | `TURN_TIMEOUT_SECONDS` | `30` | Seconds a scopone player has to play before the server plays a random legal card for them (wired into the `ScoponeEngine`) |
| `DEADLINE_HEARTBEAT_MS` | `1000` | Upper bound on the deadline consumer's poll interval (locally enqueued deadlines fire on time regardless) | | `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 | | `LOGGING_CONFIG` | unset | Path to a YAML logging configuration file (see below). Unset logs DEBUG to the console |
| `CORS_ALLOW_ORIGINS` | unset | Comma-separated origins allowed for cross-origin requests, or `*` for any. CORS is disabled unless this or `CORS_ALLOW_ORIGIN_REGEX` is set |
| `CORS_ALLOW_ORIGIN_REGEX` | unset | Regex (fullmatch) additionally matched against request origins, e.g. `https://tavolo-[a-z0-9-]+\.vercel\.app` |
| `CORS_ALLOW_METHODS` | `GET` | Comma-separated methods allowed for cross-origin requests, or `*` for all |
| `CORS_ALLOW_HEADERS` | unset | Comma-separated request headers allowed in cross-origin requests, or `*` to mirror back the requested ones. The CORS-safelisted headers are always allowed |
| `CORS_ALLOW_CREDENTIALS` | `false` | `1`/`true`/`yes`/`on` allow cookies/credentials on cross-origin requests |
| `CORS_EXPOSE_HEADERS` | unset | Comma-separated response headers exposed to the browser |
| `CORS_MAX_AGE` | `600` | Seconds browsers may cache the preflight response |
| `OTEL_ENABLED` | `false` | `1`/`true`/`yes`/`on` enable OpenTelemetry traces and metrics (requires the `otel` extra, i.e. `pip install tavolo-app[otel]`) |
| `OTEL_SERVICE_NAME` | `tavolo` | `service.name` resource attribute of the exported telemetry |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | unset | Base URL of an OTLP/HTTP collector (e.g. `http://localhost:4318`); unset uses the exporter default |
| `OTEL_EXPORTER_OTLP_HEADERS` | unset | Comma-separated `key=value` headers sent to the collector (e.g. authentication) |
| `OTEL_EXCLUDED_PATHS` | `/api/health` | Comma-separated paths excluded from tracing and metrics (exact matches) |
| `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address | | `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address |
## Logging ## Logging
@@ -103,34 +127,59 @@ loggers:
### Redis (live games) ### Redis (live games)
- `tavolo:game:<uuid>` — the whole match as JSON: players (seat 0/2 = team A, - `tavolo:game:<uuid>` — the whole session as JSON: the platform-owned
1/3 = team B), hands, table, captured piles, scope, current turn, dealer, envelope (`id`, `game_type`, `join_code`, seats, timestamps) plus the
scores, phase (`lobby``playing``finished`). Sliding TTL game-specific `state` blob, which only the registered engine
(`GAME_TTL_SECONDS`). interprets. Sliding TTL (`GAME_TTL_SECONDS`).
- `tavolo:code:<JOINCODE>` — the 6-character join code → game id index. - `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>:lock` — a short-lived lock serializing every mutation.
- `tavolo:game:<uuid>:events` — a pub/sub channel carrying "state changed" - `tavolo:game:<uuid>:events` — a pub/sub channel carrying "state changed"
signals; every open WebSocket reloads the state and pushes the signals; every open WebSocket reloads the session and pushes the
personalized view to its player. personalized view to its player.
- `tavolo:deadlines` — a sorted set (score = due timestamp) of pending - `tavolo:deadlines` — a sorted set (score = due timestamp) of pending
timeouts: turn auto-plays and hand-end auto-continues. Every worker runs timeouts (e.g. scopone's turn auto-plays and hand-end auto-continues).
a consumer that fires due entries under the per-game lock, so timeouts Every worker runs a consumer that fires due entries under the per-game
do not depend on any player being connected and survive the death of lock by calling the engine's `fire_deadline`, so timeouts do not depend
any worker (delivery is at-least-once; entries are revalidated against on any player being connected and survive the death of any worker
the live state before firing). (delivery is at-least-once; engines revalidate the entry's token
against the live state before firing).
### Postgres (statistics, via Tortoise ORM + aerich migrations) ### Postgres (statistics, via Tortoise ORM + aerich migrations)
- `match` — one row per finished match: the game played (`game_type`, one - `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 of the ids from `GET /api/game-types`, indexed so statistics can be
scoped per game), both teams' final scores, winner, target score, hands scoped per game), start/finish timestamps, and the game-specific outcome
played, start/finish timestamps. as reported by the engine (`result` JSON — for scopone: both teams'
final scores, winner, target score, hands played and the per-hand
audit).
- `match_player` — one row per participant: the OIDC `sub`, display name, - `match_player` — one row per participant: the OIDC `sub`, display name,
seat, team and whether they won. Unique per `(match, user_sub)`. seat, game-defined team label (nullable), whether they won, the points
they scored, the Elo change the match produced (`elo_delta`) and
game-specific extras (`details` JSON). Unique per `(match, user_sub)`.
- `player_rating` — current Elo rating per `(user_sub, game_type)`, with
the number of rated matches played.
When a match ends, the result is written transactionally to Postgres When a match ends, the engine's result is written transactionally to
(once, guarded by a flag on the Redis state); the finished state stays in Postgres (once, guarded by a flag on the session); the finished session
Redis until its TTL expires so clients can still fetch the final board. stays in Redis until its TTL expires so clients can still fetch the final
board.
### Elo ratings
Players carry a chess-style Elo rating per game type
(`tavolo.platform.elo`): everyone starts at 1500, a team's rating is the
mean of its members, and the standard formula
`E = 1 / (1 + 10 ** ((R_opp - R_team) / 400))` with `K = 32` decides how
many points the match result moves — the same delta for every member of
a team, zero-sum between the two teams. Ratings update in the same
transaction as the match result. To recompute every rating from the
recorded match history (e.g. to backfill matches recorded before ratings
existed):
```sh
.venv/bin/python -m tavolo.platform.backfill_elo \
--database-url postgres://tavolo:tavolo@localhost:5432/tavolo
```
## REST API ## REST API
@@ -139,12 +188,13 @@ All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
| Method | Path | Description | | Method | Path | Description |
|---|---|---| |---|---|---|
| `GET` | `/api/game-types` | The card games the platform can host (for the creation dropdown) | | `GET` | `/api/game-types` | The games the platform can host (for the creation dropdown), each with its `options_schema` |
| `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` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "options": {"target_score": 11, "napola": true}}`. Returns the lobby (`{id, join_code, players, seats_open, ...}`) |
| `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The fourth player triggers the deal | | `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The last player triggers the start and gets the game view back |
| `GET` | `/api/games/{id}` | Personalized snapshot (only your own hand is visible) | | `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/me/matches` | Cursor-paginated match history with the engine's `result` and per-player Elo deltas (`?limit=&cursor=&game_type=`) |
| `GET` | `/api/leaderboard` | Aggregated wins / matches / team points per player (`?game_type=`) | | `GET` | `/api/me/ratings` | The caller's Elo rating per game type |
| `GET` | `/api/leaderboard` | Elo rating, aggregated wins / matches / points per player, sorted by Elo (`?game_type=`) |
## WebSocket protocol ## WebSocket protocol
@@ -160,7 +210,10 @@ Server → client messages are JSON objects with a `type`:
- `{"type": "game_over", "scores": {"A": 11, "B": 7}, "winner": "A"}` - `{"type": "game_over", "scores": {"A": 11, "B": 7}, "winner": "A"}`
- `{"type": "error", "code": "illegal_move", "message": "..."}` - `{"type": "error", "code": "illegal_move", "message": "..."}`
Client → server messages: Client → server messages are JSON objects with an `action`. The
platform handles `state` (resend the snapshot) itself; every other action
is dispatched to the session's game engine with the rest of the message
as its payload. For scopone scientifico:
```json ```json
{"action": "play", "card": "07D", "capture": ["02D", "05C"]} {"action": "play", "card": "07D", "capture": ["02D", "05C"]}
@@ -212,34 +265,46 @@ must dismiss. A play attempted in this phase is rejected with an
- Hand points: `carte` (most captured cards), `denara` (most diamonds), - Hand points: `carte` (most captured cards), `denara` (most diamonds),
`settebello` (7♦), `primiera` (best 7/6/5/4 per suit, all four suits `settebello` (7♦), `primiera` (best 7/6/5/4 per suit, all four suits
required), plus one point per scopa. Ties award nothing. required), plus one point per scopa. Ties award nothing.
- Optional *napola* rule (per-game `napola` flag on `POST /api/games`, - Optional *napola* rule (per-game `napola` creation option, default on):
default on): the longest run of consecutive denari starting from the the longest run of consecutive denari starting from the ace scores one
ace scores one point per card once it reaches three cards (A-2-3 = 3, point per card once it reaches three cards (A-2-3 = 3, A-2-3-4 = 4, …).
A-2-3-4 = 4, …). A team that captures the whole denari suit (ace to A team that captures the whole denari suit (ace to king) wins the match
king) wins the match instantly, regardless of the score. instantly, regardless of the score.
- The match ends when a team reaches the target score (default 11, - 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 configurable per game via the `target_score` creation option) with a
broken by another hand. clear lead; a tie at or above the target is broken by another hand.
## Development ## Development
```sh ```sh
python3 -m venv .venv python3 -m venv .venv
.venv/bin/pip install -r requirements.txt .venv/bin/pip install -r requirements.txt
.venv/bin/pip install -e '.[dev]' .venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e '.[dev]'
# app integration suite:
.venv/bin/python -m unittest discover -s tests -t . .venv/bin/python -m unittest discover -s tests -t .
.venv/bin/mypy src # per-package suites (each package is self-contained):
.venv/bin/python -m unittest discover -s packages/tavolo-platform/tests
.venv/bin/python -m unittest discover -s packages/tavolo-scopone/tests
# type checks:
.venv/bin/python -m mypy -p tavolo.platform
.venv/bin/python -m mypy -p tavolo.scopone
.venv/bin/python -m mypy --namespace-packages --explicit-package-bases \
src/tavolo/app.py src/tavolo/config.py src/tavolo/static.py \
src/tavolo/aerich_config.py src/tavolo/logging_config.py
``` ```
Tests run fully in-process: in-memory sqlite for Postgres, in-memory stores 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` / for Redis, a fake OIDC user patched onto the mixins, and `httpx` /
`httpx-ws` ASGI transports for HTTP and WebSocket coverage. `httpx-ws` ASGI transports for HTTP and WebSocket coverage. The platform
suite additionally runs against a `DummyEngine` toy game, so the
game-independent machinery is covered without importing scopone.
### Migrations ### Migrations
The Postgres schema is owned by aerich migrations in `migrations/`. The The Postgres schema is owned by aerich migrations in `migrations/`. The
`db-migrate` compose service runs `aerich upgrade` before the app starts. `db-migrate` compose service runs `aerich upgrade` before the app starts.
To add a migration after changing `src/tavolo/models.py`: To add a migration after changing
`packages/tavolo-platform/src/tavolo/platform/models.py`:
```sh ```sh
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo .venv/bin/aerich migrate DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo .venv/bin/aerich migrate
@@ -252,29 +317,43 @@ baseline.)
## Layout ## Layout
Everything lives under `server/`:
``` ```
src/tavolo/ packages/tavolo-platform/ # game-independent platform (own pyproject + tests)
├── app.py # composition root: session/OIDC/Tortoise/OpenAPI mixins src/tavolo/platform/
├── config.py # env -> frozen Settings ├── __init__.py # public surface (GameEngine, GameSession, Platform, ...)
├── engine.py # the platform<->game contract
├── registry.py # game id -> engine lookup
├── mixin.py # Platform + PlatformMixin (mounts everything on a kaya app)
├── errors.py # shared GameError hierarchy
├── models.py # Match / MatchPlayer / PlayerRating (Postgres)
├── elo.py # chess-style Elo math (1500 start, K=32)
├── stats.py # MatchResult -> Postgres persistence + Elo update
├── backfill_elo.py # recompute all ratings from the match history
├── store.py # Redis / in-memory live-session store (+ deadline queue)
├── deadlines.py # connection-independent timeout scheduler
├── ws.py # WebSocket live-play endpoint (generic envelope)
├── auth.py # auth helpers (HTTP + WebSocket) ├── auth.py # auth helpers (HTTP + WebSocket)
├── http.py # JSON request/response helpers ├── http.py # JSON request/response helpers
├── pagination.py # keyset (cursor) pagination ├── pagination.py # keyset (cursor) pagination
├── openapi.py # shared OpenAPI parameter fragments ├── openapi.py # shared OpenAPI parameter fragments
├── tortoise_mixin.py # TortoiseORM lifecycle (HTTP + WebSocket) ├── 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/ └── routes/
├── health.py # GET /api/health ├── health.py # GET /api/health
├── games.py # lobby: create / join / snapshot ├── me.py # GET /api/me
└── stats.py # match history + leaderboard ├── games.py # lobby: game-types / create / join / snapshot
└── stats.py # match history + leaderboard + Elo ratings
packages/tavolo-scopone/ # scopone scientifico game (own pyproject + tests)
src/tavolo/scopone/
├── state.py # ScoponeState / PlayerState / Card, JSON (de)serialization
├── engine.py # pure scopone scientifico rules
├── errors.py # scopone-specific rule violations
└── plugin.py # ScoponeEngine: the GameEngine adapter
src/tavolo/ # tavolo-app: composition root (this pyproject.toml)
├── app.py # session/OIDC/Tortoise/OpenAPI/Platform/DeadlineScheduler mixins
├── config.py # env -> frozen Settings
├── aerich_config.py # aerich CLI configuration
├── logging_config.py # logging setup
└── static.py # SPA shell hosting
migrations/ # aerich migrations for the platform models
tests/ # app integration suite (platform + scopone wired together)
``` ```
@@ -0,0 +1,55 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
CREATE TABLE IF NOT EXISTS "player_rating" (
"id" UUID NOT NULL PRIMARY KEY,
"user_sub" VARCHAR(255) NOT NULL,
"game_type" VARCHAR(32) NOT NULL,
"rating" INT NOT NULL,
"matches_played" INT NOT NULL,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "uid_player_rati_user_su_655b53" UNIQUE ("user_sub", "game_type")
);
CREATE INDEX IF NOT EXISTS "idx_player_rati_game_ty_3326d1" ON "player_rating" ("game_type", "rating");
COMMENT ON TABLE "player_rating" IS 'Current Elo rating of one player for one game type.';
ALTER TABLE "match_player" ADD "elo_delta" SMALLINT;"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "match_player" DROP COLUMN "elo_delta";
DROP TABLE IF EXISTS "player_rating";"""
MODELS_STATE = (
"eJztmltv4jgUgP9KlKeuNFsBvY1Wq5WAUg07pVSF7q6mqiyTGLAa7IztbAd1+9/Xdm7EuR"
"RoYUrFyww59nHsz87xufTJnlEXefywB4UztX+znmwCZ0j+yDZ8smzo+6lYCQQceWHPpMuI"
"CwYdIYVj6HEkRS7iDsO+wJSork3LoTPfQwK5llaz6NiiBKn/xBRZDE0wF4jJ5omchyXmPu"
"KHamyXOnJwTCavGyYg+HuAgKATJDsyOdjdvRRj4qIfiMeP/gMYY+S5GSDYVQNoOVADKtnt"
"bff8QvdUUxwBh3rBjKS9/bmYUpJ0DwLsHiod1TZBBDEol7CAiwSeF2GNReGMpUCwACVTdV"
"OBi8Yw8BR0+/dxQBzF2tJvUv8c/xFNbaEbAFf9IRh0hgDYuT1SUzB4RyKHErW/mAgF6uk5"
"HDcFoqW2ekH7S/Pm4Oj0F42AcjFhulHjsp+1IhQwVNXQU8pqv0JeOdjtKWTFsDNKBnM55X"
"Vox4Iq3Nyhvjx1gDsYEYHH2KGbgi0/sh/AQ2Qi1Fd61KiA/1fzJuTf0Pyp/CLD7/Qqamno"
"JrUNKXaB4AxAuRLKCsgPZtDzukQU0zd1jQ2QS3ibDUhtSgw3xrcJ4BM1iV+PGmenn2Wrnq"
"N6OKsgP+g1Ly+7V8MitqNXsB3t2ZaxfcRELh0oTKsYDENtUyZju1QzJqK+hIWolxqIes4+"
"QCZvzDXPsKG7P8NZtlNIXA58D85RgYtRzdbU3bPNsuXy8Ml1AyjyZM8lEYFnqJhsVtPg6k"
"aqh/GPHaRcAXPY7XUGw2bvWg0/4/y7p3k1hx3V0tDSuSE9ODWsSTKI9Xd3+MVSj9a3/lXH"
"dAqTfsNvtpoTDAQFhD4C6C4yicWxKLPNY0wwn661z4bqfqPf3UariGz8sBAtKMEIOg+PkL"
"kg05KeCG0SGc+fhlakePH1BnlQs8xv+2Lwe61H2t19T6V2GMRooLRBy4jmm2aNmSmBBE70"
"ktS71ZsKkJWlE1KiLyQVwouNLZdbuJb2GjvY13saJwQCjpiFif6th8wnE1bQK8ge3KXpD6"
"UDeDCy7/cphfeUUkj2ZYUAYVFnawmFrQUHjZOTJcID2as0QNBtWVfLxVx9rkA/r4Da1PuA"
"wVijVluGd61Wzlu1Ga4tKnJ2qsOFWGcfJuRTNKuc2X3ioOSc5hIHj6F/ZfhglHoIkpLcTK"
"FHNpIqO8i2Amar37/MuN2t7tDAettrdWLashMWWpw/vsijcuqegKvag4ziWkYhusTeC+83"
"tAmhB7qai7ao85aO2vs9xS/4ZbnYzeCbh3tBGcIT8hXNNeKunAgkTpFnYFYodxNqLjyTYg"
"Yfk2ghc6bk6uWaUWgH2s1Bu3nesZ/L4+FNRnphFHcjwya9yFyol2n/VBXrhVEeYGnXF4O9"
"dsAYIsLqeNQK9eLILRzMGlOmH5P6bz7wW3OMwiBwMV5Iq5FmIHiXrVRGC77fx4f7+PBnW6"
"MtBIjvt7T/E0m/fSk/NaNZxqVOYKqwvbCwflKrbdYFbNSPz44/H50eJ35gIqlyBkv8QFRe"
"oCvlmlfcHt9dgRv4qrCyTt0mq/kByzY2Q9DtE28e3bu7W8ZJig5LVnE26bU2EcPFf+kYtV"
"R6qjDt85KLWr7Pb/w3iKX2p9AfLLA5kVP3Okdw8zH9K21Ouf/3L2IcF6Wpyr2SBZUP6JNs"
"xPtTH9UKhKPuH5BufaliQL2iGFDPFwPkGwUiBZfon4P+VTHhBRXz9sSOsP6zPMx3sSxQAV"
"fByFyRMdODXvMfE3f7st8y7z41QKsorbXNy+z5fy/uTSo="
)
@@ -0,0 +1,73 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "match" ADD "result" JSONB NOT NULL DEFAULT '{}';
UPDATE "match" SET "result" = jsonb_build_object(
'team_a_score', team_a_score,
'team_b_score', team_b_score,
'winner_team', winner_team,
'target_score', target_score,
'hands_played', hands_played);
ALTER TABLE "match" DROP COLUMN "winner_team";
ALTER TABLE "match" DROP COLUMN "team_b_score";
ALTER TABLE "match" DROP COLUMN "team_a_score";
ALTER TABLE "match" DROP COLUMN "target_score";
ALTER TABLE "match" DROP COLUMN "hands_played";
ALTER TABLE "match" ALTER COLUMN "game_type" DROP DEFAULT;
ALTER TABLE "match_player" ADD "details" JSONB NOT NULL DEFAULT '{}';
ALTER TABLE "match_player" ADD "score" DOUBLE PRECISION NOT NULL DEFAULT 0;
UPDATE "match_player" mp SET "score" = CASE WHEN mp.team = 'A'
THEN (mp_match.result->>'team_a_score')::float
ELSE (mp_match.result->>'team_b_score')::float END
FROM "match" mp_match WHERE mp_match.id = mp.match_id;
ALTER TABLE "match_player" ALTER COLUMN "team" DROP NOT NULL;
ALTER TABLE "match_player" ALTER COLUMN "team" TYPE VARCHAR(32) USING "team"::VARCHAR(32);"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "match" ADD "winner_team" VARCHAR(1) NOT NULL;
ALTER TABLE "match" ADD "team_b_score" SMALLINT NOT NULL;
ALTER TABLE "match" ADD "team_a_score" SMALLINT NOT NULL;
ALTER TABLE "match" ADD "target_score" SMALLINT NOT NULL;
ALTER TABLE "match" ADD "hands_played" SMALLINT NOT NULL;
ALTER TABLE "match" DROP COLUMN "result";
ALTER TABLE "match" ALTER COLUMN "game_type" SET DEFAULT 'scopone_scientifico';
ALTER TABLE "match_player" DROP COLUMN "details";
ALTER TABLE "match_player" DROP COLUMN "score";
ALTER TABLE "match_player" ALTER COLUMN "team" SET NOT NULL;
ALTER TABLE "match_player" ALTER COLUMN "team" TYPE VARCHAR(1) USING "team"::VARCHAR(1);"""
MODELS_STATE = (
"eJztmm1v4jgQx79KlFc9aa8C+rQ6rU4KlGq5hVIB3TttVUUmMdSqsbOOc13U63c/23l2El"
"ooUKj6piVjj+P8hoz/HvNozqgLsX/YA9y5M/8wHk0CZlB8yDd8MkzgealZGjgY47Bn0mXs"
"cwYcLowTgH0oTC70HYY8jiiRXS3DoTMPQw5dQ7kZdGJQAuU/fgcNBqfI55CJ5qmYh8HnHv"
"QP5dgudcTgiExfN0xA0M8A2pxOoejIxGA3t8KMiAt/QT++9O7tCYLYzQFBrhxA2W05oLRd"
"X3fOL1RPOcWx7VAczEja25vzO0qS7kGA3EPpI9umkEAGxCNkcJEA4whrbApnLAycBTCZqp"
"saXDgBAZbQzS+TgDiStaHuJP8c/xlNLdPNti/7I3vYHtm2WYiRnILGOzI5lMj4IsIlqMen"
"cNwUiLKa8gatr9bg4Oj0N4WA+nzKVKPCZT4pR8BB6Kqgp5RlvEJeBditO8DKYeecNOZiyq"
"vQjg0LcMekNsBWvFO/bAzJlMuX8qixgPV3axDibijcVLyA4Wt5GbU0VJOknlL2OWCChQ14"
"EfO5oMTRDJajzntqrN3I9TD+sB7yaTLZAvoFqEedXns4snpXcviZ7//Eipc1asuWhrLONe"
"vBqRaWZBDj787oqyEvjR/9y7b+riT9Rj9MOScQcGoT+mADN8skNsemXJgniCD/bqU4a64f"
"gd7pQIuhJJtCjP8a9i/L45t66KFFDjf+M7BYQDcV1Mw6NQ4Q5oj4h/K2b7FUSUK5KMdp86"
"Bn/aNn1Fa339TDJwdoivQqdcPkPrOmScMYOPcPgLl2riWNm4fBHDK/GLhm5HjxbQAxUM9b"
"DFVWol2pkfb3NUyt0VKrgNIGrSJabJo1ZroFEDBVjyTvLe9UgqxK9KZEn5G+tpf2fFYBX4"
"nlEznIUzGNZWvgQ2Ygoj6rIYuSdwm/Eo17k4p06WP7wdi8/RC+uyR8k7gsoXuzPu9P9jZO"
"Tl6ge0WvSuGr2vLK10W+fF1tdb0Eat1vU7i3mns13rXaS3jXatW8ZZu204Bl2nM4Axh3CK"
"/YY8BS0SnXhj1kPJWT+P2ocXb6WbSqOcqLswWshz2r2+1cjjSWHILZMt/ZuP9K39Xovd8Z"
"jBveET+ECktTYZRiCEg53YdSTTYWLnv4LV2As9nvd3MKudkZaVyve8324KCucItOiCtz8Q"
"vsO5SVZN0LTEFVKog9NMwT6bIpzrXtQz7vXze7beNq0G51hp1oS5JsMVVjHu6gbXU1uhBT"
"MWfMwbL5Nue4UtLdtWSxvpzrQg4QLtmhVW+tMy4fe+u17K2zAQm3XMvtSbI+69yZ7G7Sfm"
"YjUihWaHxLcrTIw2hKvsG5QtwREwHEKUvN+sHRfkIt1COEmYGHZHuc+06JpxfPDMPM3LKG"
"Leu8bT5VF4A2WdoIyxYDwMOHLNQ2cu2fFhU3wrKGzdKuz1Y3WgFjkHCjjakR+sWlinAwY0"
"KZukyO5YqVjhXHKK16ZDfI6SGRXvm4yR8gRQ98+1EQ+SiIvHU22kJFZHdPXN/VBjNNo3nG"
"lao8ddheHaR+UtvU5ifS5I368dnx56PT40SYJ5ZF6rwozNX6C/2w+F6SkCu5Fh23x3df4A"
"aePNhd5dw47/kOj41NBoHbJ3gerbv7e4ycnLK98Nhyk6rVggyV/wAtalmoVEHa5zmJWh3n"
"Nf80rDL/lOrBkpwTibrXCcHNF1lemXOq9d+/kPmorCpbrUoyLu9Qk2xE/cmXagnCUfd3SL"
"f+otOv+oLTr3rx9EvckUOy1A9zMi7brx6+TbHqLX6Ds/7F7Ol/ruuC3w=="
)
+64
View File
@@ -0,0 +1,64 @@
# tavolo-platform
The game-independent half of tavolo: lobby, live play, match history and
leaderboards. Everything here works for any game that implements the
[`GameEngine`](src/tavolo/platform/engine.py) contract; the package
itself ships no game.
## Contents
- `engine.py` — the platform↔game contract: `GameEngine` (ABC),
`GameSession` (the platform-owned envelope with an opaque `state` blob),
`Seat`, `Deadline` (kind + due time + revalidation token) and the
`MatchResult`/`PlayerResult` outcome types.
- `registry.py``GameRegistry`: game id → engine lookup, single source
of truth for which games exist.
- `mixin.py``Platform` (registry + store + scheduler + OIDC, the
collaborators every endpoint needs) and `PlatformMixin`, the kaya mixin
that registers all routes and the websocket endpoint on an app.
- `routes/``health` (`GET /api/health`), `me` (`GET /api/me`), `games`
(lobby: `GET /api/game-types`, `POST /api/games`, `POST /api/games/join`,
`GET /api/games/{id}`) and `stats` (`GET /api/me/matches`,
`GET /api/leaderboard`, `GET /api/me/ratings`).
- `ws.py` — the live-play websocket (`/ws/games/{id}`): connection
lifecycle, the message envelope and the publish/subscribe fan-out.
Game-specific actions are dispatched to the session's engine.
- `store.py` — live-session persistence: `RedisGameStore` (production)
and `InMemoryGameStore` (tests/dev) over a JSON envelope plus the
engine's opaque state blob, with per-game locks, change signals and the
shared deadline queue.
- `deadlines.py``DeadlineScheduler`: enqueue the deadline an engine
declares after each mutation; a per-loop consumer fires due entries
through `engine.fire_deadline` under the per-game lock. Engines
revalidate the token, so stale or duplicate deliveries are harmless.
- `models.py``Match` (`game_type`, timestamps, game-specific JSON
`result`), `MatchPlayer` (seat, team, won, points, Elo delta, JSON
`details`) and `PlayerRating` (Elo per `(user_sub, game_type)`).
- `stats.py``save_match_result` (engine `MatchResult` → Postgres,
transactionally, with Elo) and `apply_elo`.
- `elo.py` — chess-style Elo math generalized to two-team matches.
- `backfill_elo.py` — rebuild every rating from the match history:
`python -m tavolo.platform.backfill_elo --database-url postgres://…`.
- `auth.py`, `http.py`, `pagination.py`, `openapi.py`,
`tortoise_mixin.py` — OIDC helpers, JSON helpers, keyset pagination,
shared OpenAPI fragments, the TortoiseORM lifecycle mixin.
## Adding a game
Implement `GameEngine` (see `engine.py` for the full contract), register
it in a `GameRegistry`, and mount `PlatformMixin(Platform(...))` on a
`KayaApp` — see the composition root in `tavolo.app`. The canonical
example is
[`tavolo-scopone`](../tavolo-scopone/README.md).
## Development (from `server/`)
```sh
.venv/bin/python -m unittest discover -s packages/tavolo-platform/tests
.venv/bin/python -m mypy -p tavolo.platform
```
Tests run fully in-process against a `DummyEngine` (a two-player toy game
in `tests/helpers.py`): in-memory sqlite, in-memory stores, a patched
OIDC user and `httpx` / `httpx-ws` ASGI transports. No test in this
package may import a real game.
@@ -0,0 +1,38 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "tavolo-platform"
version = "0.1.0"
description = "Game-independent multiplayer game platform (lobby, live play, match history, leaderboards) built on the kaya framework"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"kaya-core",
"kaya-session",
"kaya-oidc",
"kaya-openapi",
"tortoise-orm",
"redis",
]
[tool.setuptools.packages.find]
where = ["src"]
namespaces = true
[tool.mypy]
python_version = "3.12"
ignore_missing_imports = true
plugins = []
# TortoiseORM auto-generates `<fk>_id` attributes on ForeignKeyField at
# runtime; without the (unavailable here) tortoise mypy plugin the stubs
# only declare the relation field. These are real attributes, not bugs.
[[tool.mypy.overrides]]
module = "tavolo.platform.models"
disable_error_code = ["attr-defined"]
[[tool.mypy.overrides]]
module = "tavolo.platform.routes.*"
disable_error_code = ["attr-defined"]
@@ -0,0 +1,53 @@
"""The game-independent half of tavolo: lobby, live play, history, ratings.
This package hosts every concern that does not depend on the rules of a
specific game: the lobby HTTP API, the live-game store, the websocket
endpoint, the deadline scheduler, match persistence and the Elo
leaderboards. A game plugs in by implementing
:class:`~tavolo.platform.engine.GameEngine` and registering it in a
:class:`~tavolo.platform.registry.GameRegistry`; the application then
mounts everything with :class:`~tavolo.platform.mixin.PlatformMixin`.
"""
from __future__ import annotations
from .engine import (
Deadline,
GameEngine,
GameSession,
MatchResult,
PlayerResult,
Seat,
)
from .errors import (
AlreadyJoined,
GameError,
GameFinished,
GameNotFound,
GameNotStarted,
IllegalMove,
LobbyFull,
NotYourTurn,
)
from .mixin import Platform, PlatformMixin
from .registry import GameRegistry, UnknownGameType
__all__ = [
"AlreadyJoined",
"Deadline",
"GameEngine",
"GameError",
"GameFinished",
"GameNotFound",
"GameNotStarted",
"GameRegistry",
"GameSession",
"IllegalMove",
"LobbyFull",
"MatchResult",
"NotYourTurn",
"Platform",
"PlatformMixin",
"PlayerResult",
"Seat",
"UnknownGameType",
]
@@ -1,17 +1,15 @@
"""Authentication helpers on top of the kaya-oidc mixin. """Authentication helpers on top of the kaya-oidc mixin.
Tavolo has no application roles: every authenticated user may create and The platform has no application roles: every authenticated user may
join games. Authorization beyond login is game membership, checked against create and join games. Authorization beyond login is game membership,
the live game state in Redis. checked against the seats of the live session.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any, Callable, Mapping, Optional from typing import Any, Callable, Mapping, Optional
from kaya.core import HttpContext, WebSocket from kaya.core import HttpContext, WebSocket
from kaya.oidc import OIDCUser from kaya.oidc import OIDCMixin, OIDCUser
from .app import oidc_mixin
def get_ws_user(ws: WebSocket) -> Optional[OIDCUser]: def get_ws_user(ws: WebSocket) -> Optional[OIDCUser]:
@@ -38,16 +36,17 @@ def display_name(user: OIDCUser) -> str:
return user.sub return user.sub
def require_auth(handler: Callable[..., Any]) -> Callable[..., Any]: def require_auth(oidc: OIDCMixin) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""Decorator: gate a handler on being authenticated (any OIDC user). """Build a decorator gating a handler on being authenticated.
Responds ``401`` with a JSON error envelope when unauthenticated Responds ``401`` with a JSON error envelope when unauthenticated
unlike kaya's built-in ``OIDCMixin.require_auth`` which redirects to unlike kaya's built-in ``OIDCMixin.require_auth`` which redirects to
the login page (wrong for a JSON API). the login page (wrong for a JSON API).
""" """
def decorator(handler: Callable[..., Any]) -> Callable[..., Any]:
async def guarded(ctx: HttpContext, *args: Any, **kwargs: Any) -> None: async def guarded(ctx: HttpContext, *args: Any, **kwargs: Any) -> None:
if oidc_mixin.get_user(ctx) is None: if oidc.get_user(ctx) is None:
await ctx.send_bytes( await ctx.send_bytes(
401, 401,
b'{"error":"unauthenticated"}', b'{"error":"unauthenticated"}',
@@ -57,3 +56,5 @@ def require_auth(handler: Callable[..., Any]) -> Callable[..., Any]:
await handler(ctx, *args, **kwargs) await handler(ctx, *args, **kwargs)
return guarded return guarded
return decorator
@@ -0,0 +1,81 @@
"""Recompute every Elo rating from the recorded match history.
Ratings are deterministic given the finished matches, so this replays all
matches in chronological order and rewrites the ``player_rating`` table
and each ``match_player.elo_delta`` from scratch. Run any time ratings
need to be rebuilt::
python -m tavolo.platform.backfill_elo --database-url postgres://...
"""
from __future__ import annotations
import argparse
import asyncio
from collections import defaultdict
from logging import getLogger
from typing import Dict, List
from tortoise.transactions import in_transaction
from .stats import apply_elo
from .tortoise_mixin import TortoiseMixin
log = getLogger(__name__)
async def backfill_elo() -> int:
"""Rebuild all ratings; returns the number of matches replayed."""
from .models import Match, MatchPlayer, PlayerRating
replayed = 0
async with in_transaction():
await PlayerRating.all().delete()
matches = await Match.all().order_by("finished_at", "id")
for match in matches:
players = await MatchPlayer.filter(match_id=match.id)
by_team: Dict[str, List[str]] = defaultdict(list)
for player in players:
if player.team is not None:
by_team[player.team].append(player.user_sub)
# Deterministic team order; the winner is the team of any
# player flagged as having won.
teams = [by_team[team] for team in sorted(by_team)]
winner_index = next(
(
i
for i, members in enumerate(teams)
if any(p.won and p.user_sub in members for p in players)
),
0,
)
deltas = await apply_elo(match.game_type, teams, winner_index)
for player in players:
player.elo_delta = deltas[player.user_sub]
await player.save()
replayed += 1
return replayed
async def _main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--database-url",
required=True,
help="Tortoise database URL, e.g. postgres://user:pass@host/db",
)
args = parser.parse_args()
mixin = TortoiseMixin(
database_url=args.database_url,
models_modules=["tavolo.platform.models"],
)
await mixin._bind()
try:
replayed = await backfill_elo()
log.info("elo backfill complete: %d matches replayed", replayed)
print(f"Recomputed ratings from {replayed} matches.")
finally:
await mixin.aclose()
if __name__ == "__main__":
asyncio.run(_main())
@@ -0,0 +1,239 @@
"""Deadline-driven timeouts, independent of player connections.
In-match timeouts (e.g. scopone's per-turn auto-play and hand-end
auto-continue) are driven by absolute deadlines, never by which players
(or whether any players) are connected. The platform owns the scheduling
machinery; the *meaning* of each deadline belongs to the game engine:
* after every mutation the engine's
:meth:`~tavolo.platform.engine.GameEngine.next_deadline` computes the
deadline the new state implies (kind + due time + a revalidation
token), and the scheduler enqueues it in the store's shared deadline
queue (a Redis sorted set in production, see
:mod:`tavolo.platform.store`); enqueueing is idempotent;
* a background consumer running on **every** worker polls the queue for
due entries and hands them to the engine's
:meth:`~tavolo.platform.engine.GameEngine.fire_deadline` under the
per-game lock; the engine revalidates the token against the live state
and raises :class:`~tavolo.platform.errors.GameError` when the entry
was overtaken by events, so duplicate or stale deliveries are harmless.
Delivery is at-least-once: an entry is removed from the queue only after
it has been processed. If a worker dies mid-processing, the entry stays
in Redis and another worker's consumer picks it up. Entries whose game
has expired are dropped the first time they fire, so the queue is
self-cleaning.
"""
from __future__ import annotations
import asyncio
import json
import time
from datetime import datetime, timezone
from logging import getLogger
from typing import Any, Dict, Optional
from kaya.core import KayaApp, KayaMixin
from .engine import GameSession
from .errors import GameError
from .registry import GameRegistry
from .stats import save_match_result
from .store import GameStore
log = getLogger(__name__)
def encode(entry: Dict[str, Any]) -> str:
"""Canonical queue-member encoding for a deadline entry."""
return json.dumps(entry, sort_keys=True)
def _decode(member: Any) -> Optional[Dict[str, Any]]:
if isinstance(member, bytes):
member = member.decode("utf-8")
if not isinstance(member, str):
return None
try:
entry = json.loads(member)
except ValueError:
return None
return entry if isinstance(entry, dict) else None
class DeadlineScheduler:
"""Enqueue and fire the deadlines game engines ask for.
Holds the store and the registry so a single instance serves every
game type. One consumer task (and its wake-up event) is kept per
event loop — tests run each test on a fresh loop.
"""
def __init__(
self,
store: GameStore,
registry: GameRegistry,
heartbeat_ms: int = 1000,
) -> None:
self._store = store
self._registry = registry
self._heartbeat = heartbeat_ms / 1000
self._consumers: Dict[asyncio.AbstractEventLoop, asyncio.Task] = {}
self._wake_events: Dict[asyncio.AbstractEventLoop, asyncio.Event] = {}
async def sync_deadline(self, session: GameSession) -> None:
"""Enqueue the deadline the current state implies, if any.
Called after every mutation that can set a deadline and as a
backstop when a client connects. Enqueueing is idempotent: an
identical entry is already queued with the same due time, so
re-adding it changes nothing.
"""
engine = self._registry.require(session.game_type)
deadline = engine.next_deadline(session)
if deadline is None:
return
entry = {
"game_id": session.id,
"kind": deadline.kind,
"token": deadline.token,
}
self.ensure_consumer()
due_at = deadline.due_at.timestamp()
await self._store.add_deadline(encode(entry), due_at)
wake = self._wake_events.get(asyncio.get_running_loop())
if wake is not None:
wake.set()
async def finalize_mutation(self, session: GameSession) -> None:
"""Persist a successful mutation, notify subscribers and enqueue
the next deadline.
Callers must hold the per-game lock. Handles the terminal
transition: the match result is written to Postgres once
(guarded by ``stats_saved``).
"""
engine = self._registry.require(session.game_type)
if engine.is_finished(session):
if session.finished_at is None:
session.finished_at = datetime.now(timezone.utc)
await save_match_result(session, engine)
log.info("game %s (%s) finished", session.id, session.game_type)
await self._store.save(session)
await self._store.publish(session.id)
await self.sync_deadline(session)
async def process_due(self, member: Any) -> None:
"""Fire a single due deadline entry.
The engine revalidates the entry against the live state under
the per-game lock; stale or foreign entries are discarded
without effect. The entry is removed from the queue once handled
(including "nothing to do"); if handling fails unexpectedly
(e.g. the lock cannot be acquired), the entry is left in the
queue so another consumer retries it.
"""
entry = _decode(member)
if entry is None:
log.warning("deadline consumer: dropping malformed entry %r", member)
await self._store.remove_deadline(member)
return
game_id = entry.get("game_id")
kind = entry.get("kind")
token = entry.get("token")
if not isinstance(game_id, str) or not isinstance(kind, str):
await self._store.remove_deadline(member)
return
async with self._store.lock(game_id):
session = await self._store.load(game_id)
if session is not None:
engine = self._registry.require(session.game_type)
try:
engine.fire_deadline(session, kind, str(token))
except GameError:
# Stale or inapplicable entry: nothing to do.
pass
else:
await self.finalize_mutation(session)
await self._store.remove_deadline(member)
# --- consumer lifecycle -------------------------------------------------
def ensure_consumer(
self, loop: Optional[asyncio.AbstractEventLoop] = None
) -> None:
"""Start the deadline consumer on the given (or running) loop if
not yet running.
Called lazily whenever a deadline is enqueued (the ASGI test
transport never fires the lifespan hooks, so the mixin's
``setup`` alone is not enough) and on application startup. The
explicit ``loop`` matters at startup: under RSGI granian calls
``setup`` before the loop runs, so ``asyncio.get_running_loop()``
would fail there.
"""
if loop is None:
loop = asyncio.get_running_loop()
for old in list(self._consumers):
if old.is_closed():
self._consumers.pop(old, None)
self._wake_events.pop(old, None)
task = self._consumers.get(loop)
if task is None or task.done():
self._wake_events[loop] = asyncio.Event()
self._consumers[loop] = loop.create_task(self._run(loop))
log.debug("deadline consumer started")
def stop_consumer(self, loop: asyncio.AbstractEventLoop) -> None:
task = self._consumers.pop(loop, None)
self._wake_events.pop(loop, None)
if task is not None:
task.cancel()
async def _run(self, loop: asyncio.AbstractEventLoop) -> None:
wake = self._wake_events[loop]
while True:
# Clear before polling so an enqueue racing the poll re-wakes us.
wake.clear()
delay = self._heartbeat
try:
for member in await self._store.due_deadlines(time.time()):
try:
await self.process_due(member)
except asyncio.CancelledError:
raise
except Exception:
# Left in the queue; retried on the next pass.
log.exception("deadline consumer: failed to process %r", member)
next_due = await self._store.next_deadline()
if next_due is not None:
delay = max(0.0, min(self._heartbeat, next_due - time.time()))
except asyncio.CancelledError:
raise
except Exception:
log.exception("deadline consumer: poll failed; retrying")
try:
await asyncio.wait_for(wake.wait(), timeout=delay)
except asyncio.TimeoutError:
pass
class DeadlineSchedulerMixin(KayaMixin):
"""Run the deadline consumer for the whole app lifetime.
Every worker (and every pod) runs the same consumer; coordination
happens exclusively through the shared deadline queue and the
per-game locks, so any worker may fire any game's deadline.
"""
def __init__(self, scheduler: DeadlineScheduler) -> None:
self._scheduler = scheduler
def apply(self, app: KayaApp) -> None:
pass
def setup(self, loop: asyncio.AbstractEventLoop) -> None:
self._scheduler.ensure_consumer(loop)
def shutdown(self, loop: asyncio.AbstractEventLoop) -> None:
self._scheduler.stop_consumer(loop)
@@ -0,0 +1,49 @@
"""Chess-style Elo ratings, generalized to two-team matches.
Every player starts at :data:`INITIAL_RATING`. A team's rating is the mean
of its members' current ratings, so the usual chess formula applies
unchanged between the two teams:
* expected score ``E = 1 / (1 + 10 ** ((R_opponent - R_team) / 400))``
* actual score ``S`` is 1 for a win and 0 for a loss (matches never draw)
* every member of a team gains/loses the same ``round(K * (S - E))``
Deltas are rounded to integers and ratings are stored as integers, so the
system is exactly zero-sum: what the winners gain the losers lose.
"""
from __future__ import annotations
from typing import Sequence
INITIAL_RATING = 1500
K_FACTOR = 32
def expected_score(rating: float, opponent_rating: float) -> float:
"""Expected score (0..1) of a side rated ``rating`` against
``opponent_rating``."""
return 1.0 / (1.0 + 10.0 ** ((opponent_rating - rating) / 400.0))
def team_rating(ratings: Sequence[float]) -> float:
"""A team's rating is the mean of its members' ratings."""
if not ratings:
raise ValueError("a team needs at least one rating")
return sum(ratings) / len(ratings)
def match_delta(
team_a_ratings: Sequence[float],
team_b_ratings: Sequence[float],
winner_team: int,
) -> int:
"""Rating change applied to each member of team A.
``winner_team`` is 0 when team A won, 1 when team B won. Team B
members change by the negation of the returned value (zero-sum).
"""
rating_a = team_rating(team_a_ratings)
rating_b = team_rating(team_b_ratings)
expected = expected_score(rating_a, rating_b)
score = 1.0 if winner_team == 0 else 0.0
return round(K_FACTOR * (score - expected))
@@ -0,0 +1,247 @@
"""The contract between the platform and a game implementation.
The platform owns everything that is game-independent: the lobby, live
game storage, websockets, timeouts, match history and leaderboards. A
game (e.g. scopone scientifico) implements :class:`GameEngine` and plugs
into the platform through :class:`~tavolo.platform.registry.GameRegistry`;
the platform never imports a concrete game module.
The central type is :class:`GameSession`: the platform-owned envelope
carrying the lifecycle metadata (id, join code, seats, timestamps) plus
an opaque ``state`` blob that only the engine interprets. The engine
serializes/deserializes that blob (:meth:`GameEngine.state_to_json` /
:meth:`GameEngine.state_from_json`) so the store stays game-agnostic.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Mapping, Optional
from .errors import GameError
__all__ = [
"Deadline",
"GameEngine",
"GameSession",
"MatchResult",
"PlayerResult",
"Seat",
]
@dataclass
class Seat:
"""One occupied chair in a game, owned by the platform.
Games may keep their own per-player state internally; the seat is
what the platform needs for the lobby, for access control and for
persisting participations.
"""
user_sub: str
display_name: str
# Game-defined team label ("A"/"B", ...); ``None`` for games without
# fixed teams.
team: Optional[str] = None
def to_json(self) -> Dict[str, Any]:
return {
"user_sub": self.user_sub,
"display_name": self.display_name,
"team": self.team,
}
@staticmethod
def from_json(data: Mapping[str, Any]) -> "Seat":
return Seat(
user_sub=str(data["user_sub"]),
display_name=str(data["display_name"]),
team=data.get("team"),
)
@dataclass
class GameSession:
"""The platform-owned envelope of a live game.
``state`` is opaque to the platform: only the engine registered for
``game_type`` reads or writes it.
"""
id: str
game_type: str
join_code: str
creator_sub: str
players: List[Seat] = field(default_factory=list)
created_at: Optional[datetime] = None
finished_at: Optional[datetime] = None
# Set once the finished match has been copied to Postgres.
stats_saved: bool = False
state: Any = None
def seated(self, sub: str) -> bool:
"""Whether ``sub`` occupies a seat in this game."""
return self.seat_of(sub) is not None
def seat_of(self, sub: str) -> Optional[Seat]:
for seat in self.players:
if seat.user_sub == sub:
return seat
return None
@dataclass(frozen=True)
class Deadline:
"""A timeout the engine wants the platform to fire.
``kind`` is an engine-defined string (e.g. ``"turn"``); ``token`` is
an opaque revalidation token: when the deadline fires, the engine must
recompute it from the live session and refuse to act (raising
:class:`~tavolo.platform.errors.GameError`) if it no longer matches,
which makes duplicate or overtaken deliveries harmless.
"""
kind: str
due_at: datetime
token: str
@dataclass
class PlayerResult:
"""The outcome of a finished match for one participant."""
user_sub: str
seat: int
won: bool
team: Optional[str] = None
# Points scored, aggregated by the leaderboard.
score: float = 0.0
# Game-specific extras persisted alongside the participation.
details: Dict[str, Any] = field(default_factory=dict)
@dataclass
class MatchResult:
"""The game-independent outcome of a finished match.
``teams`` groups the participants' subs into the sides the Elo rating
treats as opponents (exactly two for now); ``winner_team`` is the
index of the winning side. ``summary`` is persisted verbatim as the
match's JSON ``result`` column.
"""
teams: List[List[str]]
winner_team: int
players: List[PlayerResult]
summary: Dict[str, Any] = field(default_factory=dict)
class GameEngine(ABC):
"""The interface every playable game implements.
Implementations must be deterministic and I/O-free apart from
logging: all persistence, locking and transport concerns belong to
the platform. Rule violations are reported by raising
:class:`~tavolo.platform.errors.GameError` subclasses.
"""
#: Unique id carried by sessions and persisted matches.
id: str
#: Human-readable name and description, for the lobby UI.
name: str
description: str
min_players: int
max_players: int
#: JSON-schema fragment describing the game-specific creation options
#: accepted by :meth:`create` (exposed via ``GET /api/game-types``).
options_schema: Mapping[str, Any] = {}
@abstractmethod
def create(self, session: GameSession, options: Mapping[str, Any]) -> None:
"""Initialize ``session.state`` for a fresh lobby game.
The platform has already filled the session envelope and seated
the creator (``session.players[0]``, without a team label yet).
Assign the creator's team here if the game has fixed teams, then
validate ``options`` (the opaque object from the create request;
raise :class:`~tavolo.platform.errors.GameError` on invalid
values) and initialize ``session.state``.
"""
@abstractmethod
def join(self, session: GameSession, user_sub: str, display_name: str) -> None:
"""Seat a player, starting the match when the lobby fills up.
Must append a :class:`Seat` to ``session.players`` and mirror
whatever per-player state the game keeps in ``session.state``.
Raises ``AlreadyJoined`` / ``LobbyFull`` / ``GameNotStarted``.
"""
@abstractmethod
def handle_action(
self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any]
) -> None:
"""Apply one player action received over the websocket.
``payload`` is the client message minus its ``action`` field.
Unknown actions and rule violations raise
:class:`~tavolo.platform.errors.GameError`.
"""
@abstractmethod
def view_for(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
"""The personalized game view for one player.
Must hide information the player is not entitled to (e.g. other
players' hands). The platform merges in the envelope fields
(``id``, ``join_code``, ``game_type``) itself.
"""
@abstractmethod
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
"""Game-specific fields of the lobby payload (options echo, phase)."""
@abstractmethod
def in_lobby(self, session: GameSession) -> bool:
"""Whether the game is still waiting for players."""
@abstractmethod
def is_finished(self, session: GameSession) -> bool:
"""Whether the match is over and a result can be extracted."""
@abstractmethod
def result(self, session: GameSession) -> MatchResult:
"""The outcome of the match. Only called when finished."""
@abstractmethod
def state_to_json(self, state: Any) -> Dict[str, Any]:
"""Serialize the opaque game state to plain JSON."""
@abstractmethod
def state_from_json(self, data: Mapping[str, Any]) -> Any:
"""Rebuild the opaque game state from its JSON form."""
@abstractmethod
def next_deadline(self, session: GameSession) -> Optional[Deadline]:
"""The deadline the current state implies, if any.
Called after every mutation; the platform enqueues the returned
deadline (enqueueing is idempotent) and never removes previously
enqueued ones — stale entries are discarded at fire time by
:meth:`fire_deadline` revalidation.
"""
@abstractmethod
def fire_deadline(self, session: GameSession, kind: str, token: str) -> None:
"""Apply the timeout action for a due deadline.
Must recompute the current deadline and raise
:class:`~tavolo.platform.errors.GameError` without acting when
``kind``/``token`` no longer match the live state.
"""
def game_over_view(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
"""Extra fields merged into the ``game_over`` websocket message."""
return {}
@@ -0,0 +1,43 @@
"""Typed errors shared between the platform and game implementations.
Game engines raise :class:`GameError` subclasses on rule/validation
failures; the platform translates them into 4xx HTTP responses or
``error`` WebSocket messages, using :attr:`GameError.code` as the
machine-readable error code. Engines themselves stay transport-agnostic.
"""
from __future__ import annotations
class GameError(Exception):
"""Base class for every rule/validation failure a game can raise."""
#: Machine-readable code carried by WebSocket ``error`` messages.
code = "illegal_move"
class IllegalMove(GameError):
"""The requested action violates the rules of the game."""
class NotYourTurn(GameError):
"""A player attempted to act out of turn."""
class GameNotStarted(GameError):
"""An action was attempted before the game left the lobby."""
class GameFinished(GameError):
"""An action was attempted after the match ended."""
class LobbyFull(GameError):
"""A game already has its full complement of players."""
class AlreadyJoined(GameError):
"""A player tried to join a game they are already seated in."""
class GameNotFound(GameError):
"""No live game exists for the given id or join code."""
@@ -0,0 +1,50 @@
"""The :class:`PlatformMixin`: mounts the whole platform on a kaya app.
``Platform`` bundles the collaborators every platform endpoint needs —
the game registry, the live-game store, the deadline scheduler and the
OIDC mixin used for authentication — so route and websocket modules
never reach for module-global state. :class:`PlatformMixin` is a plain
:class:`~kaya.core.KayaMixin`: applying it registers the lobby, stats
and health HTTP routes plus the live-play websocket endpoint, and the
app stays a :class:`~kaya.core.KayaApp` (both ASGI and RSGI keep
working).
"""
from __future__ import annotations
from dataclasses import dataclass
from kaya.core import KayaApp, KayaMixin
from kaya.oidc import OIDCMixin
from .deadlines import DeadlineScheduler
from .registry import GameRegistry
from .store import GameStore
@dataclass(frozen=True)
class Platform:
"""The collaborators shared by all platform endpoints."""
registry: GameRegistry
game_store: GameStore
scheduler: DeadlineScheduler
oidc: OIDCMixin
class PlatformMixin(KayaMixin):
"""Register the game-independent routes and the websocket endpoint."""
def __init__(self, platform: Platform) -> None:
self.platform = platform
def apply(self, app: KayaApp) -> None:
# Imported here so module import order stays acyclic: the route
# modules reference Platform only for typing.
from . import ws
from .routes import games, health, me, stats
health.register(app, self.platform)
me.register(app, self.platform)
games.register(app, self.platform)
stats.register(app, self.platform)
ws.register(app, self.platform)
@@ -0,0 +1,86 @@
"""Tortoise ORM models: match statistics persisted in Postgres.
Live game state lives in Redis (see :mod:`tavolo.platform.store`); only
completed matches are written here. The schema is game-independent:
* :class:`Match` — one row per finished match; everything game-specific
(scores, hands played, options, per-hand audit trail) lives in the
JSON ``result`` column produced by the game's engine.
* :class:`MatchPlayer` — one row per participant, linking an OIDC
``sub`` to a seat, an optional team label, whether they won, the
points they scored and their Elo delta.
* :class:`PlayerRating` — current chess-style Elo rating of a player for
one game type, updated transactionally with every finished match (see
:mod:`tavolo.platform.elo`).
"""
from __future__ import annotations
from tortoise import fields
from tortoise.models import Model
from .elo import INITIAL_RATING
class Match(Model):
"""A completed match of one of the registered game types."""
id = fields.UUIDField(pk=True)
# Which game was played (an id from the game registry).
game_type = fields.CharField(max_length=32, db_index=True)
started_at = fields.DatetimeField()
finished_at = fields.DatetimeField()
# Game-specific outcome summary as reported by the engine's
# ``MatchResult.summary`` (scores, hands played, options, ...).
result: dict = fields.JSONField(default=dict)
players: fields.ReverseRelation["MatchPlayer"]
class Meta:
table = "match"
ordering = ["-finished_at"]
class MatchPlayer(Model):
"""Participation of one user in one match."""
id = fields.UUIDField(pk=True)
match: fields.ForeignKeyRelation[Match] = fields.ForeignKeyField(
"models.Match", related_name="players", on_delete=fields.CASCADE
)
# OIDC subject of the player; no local users table.
user_sub = fields.CharField(max_length=255, db_index=True)
display_name = fields.CharField(max_length=200)
seat = fields.SmallIntField()
# Game-defined team label; null for games without fixed teams.
team = fields.CharField(max_length=32, null=True)
won = fields.BooleanField()
# Points the player scored, aggregated by the leaderboard.
score = fields.FloatField(default=0)
# Elo change this match produced for the player (see
# tavolo.platform.elo); null for matches recorded before ratings
# existed.
elo_delta = fields.SmallIntField(null=True)
# Game-specific extras reported by the engine's PlayerResult.
details: dict = fields.JSONField(default=dict)
class Meta:
table = "match_player"
unique_together = (("match", "user_sub"),)
class PlayerRating(Model):
"""Current Elo rating of one player for one game type."""
id = fields.UUIDField(pk=True)
# OIDC subject of the player; no local users table.
user_sub = fields.CharField(max_length=255)
# Which game the rating applies to (an id from the game registry).
game_type = fields.CharField(max_length=32)
rating = fields.IntField(default=INITIAL_RATING)
matches_played = fields.IntField(default=0)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "player_rating"
unique_together = (("user_sub", "game_type"),)
indexes = (("game_type", "rating"),)
@@ -0,0 +1,60 @@
"""Registry of the games the platform can host.
The registry maps a ``game_type`` id to its
:class:`~tavolo.platform.engine.GameEngine` implementation and is the
single source of truth for which games exist: the lobby lists it, the
store uses it to deserialize opaque game state, and the stats endpoints
use it to validate ``game_type`` filters. Games register themselves at
composition time (see the application entry point); the platform itself
ships no game.
"""
from __future__ import annotations
from typing import Dict, Iterable, List, Optional
from .engine import GameEngine
from .errors import GameError
class UnknownGameType(GameError):
"""A request named a ``game_type`` no registered engine provides."""
code = "unknown_game_type"
class GameRegistry:
"""An ordered collection of game engines, keyed by their id."""
def __init__(self, engines: Iterable[GameEngine] = ()) -> None:
self._engines: Dict[str, GameEngine] = {}
for engine in engines:
self.register(engine)
def register(self, engine: GameEngine) -> GameEngine:
"""Add ``engine``; the first registered becomes the default."""
if engine.id in self._engines:
raise ValueError(f"duplicate game engine id: {engine.id!r}")
self._engines[engine.id] = engine
return engine
def get(self, game_type: str) -> Optional[GameEngine]:
"""Return the engine for ``game_type``, or ``None``."""
return self._engines.get(game_type)
def require(self, game_type: str) -> GameEngine:
"""Return the engine for ``game_type`` or raise."""
engine = self.get(game_type)
if engine is None:
raise UnknownGameType(f"unknown game_type: {game_type!r}")
return engine
@property
def default(self) -> GameEngine:
"""The first registered engine, used when no type is requested."""
try:
return next(iter(self._engines.values()))
except StopIteration:
raise RuntimeError("no game engines registered") from None
def all(self) -> List[GameEngine]:
return list(self._engines.values())
@@ -0,0 +1 @@
"""HTTP endpoints of the platform: health, identity, lobby, statistics."""
@@ -0,0 +1,287 @@
"""Game lobby endpoints.
A game starts as a lobby: the creator is seated first and shares the
six-character ``join_code``. When the lobby fills up, the engine starts
the match. Live play then happens over the ``/ws/games/{id}`` websocket
(see :mod:`tavolo.platform.ws`); these endpoints cover creation, joining
and snapshotting state.
Everything game-specific — which options a game accepts, when the lobby
is full, what the personalized view looks like — is delegated to the
engine registered for the session's ``game_type``.
"""
from __future__ import annotations
import secrets
import uuid
from datetime import datetime, timezone
from logging import getLogger
from typing import TYPE_CHECKING, Any, Dict
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from ..auth import display_name, require_auth
from ..engine import GameSession, Seat
from ..errors import GameError
from ..http import JsonRequestError, read_json, read_json_optional, send_error, send_json
if TYPE_CHECKING:
from ..mixin import Platform
log = getLogger(__name__)
# Join codes avoid characters that are easy to confuse when read aloud.
_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
_CODE_LENGTH = 6
_MAX_CODE_ATTEMPTS = 20
def _now_code() -> str:
return "".join(secrets.choice(_CODE_ALPHABET) for _ in range(_CODE_LENGTH))
async def _unique_code(platform: "Platform") -> str:
for _ in range(_MAX_CODE_ATTEMPTS):
code = _now_code()
if await platform.game_store.find_by_code(code) is None:
return code
raise RuntimeError("could not allocate a unique join code")
def _lobby_payload(platform: "Platform", session: GameSession) -> Dict[str, Any]:
engine = platform.registry.require(session.game_type)
return {
"id": session.id,
"join_code": session.join_code,
"game_type": session.game_type,
"players": [
{
"sub": seat.user_sub,
"name": seat.display_name,
"seat": index,
"team": seat.team,
}
for index, seat in enumerate(session.players)
],
"seats_open": engine.max_players - len(session.players),
**engine.lobby_view(session),
}
def _view_payload(platform: "Platform", session: GameSession, sub: str) -> Dict[str, Any]:
engine = platform.registry.require(session.game_type)
return {
"id": session.id,
"join_code": session.join_code,
"game_type": session.game_type,
**engine.view_for(session, sub),
}
def register(app: KayaApp, platform: "Platform") -> None:
@app.GET("/api/game-types")
@operation(summary="List available games",
description="Every game the platform can host, for the "
"match-creation dropdown. ``options_schema`` "
"describes the per-game creation options accepted "
"by POST /api/games.",
tags=["games"],
responses={200: {"description": "The available game types"}})
async def list_game_types(ctx: HttpContext) -> None:
await send_json(ctx, 200, {
"results": [
{
"id": engine.id,
"name": engine.name,
"description": engine.description,
"min_players": engine.min_players,
"max_players": engine.max_players,
"options_schema": engine.options_schema,
}
for engine in platform.registry.all()
]
})
@app.POST("/api/games")
@operation(summary="Create a game",
description="Creates a lobby game and seats the caller in "
"seat 0. Share the returned join_code with the "
"other players. ``options`` is an opaque object "
"validated by the chosen game (see "
"GET /api/game-types).",
tags=["games"],
request_body={
"required": False,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"game_type": {
"type": "string",
"description": "One of the ids from "
"GET /api/game-types; "
"defaults to the "
"platform's first "
"registered game",
},
"options": {
"type": "object",
"description": "Game-specific creation "
"options",
},
},
}
}
},
},
responses={
201: {"description": "The created lobby"},
400: {"description": "Invalid game_type, options or body"},
401: {"description": "Authentication required"},
})
@require_auth(platform.oidc)
async def create_game(ctx: HttpContext) -> None:
body: dict = {}
try:
body = await read_json_optional(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
game_type: Any = body.get("game_type")
if game_type is None:
engine = platform.registry.default
elif isinstance(game_type, str) and platform.registry.get(game_type) is not None:
engine = platform.registry.require(game_type)
else:
await send_error(ctx, 400, f"unknown game_type: {game_type!r}")
return
options: Any = body.get("options", {})
if not isinstance(options, dict):
await send_error(ctx, 400, "options must be an object")
return
user = platform.oidc.get_user(ctx)
assert user is not None # enforced by @require_auth
session = GameSession(
id=str(uuid.uuid4()),
game_type=engine.id,
join_code=await _unique_code(platform),
creator_sub=user.sub,
players=[Seat(user_sub=user.sub, display_name=display_name(user))],
created_at=datetime.now(timezone.utc),
)
try:
engine.create(session, options)
except GameError as exc:
await send_error(ctx, 400, str(exc))
return
await platform.game_store.save(session)
log.info(
"game %s created by %s (%s, options %r)",
session.id,
user.sub,
engine.id,
options,
)
await send_json(ctx, 201, _lobby_payload(platform, session))
@app.POST("/api/games/join")
@operation(summary="Join a game by code",
description="Seats the caller in the next free chair. Joining "
"as the last player starts the match.",
tags=["games"],
request_body={
"required": True,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {"code": {"type": "string"}},
"required": ["code"],
}
}
},
},
responses={
200: {"description": "Seated; game state (may be playing)"},
400: {"description": "Missing code"},
401: {"description": "Authentication required"},
404: {"description": "Unknown join code"},
409: {"description": "Already joined or lobby full"},
})
@require_auth(platform.oidc)
async def join_game(ctx: HttpContext) -> None:
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
code = body.get("code")
if not isinstance(code, str) or not code:
await send_error(ctx, 400, "code is required")
return
user = platform.oidc.get_user(ctx)
assert user is not None
existing = await platform.game_store.find_by_code(code)
if existing is None:
log.debug("join rejected for %s: unknown code %r", user.sub, code)
await send_error(ctx, 404, "unknown join code")
return
async with platform.game_store.lock(existing.id):
session = await platform.game_store.load(existing.id)
if session is None:
await send_error(ctx, 404, "unknown join code")
return
engine = platform.registry.require(session.game_type)
try:
engine.join(session, user.sub, display_name(user))
except GameError as exc:
log.debug("join rejected for %s in game %s: %s", user.sub, session.id, exc)
await send_error(ctx, 409, str(exc))
return
await platform.game_store.save(session)
await platform.game_store.publish(session.id)
# When the last join started the match, the engine may have
# armed a deadline; queue it so it fires even if nobody ever
# connects.
await platform.scheduler.sync_deadline(session)
seat = next(i for i, s in enumerate(session.players) if s.user_sub == user.sub)
if engine.in_lobby(session):
log.info(
"%s joined game %s (seat %d, %d/%d players)",
user.sub, session.id, seat, len(session.players), engine.max_players,
)
await send_json(ctx, 200, _lobby_payload(platform, session))
return
log.info("%s joined game %s (seat %d); match started", user.sub, session.id, seat)
await send_json(ctx, 200, _view_payload(platform, session, user.sub))
@app.GET("/api/games/${game_id}")
@operation(summary="Get a game snapshot",
description="Only seated players may read a game; the view is "
"personalized by the engine (e.g. hidden hands).",
tags=["games"],
responses={
200: {"description": "The personalized game state"},
401: {"description": "Authentication required"},
403: {"description": "Not a player in this game"},
404: {"description": "Game not found"},
})
@require_auth(platform.oidc)
async def get_game(ctx: HttpContext, game_id: str) -> None:
session = await platform.game_store.load(game_id)
if session is None:
await send_error(ctx, 404, "game not found")
return
user = platform.oidc.get_user(ctx)
assert user is not None
if not session.seated(user.sub):
await send_error(ctx, 403, "forbidden")
return
await send_json(ctx, 200, _view_payload(platform, session, user.sub))
@@ -0,0 +1,23 @@
"""Liveness probe."""
from __future__ import annotations
from typing import TYPE_CHECKING
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
if TYPE_CHECKING:
from ..mixin import Platform
def register(app: KayaApp, platform: "Platform") -> None:
@app.GET("/api/health")
@operation(summary="Health check",
tags=["health"],
responses={200: {"description": "The service is up"}})
async def health(ctx: HttpContext) -> None:
await ctx.send_bytes(
200,
b'{"status":"ok"}',
{"content-type": ("application/json",)},
)
@@ -0,0 +1,30 @@
"""Whoami endpoint: lets the single-page app detect the login state."""
from __future__ import annotations
from typing import TYPE_CHECKING
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from ..auth import display_name, require_auth
from ..http import send_json
if TYPE_CHECKING:
from ..mixin import Platform
def register(app: KayaApp, platform: "Platform") -> None:
@app.GET("/api/me")
@operation(summary="Current user",
description="Returns the authenticated user's identity from the "
"session; 401 when not logged in.",
tags=["auth"],
responses={
200: {"description": "The current user"},
401: {"description": "Not logged in"},
})
@require_auth(platform.oidc)
async def me(ctx: HttpContext) -> None:
user = platform.oidc.get_user(ctx)
assert user is not None # enforced by @require_auth
await send_json(ctx, 200, {"sub": user.sub, "name": display_name(user)})
@@ -0,0 +1,191 @@
"""Player statistics endpoints, served from Postgres.
Every finished match is persisted by
:func:`tavolo.platform.stats.save_match_result`. These endpoints expose a
player's own match history and a global leaderboard aggregated from the
same two tables. The game-specific outcome of each match is exposed
verbatim through the ``result`` JSON column.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from ..auth import require_auth
from ..elo import INITIAL_RATING
from ..http import extract_query_params, send_error, send_json
from ..models import Match, MatchPlayer, PlayerRating
from ..openapi import PAGINATION_PARAMETERS
from ..pagination import CursorDecodeError, paginate, parse_cursor_params
if TYPE_CHECKING:
from ..mixin import Platform
GAME_TYPE_PARAMETER: Dict[str, Any] = {
"name": "game_type",
"in": "query",
"required": False,
"schema": {"type": "string"},
"description": "Only count matches of this game (id from GET /api/game-types).",
}
def _parse_game_type(
platform: "Platform", query_string: str
) -> Tuple[Optional[str], Optional[str]]:
"""Parse the ``game_type`` query parameter.
Returns ``(value, error)``: ``(None, None)`` when absent, ``(id, None)``
when valid, ``(None, message)`` when it names no registered game."""
values = extract_query_params(query_string).get("game_type")
if not values:
return None, None
game_type = values[0]
if platform.registry.get(game_type) is None:
return None, f"unknown game_type: {game_type!r}"
return game_type, None
async def _serialize_match(match: Match, viewer: str) -> Dict[str, Any]:
participants = await MatchPlayer.filter(match_id=match.id).order_by("seat")
return {
"id": str(match.id),
"game_type": match.game_type,
# The engine's MatchResult.summary: for scopone, the teams' final
# scores, the winner, the target score and the per-hand audit.
"result": match.result,
"started_at": match.started_at.isoformat(),
"finished_at": match.finished_at.isoformat(),
"you_won": any(p.user_sub == viewer and p.won for p in participants),
"your_elo_delta": next(
(p.elo_delta for p in participants if p.user_sub == viewer), None
),
"players": [
{
"user_sub": p.user_sub,
"display_name": p.display_name,
"seat": p.seat,
"team": p.team,
"won": p.won,
"score": p.score,
"elo_delta": p.elo_delta,
"details": p.details,
}
for p in participants
],
}
def register(app: KayaApp, platform: "Platform") -> None:
@app.GET("/api/me/matches")
@operation(summary="List my matches",
description="Cursor-paginated history of finished matches the "
"caller played, newest first, with the result.",
tags=["stats"],
parameters=[*PAGINATION_PARAMETERS, GAME_TYPE_PARAMETER],
responses={
200: {"description": "A page of matches"},
400: {"description": "Invalid pagination cursor or game_type"},
401: {"description": "Authentication required"},
})
@require_auth(platform.oidc)
async def my_matches(ctx: HttpContext) -> None:
try:
cursor = parse_cursor_params(ctx.query_string)
except CursorDecodeError as exc:
await send_error(ctx, 400, str(exc))
return
game_type, error = _parse_game_type(platform, ctx.query_string)
if error is not None:
await send_error(ctx, 400, error)
return
user = platform.oidc.get_user(ctx)
assert user is not None
queryset = Match.filter(players__user_sub=user.sub).distinct()
if game_type is not None:
queryset = queryset.filter(game_type=game_type)
matches, next_cursor = await paginate(
queryset, [("finished_at", "DESC"), ("id", "ASC")], cursor
)
results = [await _serialize_match(m, user.sub) for m in matches]
await send_json(ctx, 200, {"results": results, "next_cursor": next_cursor})
@app.GET("/api/leaderboard")
@operation(summary="Global leaderboard",
description="Elo rating, aggregated wins, matches played and "
"points for every player with at least one "
"finished match. Sorted by Elo rating (the rating "
"for the requested game_type, or the default game "
"when the filter is absent).",
tags=["stats"],
parameters=[GAME_TYPE_PARAMETER],
responses={
200: {"description": "The leaderboard"},
400: {"description": "Unknown game_type"},
})
async def leaderboard(ctx: HttpContext) -> None:
game_type, error = _parse_game_type(platform, ctx.query_string)
if error is not None:
await send_error(ctx, 400, error)
return
queryset = MatchPlayer.all()
if game_type is not None:
queryset = queryset.filter(match__game_type=game_type)
rows = await queryset
# Ratings are per game type; without a filter show the default
# game's.
rating_game = game_type or platform.registry.default.id
rating_rows = await PlayerRating.filter(game_type=rating_game)
ratings = {row.user_sub: row.rating for row in rating_rows}
aggregate: Dict[str, Dict[str, Any]] = {}
for row in rows:
entry = aggregate.setdefault(
row.user_sub,
{
"user_sub": row.user_sub,
"display_name": row.display_name,
"matches": 0,
"wins": 0,
"points": 0,
"elo": ratings.get(row.user_sub, INITIAL_RATING),
},
)
entry["matches"] += 1
entry["wins"] += 1 if row.won else 0
entry["points"] += row.score
# Keep the most recent display name seen.
entry["display_name"] = row.display_name
ranking: List[Dict[str, Any]] = sorted(
aggregate.values(),
key=lambda e: (e["elo"], e["wins"], e["points"], -e["matches"]),
reverse=True,
)
await send_json(ctx, 200, {"results": ranking})
@app.GET("/api/me/ratings")
@operation(summary="My Elo ratings",
description="The caller's current Elo rating for every game "
"type they have played.",
tags=["stats"],
responses={
200: {"description": "The caller's ratings"},
401: {"description": "Authentication required"},
})
@require_auth(platform.oidc)
async def my_ratings(ctx: HttpContext) -> None:
user = platform.oidc.get_user(ctx)
assert user is not None
rows = await PlayerRating.filter(user_sub=user.sub).order_by("game_type")
await send_json(ctx, 200, {
"results": [
{
"game_type": row.game_type,
"rating": row.rating,
"matches_played": row.matches_played,
}
for row in rows
]
})
@@ -0,0 +1,110 @@
"""Copy finished match results from the live store into Postgres.
Called once when a session reaches the finished state (guarded by the
``stats_saved`` flag on the session). The write is transactional so a
match never appears with only some of its players. The same transaction
also updates the participants' Elo ratings (see
:mod:`tavolo.platform.elo`).
This module is game-independent: everything it persists comes from the
engine's :class:`~tavolo.platform.engine.MatchResult`.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from logging import getLogger
from typing import Dict, List
from tortoise.transactions import in_transaction
from .elo import match_delta
from .engine import GameEngine, GameSession
log = getLogger(__name__)
async def apply_elo(
game_type: str, teams: List[List[str]], winner_team: int
) -> Dict[str, int]:
"""Update the Elo ratings of ``teams`` for ``game_type``.
``teams`` groups the participants' subs into the two opposing sides;
``winner_team`` is the index of the winning side. Ratings are read
from (and written back to) the ``player_rating`` table; unrated
players start at the initial rating. Returns the per-player delta.
Must be called inside a transaction.
"""
if len(teams) != 2:
raise ValueError("Elo rating requires exactly two teams")
from .models import PlayerRating
ratings: Dict[str, "PlayerRating"] = {}
for subs in teams:
for sub in subs:
rating = await PlayerRating.get_or_none(
user_sub=sub, game_type=game_type
)
if rating is None:
rating = await PlayerRating.create(
id=uuid.uuid4(), user_sub=sub, game_type=game_type
)
ratings[sub] = rating
delta_a = match_delta(
[ratings[sub].rating for sub in teams[0]],
[ratings[sub].rating for sub in teams[1]],
winner_team,
)
deltas: Dict[str, int] = {
**{sub: delta_a for sub in teams[0]},
**{sub: -delta_a for sub in teams[1]},
}
for sub, delta in deltas.items():
rating = ratings[sub]
rating.rating += delta
rating.matches_played += 1
await rating.save()
return deltas
async def save_match_result(session: GameSession, engine: GameEngine) -> None:
"""Persist ``session`` to Postgres if it is finished and not yet saved."""
if session.stats_saved or not engine.is_finished(session):
return
result = engine.result(session)
from .models import Match, MatchPlayer
started_at = session.created_at or datetime.now(timezone.utc)
finished_at = session.finished_at or datetime.now(timezone.utc)
display_names = {seat.user_sub: seat.display_name for seat in session.players}
async with in_transaction():
match = await Match.create(
id=uuid.uuid4(),
game_type=session.game_type,
started_at=started_at,
finished_at=finished_at,
result=result.summary,
)
deltas = await apply_elo(session.game_type, result.teams, result.winner_team)
for player in result.players:
await MatchPlayer.create(
id=uuid.uuid4(),
match=match,
user_sub=player.user_sub,
display_name=display_names.get(player.user_sub, player.user_sub),
seat=player.seat,
team=player.team,
won=player.won,
score=player.score,
elo_delta=deltas[player.user_sub],
details=player.details,
)
session.stats_saved = True
log.info(
"match result persisted: game %s (%s), team %d of %d won",
session.id,
session.game_type,
result.winner_team,
len(result.teams),
)
@@ -1,31 +1,37 @@
"""Persistence for live games. """Persistence for live games.
Game state is small, mutable and short-lived, which makes Redis a natural Game sessions are small, mutable and short-lived, which makes Redis a
fit: the whole match is a single JSON value under ``tavolo:game:<id>`` with natural fit: the whole match is a single JSON value under
a sliding TTL, and a join-code index maps the short code a player shares to ``tavolo:game:<id>`` with a sliding TTL, and a join-code index maps the
that id. Completed matches are copied to Postgres (see short code a player shares to that id. Completed matches are copied to
:mod:`tavolo.models`); Redis keeps serving the finished state until it Postgres (see :mod:`tavolo.platform.models`); Redis keeps serving the
expires. finished session until it expires.
Two implementations satisfy the same interface: Two implementations satisfy the same interface:
* :class:`RedisGameStore` production, used when ``REDIS_URL`` is set. * :class:`RedisGameStore` production, used when ``REDIS_URL`` is set.
* :class:`InMemoryGameStore` tests and ephemeral dev, used otherwise. * :class:`InMemoryGameStore` tests and ephemeral dev, used otherwise.
Concurrency is handled with a per-game lock so two simultaneous plays The store is game-agnostic: a session is serialized as a platform-owned
envelope (id, join code, seats, timestamps) plus an opaque ``state``
blob produced by the game's engine (resolved through the
:class:`~tavolo.platform.registry.GameRegistry` both stores are
constructed with).
Concurrency is handled with a per-game lock so two simultaneous actions
cannot interleave. State changes are broadcast on a per-game pub/sub cannot interleave. State changes are broadcast on a per-game pub/sub
channel as a simple "something changed" signal; every open websocket channel as a simple "something changed" signal; every open websocket
reloads the state and renders the personalized view. Publishing only a reloads the session and renders the personalized view. Publishing only a
signal (never the state) means updated state reaches connections on every signal (never the state) means updated state reaches connections on
worker without leaking hidden hands into the channel. every worker without leaking hidden information into the channel.
Timeouts (turn auto-play, hand-end auto-continue) are driven by a shared Timeouts are driven by a shared delayed-deadline queue: producers
delayed-deadline queue: producers enqueue an opaque ``member`` string with enqueue an opaque ``member`` string with a due timestamp, and a consumer
a due timestamp, and a consumer on every worker polls for due entries. on every worker polls for due entries (see
Delivery is at-least-once entries are removed only after they are :mod:`tavolo.platform.deadlines`). Delivery is at-least-once entries
processed so a worker dying mid-processing cannot lose a deadline; are removed only after they are processed so a worker dying
consumers revalidate entries against the live state under the per-game mid-processing cannot lose a deadline; engines revalidate entries
lock, which makes duplicate deliveries harmless. against the live state, which makes duplicate deliveries harmless.
""" """
from __future__ import annotations from __future__ import annotations
@@ -33,12 +39,14 @@ import asyncio
import contextlib import contextlib
import json import json
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from datetime import datetime
from logging import getLogger from logging import getLogger
from typing import AsyncContextManager, AsyncIterator, Dict, List, Optional, Set, cast from typing import Any, AsyncContextManager, AsyncIterator, Dict, List, Mapping, Optional, Set, cast
from redis.asyncio import Redis from redis.asyncio import Redis
from .game.state import GameState from .engine import GameSession, Seat
from .registry import GameRegistry
log = getLogger(__name__) log = getLogger(__name__)
@@ -51,20 +59,66 @@ DEADLINES_KEY = "tavolo:deadlines"
_BUMP = b"update" _BUMP = b"update"
def _dt_to_json(value: Optional[datetime]) -> Optional[str]:
return value.isoformat() if value is not None else None
def _dt_from_json(value: Any) -> Optional[datetime]:
if not value:
return None
try:
return datetime.fromisoformat(str(value))
except ValueError:
return None
def session_to_json(session: GameSession, registry: GameRegistry) -> Dict[str, Any]:
"""Serialize a session: platform envelope plus the engine's state blob."""
engine = registry.require(session.game_type)
return {
"id": session.id,
"game_type": session.game_type,
"join_code": session.join_code,
"creator_sub": session.creator_sub,
"players": [seat.to_json() for seat in session.players],
"created_at": _dt_to_json(session.created_at),
"finished_at": _dt_to_json(session.finished_at),
"stats_saved": session.stats_saved,
"state": engine.state_to_json(session.state),
}
def session_from_json(data: Mapping[str, Any], registry: GameRegistry) -> GameSession:
"""Rebuild a session, delegating the state blob to its game engine."""
game_type = str(data["game_type"])
engine = registry.require(game_type)
return GameSession(
id=str(data["id"]),
game_type=game_type,
join_code=str(data["join_code"]),
creator_sub=str(data.get("creator_sub", "")),
players=[Seat.from_json(p) for p in data.get("players", [])],
created_at=_dt_from_json(data.get("created_at")),
finished_at=_dt_from_json(data.get("finished_at")),
stats_saved=bool(data.get("stats_saved", False)),
state=engine.state_from_json(data.get("state") or {}),
)
class GameStore(ABC): class GameStore(ABC):
"""Abstract persistence + notification layer for live games.""" """Abstract persistence + notification layer for live games."""
@abstractmethod @abstractmethod
async def load(self, game_id: str) -> Optional[GameState]: async def load(self, game_id: str) -> Optional[GameSession]:
"""Return the live state for ``game_id`` or ``None``.""" """Return the live session for ``game_id`` or ``None``."""
@abstractmethod @abstractmethod
async def save(self, state: GameState) -> None: async def save(self, session: GameSession) -> None:
"""Persist ``state``, refreshing its TTL and code index.""" """Persist ``session``, refreshing its TTL and code index."""
@abstractmethod @abstractmethod
async def find_by_code(self, code: str) -> Optional[GameState]: async def find_by_code(self, code: str) -> Optional[GameSession]:
"""Return the live state for a join ``code`` or ``None``.""" """Return the live session for a join ``code`` or ``None``."""
@abstractmethod @abstractmethod
def lock(self, game_id: str) -> AsyncContextManager[None]: def lock(self, game_id: str) -> AsyncContextManager[None]:
@@ -76,7 +130,7 @@ class GameStore(ABC):
@abstractmethod @abstractmethod
async def publish(self, game_id: str) -> None: async def publish(self, game_id: str) -> None:
"""Signal that the state of ``game_id`` changed.""" """Signal that the session of ``game_id`` changed."""
@abstractmethod @abstractmethod
async def add_deadline(self, member: str, due_at: float) -> None: async def add_deadline(self, member: str, due_at: float) -> None:
@@ -104,8 +158,9 @@ def _channel(game_id: str) -> str:
class RedisGameStore(GameStore): class RedisGameStore(GameStore):
def __init__(self, redis: Redis, ttl_seconds: int = 86400) -> None: def __init__(self, redis: Redis, registry: GameRegistry, ttl_seconds: int = 86400) -> None:
self._redis = redis self._redis = redis
self._registry = registry
self._ttl = ttl_seconds self._ttl = ttl_seconds
def lock(self, game_id: str): def lock(self, game_id: str):
@@ -114,8 +169,7 @@ class RedisGameStore(GameStore):
return self._redis.lock(f"{GAME_KEY_PREFIX}{game_id}:lock", return self._redis.lock(f"{GAME_KEY_PREFIX}{game_id}:lock",
timeout=10, blocking_timeout=10) timeout=10, blocking_timeout=10)
async def load(self, game_id: str) -> Optional[GameState]: async def load(self, game_id: str) -> Optional[GameSession]:
raw = await self._redis.get(f"{GAME_KEY_PREFIX}{game_id}") raw = await self._redis.get(f"{GAME_KEY_PREFIX}{game_id}")
if raw is None: if raw is None:
log.debug("redis load %s: miss", game_id) log.debug("redis load %s: miss", game_id)
@@ -123,18 +177,17 @@ class RedisGameStore(GameStore):
if isinstance(raw, bytes): if isinstance(raw, bytes):
raw = raw.decode("utf-8") raw = raw.decode("utf-8")
log.debug("redis load %s: hit", game_id) log.debug("redis load %s: hit", game_id)
return GameState.from_json(json.loads(raw)) return session_from_json(json.loads(raw), self._registry)
async def save(self, state: GameState) -> None: async def save(self, session: GameSession) -> None:
payload = json.dumps(session_to_json(session, self._registry))
payload = json.dumps(state.to_json())
async with self._redis.pipeline(transaction=True) as pipe: async with self._redis.pipeline(transaction=True) as pipe:
pipe.set(f"{GAME_KEY_PREFIX}{state.id}", payload, ex=self._ttl) pipe.set(f"{GAME_KEY_PREFIX}{session.id}", payload, ex=self._ttl)
pipe.set(f"{CODE_KEY_PREFIX}{state.join_code}", state.id, ex=self._ttl) pipe.set(f"{CODE_KEY_PREFIX}{session.join_code}", session.id, ex=self._ttl)
await pipe.execute() await pipe.execute()
log.debug("redis save %s (phase %s, ttl %ds)", state.id, state.phase, self._ttl) log.debug("redis save %s (game %s, ttl %ds)", session.id, session.game_type, self._ttl)
async def find_by_code(self, code: str) -> Optional[GameState]: async def find_by_code(self, code: str) -> Optional[GameSession]:
game_id = await self._redis.get(f"{CODE_KEY_PREFIX}{code.upper()}") game_id = await self._redis.get(f"{CODE_KEY_PREFIX}{code.upper()}")
if game_id is None: if game_id is None:
return None return None
@@ -186,8 +239,9 @@ async def _redis_events(pubsub) -> AsyncIterator[None]:
class InMemoryGameStore(GameStore): class InMemoryGameStore(GameStore):
"""Process-local store used by tests and when Redis is not configured.""" """Process-local store used by tests and when Redis is not configured."""
def __init__(self) -> None: def __init__(self, registry: GameRegistry) -> None:
self._games: Dict[str, GameState] = {} self._registry = registry
self._games: Dict[str, str] = {}
self._codes: Dict[str, str] = {} self._codes: Dict[str, str] = {}
self._locks: Dict[str, asyncio.Lock] = {} self._locks: Dict[str, asyncio.Lock] = {}
self._subscribers: Dict[str, Set[asyncio.Queue]] = {} self._subscribers: Dict[str, Set[asyncio.Queue]] = {}
@@ -205,15 +259,17 @@ class InMemoryGameStore(GameStore):
async with self._lock_for(game_id): async with self._lock_for(game_id):
yield yield
async def load(self, game_id: str) -> Optional[GameState]: async def load(self, game_id: str) -> Optional[GameSession]:
state = self._games.get(game_id) raw = self._games.get(game_id)
return GameState.from_json(state.to_json()) if state else None if raw is None:
return None
return session_from_json(json.loads(raw), self._registry)
async def save(self, state: GameState) -> None: async def save(self, session: GameSession) -> None:
self._games[state.id] = GameState.from_json(state.to_json()) self._games[session.id] = json.dumps(session_to_json(session, self._registry))
self._codes[state.join_code] = state.id self._codes[session.join_code] = session.id
async def find_by_code(self, code: str) -> Optional[GameState]: async def find_by_code(self, code: str) -> Optional[GameSession]:
game_id = self._codes.get(code.upper()) game_id = self._codes.get(code.upper())
if game_id is None: if game_id is None:
return None return None
@@ -83,6 +83,22 @@ class TortoiseMixin(KayaMixin):
self._ctx = None self._ctx = None
self._init_loop = None self._init_loop = None
async def aclose(self) -> None:
"""Close the current context's connections and forget it.
Must be called from the event loop that owns the context. When a
loop goes away without this, its aiosqlite connections are orphaned;
their non-daemon worker threads then block interpreter shutdown
forever. The test suite calls this at the end of every test because
each test runs in a fresh event loop.
"""
ctx = self._ctx
self._ctx = None
self._init_loop = None
if ctx is not None:
log.info("closing database connections")
await ctx.close_connections()
async def _build_context(self) -> TortoiseContext: async def _build_context(self) -> TortoiseContext:
ctx = TortoiseContext() ctx = TortoiseContext()
with ctx: with ctx:
@@ -0,0 +1,210 @@
"""WebSocket endpoint for live play.
Clients connect to ``/ws/games/{game_id}`` using their session cookie
(the OIDC login stores the user in the session, which the session mixin
loads onto the websocket). Only seated players are accepted.
The platform owns the connection lifecycle — authentication, seat
check, the subscription/publish fan-out and the message envelope — and
delegates the game-specific actions to the engine registered for the
session's ``game_type``.
Protocol
--------
Server -> client messages are JSON objects with a ``type``:
* ``state`` — the personalized game view (the engine's
:meth:`~tavolo.platform.engine.GameEngine.view_for` output merged with
the session envelope: ``id``, ``join_code``, ``game_type``).
* ``game_over`` — sent once when the match ends, carrying the engine's
:meth:`~tavolo.platform.engine.GameEngine.game_over_view` fields.
* ``error`` — a rejected action or malformed message.
Client -> server messages are JSON objects::
{"action": "state"} # platform: resend the view
{"action": "<game action>", ...fields} # dispatched to the engine
For scopone scientifico the game actions are ``play`` (with ``card``
and optionally ``capture``) and ``ack`` — see
:mod:`tavolo.scopone.plugin`. Because the whole message (minus
``action``) is handed to the engine as the payload, games define their
own fields freely.
Mutations run under the per-game lock; after a successful action the new
session is saved and a change signal is published. Every connected
websocket is subscribed to that signal and re-renders the state, so all
players see the move immediately (and consistently across workers).
Timeouts do not depend on anyone being connected: they are driven by
the absolute deadlines the engine declares, via the shared deadline
queue drained by a consumer on every worker (see
:mod:`tavolo.platform.deadlines`). A disconnected or idle player
therefore cannot stall the match, and a worker dying cannot either.
"""
from __future__ import annotations
import asyncio
import json
from contextlib import suppress
from logging import getLogger
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict
from kaya.core import KayaApp, WebSocket
from . import auth
from .engine import GameSession
from .errors import GameError
if TYPE_CHECKING: # avoid the import cycle: mixin imports this module
from .mixin import Platform
log = getLogger(__name__)
Send = Callable[[Dict[str, Any]], Awaitable[None]]
def _error(message: str, code: str = "invalid") -> Dict[str, Any]:
return {"type": "error", "code": code, "message": message}
def _state_message(platform: "Platform", session: GameSession, sub: str) -> Dict[str, Any]:
engine = platform.registry.require(session.game_type)
return {
"type": "state",
"game": {
"id": session.id,
"join_code": session.join_code,
"game_type": session.game_type,
**engine.view_for(session, sub),
},
}
def register(app: KayaApp, platform: "Platform") -> None:
"""Register the live-play websocket endpoint on ``app``."""
@app.websocket("/ws/games/${game_id}")
async def game_socket(ws: WebSocket, game_id: str) -> None:
user = auth.get_ws_user(ws)
if user is None:
log.debug("websocket %s rejected: no authenticated user", game_id)
await ws.close(4401)
return
session = await platform.game_store.load(game_id)
if session is None:
log.debug("websocket rejected: unknown game %s", game_id)
await ws.close(4404)
return
if not session.seated(user.sub):
log.debug("websocket %s rejected: %s is not seated", game_id, user.sub)
await ws.close(4403)
return
await ws.accept()
log.info("%s connected to game %s", user.sub, game_id)
send_lock = asyncio.Lock()
async def send(payload: Dict[str, Any]) -> None:
async with send_lock:
await ws.send_text(json.dumps(payload))
await send(_state_message(platform, session, user.sub))
# Backstop: make sure the current deadline is queued even if its
# entry was lost (e.g. the queue was flushed while the game lived
# on thanks to its sliding TTL).
await platform.scheduler.sync_deadline(session)
async with platform.game_store.subscribe(game_id) as events:
forward = asyncio.create_task(
_forward(platform, events, game_id, user.sub, send)
)
try:
async for message in ws:
if message.kind == "close":
break
if message.kind != "text" or not isinstance(message.data, str):
await send(_error("expected a text frame with a JSON object"))
continue
await _handle_message(platform, send, game_id, user.sub, message.data)
finally:
forward.cancel()
with suppress(asyncio.CancelledError):
await forward
log.debug("%s disconnected from game %s", user.sub, game_id)
async def _forward(
platform: "Platform",
events,
game_id: str,
sub: str,
send: Send,
) -> None:
async for _ in events:
session = await platform.game_store.load(game_id)
if session is None:
return
engine = platform.registry.require(session.game_type)
await send(_state_message(platform, session, sub))
if engine.is_finished(session):
await send(
{
"type": "game_over",
**engine.game_over_view(session, sub),
}
)
return
async def _handle_message(
platform: "Platform", send: Send, game_id: str, sub: str, raw: str
) -> None:
try:
data = json.loads(raw)
except (ValueError, TypeError):
log.debug("game %s: malformed message from %s (not JSON)", game_id, sub)
await send(_error("invalid JSON"))
return
if not isinstance(data, dict):
log.debug("game %s: malformed message from %s (not an object)", game_id, sub)
await send(_error("message must be a JSON object"))
return
action = data.get("action")
if action in ("state", "sync"):
session = await platform.game_store.load(game_id)
if session is not None:
await send(_state_message(platform, session, sub))
return
if not isinstance(action, str):
await send(_error(f"unknown action: {action!r}"))
return
await _handle_action(platform, send, game_id, sub, action, data)
async def _handle_action(
platform: "Platform",
send: Send,
game_id: str,
sub: str,
action: str,
data: Dict[str, Any],
) -> None:
async with platform.game_store.lock(game_id):
session = await platform.game_store.load(game_id)
if session is None:
await send(_error("game not found", code="not_found"))
return
engine = platform.registry.require(session.game_type)
payload = {key: value for key, value in data.items() if key != "action"}
try:
engine.handle_action(session, sub, action, payload)
except GameError as exc:
log.debug("game %s: rejected %r by %s: %s", game_id, action, sub, exc)
await send(_error(str(exc), code=exc.code))
return
log.debug("game %s: %s performed %r", game_id, sub, action)
await platform.scheduler.finalize_mutation(session)
@@ -0,0 +1,348 @@
"""Shared fixtures for the tavolo-platform test suite.
The centerpiece is :class:`DummyEngine`: a tiny two-player game
implementing the platform's
:class:`~tavolo.platform.engine.GameEngine` contract, so every platform
behaviour (lobby, store, websockets, deadlines, stats) is exercised
without importing any real game. Its rules: the first player to reach
``target`` plays wins the match.
:func:`make_platform` builds a throwaway :class:`~kaya.core.KayaApp`
wired with in-memory stores, a dummy OIDC mixin (patched per test) and
a sqlite :class:`~tavolo.platform.tortoise_mixin.TortoiseMixin`, so
tests construct their own app instead of importing a global one.
"""
from __future__ import annotations
import asyncio
import contextlib
import unittest.mock as _mock
from datetime import datetime, timedelta, timezone
from functools import wraps
from typing import Any, Callable, Coroutine, Dict, Iterator, List, Mapping, Optional, Sequence, Tuple
from kaya.core import KayaApp
from kaya.oidc import OIDCConfig, OIDCMixin, OIDCUser
from kaya.openapi import OpenAPIMixin
from kaya.session import InMemorySessionStore, SessionMixin
from tavolo.platform import (
AlreadyJoined,
Deadline,
GameEngine,
GameError,
GameFinished,
GameNotStarted,
GameSession,
IllegalMove,
LobbyFull,
MatchResult,
NotYourTurn,
Platform,
PlatformMixin,
PlayerResult,
Seat,
)
from tavolo.platform.auth import get_ws_user # noqa: F401 (re-exported for patching)
from tavolo.platform.deadlines import DeadlineScheduler
from tavolo.platform.registry import GameRegistry
from tavolo.platform.store import InMemoryGameStore
from tavolo.platform.tortoise_mixin import TortoiseMixin
class DummyEngine(GameEngine):
"""A two-player toy game: first to ``target`` plays wins.
Team labels are "A"/"B" (one player per team) so Elo paths are
exercised too. A ``deadline_in_seconds`` creation option arms a
``tick`` deadline that plays for the first player when it fires.
"""
id = "dummy"
name = "Dummy game"
description = "A two-player toy game for testing the platform."
min_players = 2
max_players = 2
options_schema = {
"type": "object",
"properties": {
"target": {
"type": "integer",
"minimum": 1,
"default": 3,
"description": "Plays needed to win the match.",
},
"deadline_in_seconds": {
"type": "number",
"description": "Arm a tick deadline this far in the future.",
},
},
}
def create(self, session: GameSession, options: Mapping[str, Any]) -> None:
target = options.get("target", 3)
if isinstance(target, bool) or not isinstance(target, int) or target < 1:
raise IllegalMove("target must be a positive integer")
# The creator takes team A.
creator = session.players[0]
session.players[0] = Seat(
user_sub=creator.user_sub,
display_name=creator.display_name,
team="A",
)
session.state = {
"target": target,
"plays": [],
"started": False,
"finished": False,
"winner": None,
"deadline_in_seconds": options.get("deadline_in_seconds"),
}
def join(self, session: GameSession, user_sub: str, display_name: str) -> None:
state = session.state
if state["started"]:
raise GameNotStarted("game has already started")
if session.seated(user_sub):
raise AlreadyJoined("already joined this game")
if len(session.players) >= 2:
raise LobbyFull("game is full")
session.players.append(
Seat(
user_sub=user_sub,
display_name=display_name,
team="A" if not session.players else "B",
)
)
if len(session.players) == 2:
state["started"] = True
def handle_action(
self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any]
) -> None:
state = session.state
if not state["started"]:
raise GameNotStarted("the game has not started yet")
if state["finished"]:
raise GameFinished("the match is over")
if not session.seated(user_sub):
raise NotYourTurn("you are not seated in this game")
if action != "play":
raise IllegalMove(f"unknown action: {action!r}")
self._play(session, user_sub)
def _play(self, session: GameSession, user_sub: str) -> None:
state = session.state
state["plays"].append(user_sub)
if len(state["plays"]) >= state["target"]:
state["finished"] = True
state["winner"] = user_sub
def view_for(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
state = session.state
return {
"started": state["started"],
"finished": state["finished"],
"winner": state["winner"],
"plays": len(state["plays"]),
"target": state["target"],
"viewer_seated": session.seated(user_sub),
}
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
return {
"phase": "playing" if session.state["started"] else "lobby",
"target": session.state["target"],
}
def in_lobby(self, session: GameSession) -> bool:
return not session.state["started"]
def is_finished(self, session: GameSession) -> bool:
return bool(session.state["finished"])
def result(self, session: GameSession) -> MatchResult:
state = session.state
winner = state["winner"]
if winner is None:
raise GameError("no result: the match is not finished")
subs = [seat.user_sub for seat in session.players]
plays: List[str] = state["plays"]
return MatchResult(
teams=[[subs[0]], [subs[1]]],
winner_team=subs.index(winner),
players=[
PlayerResult(
user_sub=seat.user_sub,
seat=index,
won=seat.user_sub == winner,
team=seat.team,
score=float(plays.count(seat.user_sub)),
details={"plays": plays.count(seat.user_sub)},
)
for index, seat in enumerate(session.players)
],
summary={
"target": state["target"],
"plays": len(plays),
"winner": winner,
},
)
def state_to_json(self, state: Any) -> Dict[str, Any]:
return dict(state)
def state_from_json(self, data: Mapping[str, Any]) -> Any:
return dict(data)
def next_deadline(self, session: GameSession) -> Optional[Deadline]:
seconds = session.state.get("deadline_in_seconds")
if (
seconds is None
or not session.state["started"]
or session.state["finished"]
):
return None
due_at = datetime.now(timezone.utc) + timedelta(seconds=float(seconds))
return Deadline(
kind="tick",
due_at=due_at,
token=f"tick:{len(session.state['plays'])}",
)
def fire_deadline(self, session: GameSession, kind: str, token: str) -> None:
current = self.next_deadline(session)
if current is None or current.kind != kind or current.token != token:
raise GameError("stale deadline")
# The house plays for the first player.
self._play(session, session.players[0].user_sub)
def make_platform(
engines: Sequence[GameEngine] = (DummyEngine(),),
) -> Tuple[KayaApp, Platform, TortoiseMixin]:
"""Build a throwaway app + platform wired with in-memory stores."""
registry = GameRegistry(engines)
game_store = InMemoryGameStore(registry)
session_mixin = SessionMixin(InMemorySessionStore())
oidc_mixin = OIDCMixin(
OIDCConfig(
issuer="http://localhost:8180/tavolo",
client_id="tavolo",
client_secret=None,
redirect_uri="http://localhost:8080/auth/callback",
post_login_redirect="/",
post_logout_redirect="/",
),
session=session_mixin,
)
tortoise_mixin = TortoiseMixin(
database_url="sqlite://:memory:",
models_modules=["tavolo.platform.models"],
skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}),
)
openapi_mixin = OpenAPIMixin(
title="tavolo-platform-tests",
version="0.1.0",
description="test app",
spec_path="/api/openapi.json",
docs_path="/api/docs",
)
scheduler = DeadlineScheduler(game_store, registry, heartbeat_ms=50)
platform = Platform(
registry=registry,
game_store=game_store,
scheduler=scheduler,
oidc=oidc_mixin,
)
app = KayaApp(
mixins=[
session_mixin,
oidc_mixin,
tortoise_mixin,
openapi_mixin,
PlatformMixin(platform),
]
)
_tortoise_mixins.append(tortoise_mixin)
_schedulers.append(scheduler)
return app, platform, tortoise_mixin
_tortoise_mixins: List[TortoiseMixin] = []
_schedulers: List[DeadlineScheduler] = []
def async_test(coro: Callable[..., Coroutine[Any, Any, None]]) -> Callable[..., None]:
"""Like ``pwo.async_test`` (fresh loop per test), but tear down the
platform pieces afterwards: close Tortoise contexts (otherwise
orphaned aiosqlite threads block interpreter shutdown) and stop
deadline consumers."""
@wraps(coro)
def wrapper(*args: Any, **kwargs: Any) -> None:
async def run() -> None:
loop = asyncio.get_running_loop()
try:
await coro(*args, **kwargs)
finally:
for scheduler in _schedulers:
scheduler.stop_consumer(loop)
_schedulers.clear()
for mixin in _tortoise_mixins:
await mixin.aclose()
_tortoise_mixins.clear()
with asyncio.Runner() as runner:
runner.run(run())
return wrapper
async def use_db(tortoise_mixin: TortoiseMixin):
"""Bind the app's Tortoise context for this loop, for seeding rows."""
from tortoise.context import TortoiseContext
await tortoise_mixin._bind()
ctx: Optional[TortoiseContext] = tortoise_mixin._ctx
assert ctx is not None
return ctx
def make_user(sub: str, name: Optional[str] = None) -> OIDCUser:
return OIDCUser({"sub": sub, "preferred_username": name or sub})
@contextlib.contextmanager
def oidc_user(oidc: OIDCMixin, sub: str, name: Optional[str] = None) -> Iterator[OIDCUser]:
"""Context manager: patch ``oidc.get_user`` to return this user."""
user = make_user(sub, name)
patcher = _mock.patch.object(oidc, "get_user", return_value=user)
patcher.start()
try:
yield user
finally:
patcher.stop()
@contextlib.contextmanager
def ws_users(users: Sequence[OIDCUser]) -> Iterator[None]:
"""Context manager: patch ``auth.get_ws_user`` to hand out ``users``
one per websocket connection, in order. Once exhausted it keeps
returning the last user."""
from tavolo.platform import auth
remaining = list(users)
last = remaining[-1] if remaining else None
def _next(_ws):
if remaining:
return remaining.pop(0)
return last
patcher = _mock.patch.object(auth, "get_ws_user", side_effect=_next)
patcher.start()
try:
yield
finally:
patcher.stop()
@@ -0,0 +1,187 @@
"""Deadline-scheduler tests, driven by the DummyEngine.
Timeouts must be driven by the persisted deadlines and the shared queue,
not by connected sockets: these tests seed sessions, enqueue their
deadlines and let the background consumer fire them without a single
websocket. The engine owns the meaning of each deadline; the scheduler
owns enqueueing, delivery and removal.
"""
from __future__ import annotations
import asyncio
import unittest
from typing import Any, Dict, Optional
from tavolo.platform import GameSession, Seat
from tavolo.platform.deadlines import encode
from helpers import DummyEngine, async_test, make_platform, use_db
def _started_session(
game_id: str = "dl-1",
code: str = "DL0001",
target: int = 3,
deadline_in_seconds: Optional[float] = None,
) -> GameSession:
engine = DummyEngine()
session = GameSession(
id=game_id,
game_type=engine.id,
join_code=code,
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="alice", team="A")],
)
options: Dict[str, Any] = {"target": target}
if deadline_in_seconds is not None:
options["deadline_in_seconds"] = deadline_in_seconds
engine.create(session, options)
engine.join(session, "bob", "bob")
return session
async def _wait_for(predicate, timeout: float = 5.0):
"""Poll the store until ``predicate`` returns a truthy value."""
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
result = await predicate()
if result:
return result
await asyncio.sleep(0.05)
return None
class ConnectionIndependenceTest(unittest.TestCase):
@async_test
async def test_tick_fires_with_no_connections(self) -> None:
_, platform, _ = make_platform()
store = platform.game_store
scheduler = platform.scheduler
session = _started_session(deadline_in_seconds=0.05)
await store.save(session)
await scheduler.sync_deadline(session)
# Nobody ever connects: the consumer must still fire the tick,
# which plays for the first player.
result = await _wait_for(
lambda: _plays_is(store, session.id, 1),
)
self.assertIsNotNone(result, "deadline never fired")
@async_test
async def test_no_deadline_nothing_enqueued(self) -> None:
_, platform, _ = make_platform()
session = _started_session() # no deadline_in_seconds option
await platform.game_store.save(session)
await platform.scheduler.sync_deadline(session)
self.assertIsNone(await platform.game_store.next_deadline())
async def _plays_is(store, game_id: str, count: int) -> Optional[GameSession]:
session = await store.load(game_id)
if session is not None and len(session.state["plays"]) == count:
return session
return None
class ProcessDueTest(unittest.TestCase):
"""Direct ``process_due`` behaviour: engine revalidation and idempotency."""
@async_test
async def test_processing_twice_is_a_no_op(self) -> None:
# Simulates a worker dying after firing but before removing the
# entry: another worker re-delivers the same entry. The engine's
# token has moved on, so the second delivery is stale.
_, platform, _ = make_platform()
store = platform.game_store
scheduler = platform.scheduler
session = _started_session(deadline_in_seconds=3600)
await store.save(session)
deadline = DummyEngine().next_deadline(session)
assert deadline is not None
member = encode({
"game_id": session.id,
"kind": deadline.kind,
"token": deadline.token,
})
await scheduler.process_due(member)
await scheduler.process_due(member)
result = await store.load(session.id)
assert result is not None
# Fired exactly once: one play, not two.
self.assertEqual(["alice"], result.state["plays"])
@async_test
async def test_stale_entry_is_discarded(self) -> None:
# A tick enqueued before a play landed in time: the token has
# moved, so the entry must not fire.
_, platform, _ = make_platform()
scheduler = platform.scheduler
store = platform.game_store
session = _started_session(deadline_in_seconds=3600)
session.state["plays"].append("alice") # a play landed in time
await store.save(session)
member = encode({
"game_id": session.id,
"kind": "tick",
"token": "tick:0", # not the live token ("tick:1")
})
await store.add_deadline(member, due_at=0.0)
await scheduler.process_due(member)
result = await store.load(session.id)
assert result is not None
self.assertEqual(["alice"], result.state["plays"])
# The entry was removed after processing.
self.assertNotIn(member, await store.due_deadlines(float("inf")))
@async_test
async def test_entry_for_expired_game_is_dropped(self) -> None:
_, platform, _ = make_platform()
scheduler = platform.scheduler
store = platform.game_store
member = encode({
"game_id": "dl-gone",
"kind": "tick",
"token": "tick:0",
})
await store.add_deadline(member, due_at=0.0)
await scheduler.process_due(member)
self.assertNotIn(member, await store.due_deadlines(float("inf")))
@async_test
async def test_malformed_entry_is_dropped(self) -> None:
_, platform, _ = make_platform()
scheduler = platform.scheduler
store = platform.game_store
await store.add_deadline("not json", due_at=0.0)
await scheduler.process_due("not json")
self.assertNotIn("not json", await store.due_deadlines(float("inf")))
@async_test
async def test_finished_match_is_persisted_on_tick(self) -> None:
# A tick that completes the match writes the result to Postgres.
from tavolo.platform.models import Match
_, platform, tortoise_mixin = make_platform()
ctx = await use_db(tortoise_mixin)
scheduler = platform.scheduler
store = platform.game_store
session = _started_session(target=1, deadline_in_seconds=3600)
await store.save(session)
deadline = DummyEngine().next_deadline(session)
assert deadline is not None
member = encode({
"game_id": session.id,
"kind": deadline.kind,
"token": deadline.token,
})
with ctx:
await scheduler.process_due(member)
self.assertEqual(1, await Match.all().count())
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,73 @@
"""Unit tests for the chess-style Elo math in :mod:`tavolo.platform.elo`."""
from __future__ import annotations
import unittest
from tavolo.platform.elo import (
INITIAL_RATING,
K_FACTOR,
expected_score,
match_delta,
team_rating,
)
class ExpectedScoreTest(unittest.TestCase):
def test_equal_ratings_give_even_odds(self) -> None:
self.assertAlmostEqual(0.5, expected_score(1500, 1500))
def test_higher_rating_is_favoured(self) -> None:
self.assertGreater(expected_score(1700, 1500), 0.5)
self.assertLess(expected_score(1500, 1700), 0.5)
def test_scores_sum_to_one(self) -> None:
self.assertAlmostEqual(
1.0, expected_score(1600, 1400) + expected_score(1400, 1600)
)
def test_four_hundred_points_is_ten_to_one(self) -> None:
self.assertAlmostEqual(10 / 11, expected_score(1900, 1500))
class TeamRatingTest(unittest.TestCase):
def test_mean_of_members(self) -> None:
self.assertEqual(1600, team_rating([1500, 1700]))
def test_empty_team_rejected(self) -> None:
with self.assertRaises(ValueError):
team_rating([])
class MatchDeltaTest(unittest.TestCase):
def test_equal_teams_exchange_half_k(self) -> None:
delta = match_delta([1500, 1500], [1500, 1500], winner_team=0)
self.assertEqual(K_FACTOR // 2, delta)
def test_favourite_gains_less_than_underdog(self) -> None:
favourite = match_delta([1700, 1700], [1500, 1500], winner_team=0)
underdog = match_delta([1500, 1500], [1700, 1700], winner_team=0)
self.assertGreater(underdog, favourite)
self.assertGreater(favourite, 0)
def test_losing_side_loses_the_winners_gain(self) -> None:
# Zero-sum: the losers' delta is the negation of the winners'.
win = match_delta([1600, 1500], [1400, 1500], winner_team=0)
loss = match_delta([1600, 1500], [1400, 1500], winner_team=1)
self.assertEqual(-win, -abs(win)) # winner gains
# Losing the same pairing costs K * E, winning gains K * (1 - E);
# both are computed from the same expectation, so loss = win - K.
self.assertEqual(win - K_FACTOR, loss)
def test_team_average_decides_not_individual_ratings(self) -> None:
# [1700, 1300] averages 1500, same as [1500, 1500].
mixed = match_delta([1700, 1300], [1500, 1500], winner_team=0)
even = match_delta([1500, 1500], [1500, 1500], winner_team=0)
self.assertEqual(even, mixed)
def test_initial_rating_constant(self) -> None:
self.assertEqual(1500, INITIAL_RATING)
self.assertEqual(32, K_FACTOR)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,204 @@
"""Game lobby route tests via kaya's ASGI transport, on the DummyEngine."""
from __future__ import annotations
import unittest
from httpx import ASGITransport, AsyncClient
from helpers import async_test, make_platform, oidc_user
class GamesRouteTest(unittest.TestCase):
@async_test
async def test_create_requires_auth(self) -> None:
app, _, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.post("/api/games", json={})
self.assertEqual(401, response.status_code)
self.assertEqual({"error": "unauthenticated"}, response.json())
@async_test
async def test_create_and_read_lobby(self) -> None:
app, platform, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
created = await client.post(
"/api/games", json={"options": {"target": 5}}
)
self.assertEqual(201, created.status_code)
body = created.json()
self.assertEqual("lobby", body["phase"])
self.assertEqual(1, body["seats_open"])
self.assertEqual(5, body["target"])
self.assertEqual("dummy", body["game_type"])
self.assertEqual("A", body["players"][0]["team"])
self.assertEqual(6, len(body["join_code"]))
game_id = body["id"]
with oidc_user(platform.oidc, "alice"):
snapshot = await client.get(f"/api/games/{game_id}")
self.assertEqual(200, snapshot.status_code)
snap = snapshot.json()
self.assertEqual(game_id, snap["id"])
self.assertEqual("dummy", snap["game_type"])
self.assertTrue(snap["viewer_seated"])
with oidc_user(platform.oidc, "mallory"):
forbidden = await client.get(f"/api/games/{game_id}")
self.assertEqual(403, forbidden.status_code)
@async_test
async def test_join_starts_game(self) -> None:
app, platform, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
created = await client.post("/api/games", json={})
code = created.json()["join_code"]
with oidc_user(platform.oidc, "bob"):
started = await client.post("/api/games/join", json={"code": code})
self.assertEqual(200, started.status_code)
state = started.json()
# The second join started the match, so the response is the
# personalized view rather than the lobby payload.
self.assertTrue(state["started"])
self.assertFalse(state["finished"])
self.assertEqual(0, state["plays"])
self.assertTrue(state["viewer_seated"])
@async_test
async def test_create_rejects_bad_options(self) -> None:
app, platform, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
zero = await client.post(
"/api/games", json={"options": {"target": 0}}
)
text = await client.post(
"/api/games", json={"options": {"target": "three"}}
)
non_object = await client.post(
"/api/games", json={"options": [1, 2]}
)
self.assertEqual(400, zero.status_code)
self.assertEqual(400, text.status_code)
self.assertEqual(400, non_object.status_code)
@async_test
async def test_join_errors(self) -> None:
app, platform, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
created = await client.post("/api/games", json={})
code = created.json()["join_code"]
with oidc_user(platform.oidc, "bob"):
unknown = await client.post("/api/games/join", json={"code": "ZZZZZZ"})
self.assertEqual(404, unknown.status_code)
with oidc_user(platform.oidc, "alice"):
duplicate = await client.post("/api/games/join", json={"code": code})
self.assertEqual(409, duplicate.status_code)
with oidc_user(platform.oidc, "bob"):
missing = await client.post("/api/games/join", json={})
self.assertEqual(400, missing.status_code)
with oidc_user(platform.oidc, "bob"):
await client.post("/api/games/join", json={"code": code})
with oidc_user(platform.oidc, "erin"):
late = await client.post("/api/games/join", json={"code": code})
self.assertEqual(409, late.status_code)
@async_test
async def test_get_unknown_game(self) -> None:
app, platform, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
response = await client.get("/api/games/does-not-exist")
self.assertEqual(404, response.status_code)
class GameTypesRouteTest(unittest.TestCase):
@async_test
async def test_lists_available_game_types(self) -> None:
app, _, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/game-types")
self.assertEqual(200, response.status_code)
results = response.json()["results"]
self.assertEqual(["dummy"], [g["id"] for g in results])
self.assertEqual("Dummy game", results[0]["name"])
self.assertTrue(results[0]["description"])
self.assertEqual(2, results[0]["min_players"])
self.assertEqual(2, results[0]["max_players"])
self.assertIn("target", results[0]["options_schema"]["properties"])
@async_test
async def test_create_defaults_game_type(self) -> None:
app, platform, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
created = await client.post("/api/games", json={})
self.assertEqual(201, created.status_code)
self.assertEqual("dummy", created.json()["game_type"])
@async_test
async def test_create_with_explicit_game_type(self) -> None:
app, platform, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
created = await client.post(
"/api/games", json={"game_type": "dummy"}
)
self.assertEqual(201, created.status_code)
body = created.json()
self.assertEqual("dummy", body["game_type"])
with oidc_user(platform.oidc, "alice"):
snapshot = await client.get(f"/api/games/{body['id']}")
self.assertEqual("dummy", snapshot.json()["game_type"])
@async_test
async def test_create_rejects_unknown_game_type(self) -> None:
app, platform, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
unknown = await client.post("/api/games", json={"game_type": "briscola"})
non_string = await client.post("/api/games", json={"game_type": 42})
self.assertEqual(400, unknown.status_code)
self.assertEqual(400, non_string.status_code)
class MeRouteTest(unittest.TestCase):
@async_test
async def test_me_authenticated(self) -> None:
app, platform, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
response = await client.get("/api/me")
self.assertEqual(200, response.status_code)
self.assertEqual({"sub": "alice", "name": "alice"}, response.json())
@async_test
async def test_me_unauthenticated(self) -> None:
app, _, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/me")
self.assertEqual(401, response.status_code)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,392 @@
"""Match-statistics tests: Postgres persistence and the stats endpoints."""
from __future__ import annotations
import unittest
import uuid
from datetime import datetime, timezone
from httpx import ASGITransport, AsyncClient
from tavolo.platform.elo import INITIAL_RATING
from tavolo.platform.models import Match, MatchPlayer, PlayerRating
from tavolo.platform.stats import save_match_result
from helpers import DummyEngine, async_test, make_platform, oidc_user, use_db
def _finished_session(target: int = 2):
"""A started dummy session one play short of completion."""
from tavolo.platform import GameSession, Seat
engine = DummyEngine()
session = GameSession(
id="stats-game",
game_type=engine.id,
join_code="STATS1",
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="alice", team="A")],
)
engine.create(session, {"target": target})
engine.join(session, "bob", "bob")
engine.handle_action(session, "alice", "play", {})
return engine, session
class SaveMatchResultTest(unittest.TestCase):
@async_test
async def test_finished_match_is_persisted_once(self) -> None:
_, _, tortoise_mixin = make_platform()
ctx = await use_db(tortoise_mixin)
engine, session = _finished_session()
engine.handle_action(session, "alice", "play", {})
self.assertTrue(engine.is_finished(session))
with ctx:
await save_match_result(session, engine)
await save_match_result(session, engine) # idempotent
self.assertEqual(1, await Match.all().count())
self.assertEqual(2, await MatchPlayer.all().count())
match = await Match.all().first()
assert match is not None
# The game type travels from the session onto the row; the
# engine's summary is stored verbatim as the result.
self.assertEqual("dummy", match.game_type)
self.assertEqual("alice", match.result["winner"])
self.assertEqual(2, match.result["plays"])
winners = await MatchPlayer.filter(won=True)
self.assertEqual({"alice"}, {p.user_sub for p in winners})
scores = {p.user_sub: p.score for p in await MatchPlayer.all()}
self.assertEqual({"alice": 2.0, "bob": 0.0}, scores)
@async_test
async def test_finished_match_updates_elo_ratings(self) -> None:
_, _, tortoise_mixin = make_platform()
ctx = await use_db(tortoise_mixin)
engine, session = _finished_session()
engine.handle_action(session, "alice", "play", {})
with ctx:
await save_match_result(session, engine)
ratings = {
row.user_sub: row for row in await PlayerRating.all()
}
self.assertEqual(2, len(ratings))
# Two players at 1500: winner gains K/2, loser loses it.
self.assertEqual(INITIAL_RATING + 16, ratings["alice"].rating)
self.assertEqual(1, ratings["alice"].matches_played)
self.assertEqual(INITIAL_RATING - 16, ratings["bob"].rating)
self.assertEqual(1, ratings["bob"].matches_played)
# The per-match delta is recorded on each participation row.
deltas = {
p.user_sub: p.elo_delta for p in await MatchPlayer.all()
}
self.assertEqual({"alice": 16, "bob": -16}, deltas)
@async_test
async def test_elo_ratings_accumulate_across_matches(self) -> None:
_, _, tortoise_mixin = make_platform()
ctx = await use_db(tortoise_mixin)
engine, session = _finished_session()
engine.handle_action(session, "alice", "play", {})
from tavolo.platform import GameSession, Seat
engine2, session2 = DummyEngine(), GameSession(
id="stats-game-2",
game_type="dummy",
join_code="STATS2",
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="alice", team="A")],
)
engine2.create(session2, {"target": 2})
engine2.join(session2, "bob", "bob")
# Bob wins the second match.
engine2.handle_action(session2, "bob", "play", {})
engine2.handle_action(session2, "bob", "play", {})
with ctx:
await save_match_result(session, engine)
await save_match_result(session2, engine2)
ratings = {
row.user_sub: row.rating for row in await PlayerRating.all()
}
# Match 1: even teams, alice wins (+16/-16). Match 2: alice
# is now the favourite (1516 vs 1484), so losing costs 17.
self.assertEqual(INITIAL_RATING - 1, ratings["alice"])
self.assertEqual(INITIAL_RATING + 1, ratings["bob"])
bob = await PlayerRating.get(user_sub="bob")
self.assertEqual(2, bob.matches_played)
@async_test
async def test_unfinished_match_is_not_persisted(self) -> None:
_, _, tortoise_mixin = make_platform()
ctx = await use_db(tortoise_mixin)
engine, session = _finished_session()
with ctx:
await save_match_result(session, engine)
self.assertEqual(0, await Match.all().count())
async def _seed_two_matches(
tortoise_mixin, game_types: tuple = ("dummy", "dummy")
) -> None:
ctx = await use_db(tortoise_mixin)
with ctx:
for index, (winner, finished) in enumerate(
[
("alice", datetime(2026, 1, 1, 10, tzinfo=timezone.utc)),
("bob", datetime(2026, 1, 2, 10, tzinfo=timezone.utc)),
]
):
match = await Match.create(
id=uuid.uuid4(),
game_type=game_types[index],
started_at=datetime(2025, 12, 31, tzinfo=timezone.utc),
finished_at=finished,
result={"winner": winner, "plays": 3 + index},
)
for seat, sub in enumerate(("alice", "bob")):
await MatchPlayer.create(
id=uuid.uuid4(),
match=match,
user_sub=sub,
display_name=sub,
seat=seat,
team="A" if seat == 0 else "B",
won=(sub == winner),
score=2.0 + index if sub == winner else 1.0,
)
class StatsRouteTest(unittest.TestCase):
@async_test
async def test_my_matches_newest_first(self) -> None:
app, platform, tortoise_mixin = make_platform()
await _seed_two_matches(tortoise_mixin)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
response = await client.get("/api/me/matches")
self.assertEqual(200, response.status_code)
results = response.json()["results"]
self.assertEqual(2, len(results))
self.assertEqual("bob", results[0]["result"]["winner"]) # newest first
self.assertFalse(results[0]["you_won"])
self.assertTrue(results[1]["you_won"])
self.assertEqual(2, len(results[0]["players"]))
self.assertIn("next_cursor", response.json())
@async_test
async def test_my_matches_pagination(self) -> None:
app, platform, tortoise_mixin = make_platform()
await _seed_two_matches(tortoise_mixin)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
first = await client.get("/api/me/matches?limit=1")
cursor = first.json()["next_cursor"]
self.assertIsNotNone(cursor)
second = await client.get(f"/api/me/matches?limit=1&cursor={cursor}")
self.assertEqual(1, len(first.json()["results"]))
self.assertEqual(1, len(second.json()["results"]))
self.assertNotEqual(
first.json()["results"][0]["id"],
second.json()["results"][0]["id"],
)
@async_test
async def test_my_matches_requires_auth(self) -> None:
app, _, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/me/matches")
self.assertEqual(401, response.status_code)
@async_test
async def test_leaderboard_aggregates(self) -> None:
app, _, tortoise_mixin = make_platform()
await _seed_two_matches(tortoise_mixin)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/leaderboard")
self.assertEqual(200, response.status_code)
by_sub = {row["user_sub"]: row for row in response.json()["results"]}
self.assertEqual(2, by_sub["alice"]["matches"])
self.assertEqual(1, by_sub["alice"]["wins"]) # alice won match 1
self.assertEqual(3.0, by_sub["alice"]["points"]) # 2.0 + 1.0
self.assertEqual(1, by_sub["bob"]["wins"]) # bob won match 2
self.assertEqual(4.0, by_sub["bob"]["points"]) # 1.0 + 3.0
# Bob leads on points after tying Alice on wins.
self.assertEqual("bob", response.json()["results"][0]["user_sub"])
@async_test
async def test_leaderboard_includes_elo_and_sorts_by_it(self) -> None:
app, _, tortoise_mixin = make_platform()
await _seed_two_matches(tortoise_mixin)
ctx = await use_db(tortoise_mixin)
with ctx:
# Alice outranks everyone despite Bob leading on points.
await PlayerRating.create(
id=uuid.uuid4(),
user_sub="alice",
game_type="dummy",
rating=1600,
matches_played=2,
)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/leaderboard")
self.assertEqual(200, response.status_code)
results = response.json()["results"]
by_sub = {row["user_sub"]: row for row in results}
self.assertEqual(1600, by_sub["alice"]["elo"])
# Players without a rating row report the initial rating.
self.assertEqual(INITIAL_RATING, by_sub["bob"]["elo"])
# Elo outranks wins/points.
self.assertEqual("alice", results[0]["user_sub"])
@async_test
async def test_leaderboard_elo_scoped_by_game_type(self) -> None:
app, platform, tortoise_mixin = make_platform(
engines=(DummyEngine(), SecondEngine())
)
await _seed_two_matches(tortoise_mixin)
ctx = await use_db(tortoise_mixin)
with ctx:
await PlayerRating.create(
id=uuid.uuid4(),
user_sub="bob",
game_type="dummy",
rating=1516,
matches_played=1,
)
# Bob's rating in another game must not leak into the
# dummy leaderboard.
await PlayerRating.create(
id=uuid.uuid4(),
user_sub="bob",
game_type="second",
rating=1800,
matches_played=1,
)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/leaderboard?game_type=dummy")
self.assertEqual(200, response.status_code)
results = response.json()["results"]
by_sub = {row["user_sub"]: row for row in results}
self.assertEqual(1516, by_sub["bob"]["elo"])
self.assertEqual(INITIAL_RATING, by_sub["alice"]["elo"])
self.assertEqual("second", platform.registry.all()[1].id)
@async_test
async def test_my_matches_include_elo_delta(self) -> None:
app, platform, tortoise_mixin = make_platform()
ctx = await use_db(tortoise_mixin)
engine, session = _finished_session()
engine.handle_action(session, "alice", "play", {})
with ctx:
await save_match_result(session, engine)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
response = await client.get("/api/me/matches")
self.assertEqual(200, response.status_code)
players = {
p["user_sub"]: p
for p in response.json()["results"][0]["players"]
}
self.assertEqual(16, players["alice"]["elo_delta"])
self.assertEqual(-16, players["bob"]["elo_delta"])
self.assertEqual(16, response.json()["results"][0]["your_elo_delta"])
@async_test
async def test_my_ratings_requires_auth(self) -> None:
app, _, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/me/ratings")
self.assertEqual(401, response.status_code)
@async_test
async def test_my_ratings_returns_only_own_rows(self) -> None:
app, platform, tortoise_mixin = make_platform()
ctx = await use_db(tortoise_mixin)
with ctx:
await PlayerRating.create(
id=uuid.uuid4(),
user_sub="alice",
game_type="dummy",
rating=1516,
matches_played=1,
)
await PlayerRating.create(
id=uuid.uuid4(),
user_sub="bob",
game_type="dummy",
rating=1484,
matches_played=1,
)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
response = await client.get("/api/me/ratings")
self.assertEqual(200, response.status_code)
self.assertEqual(
[{"game_type": "dummy", "rating": 1516, "matches_played": 1}],
response.json()["results"],
)
class SecondEngine(DummyEngine):
id = "second"
name = "Second game"
class GameTypeFilterTest(unittest.TestCase):
"""Stats endpoints scope results by the match's game type."""
@async_test
async def test_my_matches_filter_by_game_type(self) -> None:
# The second seed names a game the registry does not know; rows are
# written directly, so this only exercises the SQL filter.
app, platform, tortoise_mixin = make_platform()
await _seed_two_matches(tortoise_mixin, game_types=("dummy", "other_game"))
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
all_matches = await client.get("/api/me/matches")
scoped = await client.get("/api/me/matches?game_type=dummy")
unknown = await client.get("/api/me/matches?game_type=briscola")
self.assertEqual(2, len(all_matches.json()["results"]))
self.assertEqual(
{"dummy", "other_game"},
{m["game_type"] for m in all_matches.json()["results"]},
)
scoped_results = scoped.json()["results"]
self.assertEqual(1, len(scoped_results))
self.assertEqual("dummy", scoped_results[0]["game_type"])
self.assertEqual(400, unknown.status_code)
@async_test
async def test_leaderboard_filter_by_game_type(self) -> None:
app, platform, tortoise_mixin = make_platform()
await _seed_two_matches(tortoise_mixin, game_types=("dummy", "other_game"))
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
scoped = await client.get("/api/leaderboard?game_type=dummy")
unknown = await client.get("/api/leaderboard?game_type=briscola")
self.assertEqual(200, scoped.status_code)
by_sub = {row["user_sub"]: row for row in scoped.json()["results"]}
# Only the first match counts: one match per player, alice won.
self.assertEqual(1, by_sub["alice"]["matches"])
self.assertEqual(1, by_sub["alice"]["wins"])
self.assertEqual(0, by_sub["bob"]["wins"])
self.assertEqual(400, unknown.status_code)
if __name__ == "__main__":
unittest.main()
@@ -4,83 +4,89 @@ from __future__ import annotations
import asyncio import asyncio
import unittest import unittest
from pwo import async_test from tavolo.platform import GameSession, Seat
from tavolo.platform.registry import GameRegistry
from tavolo.platform.store import InMemoryGameStore
from helpers import DummyEngine, async_test
from tavolo.game import engine
from tavolo.store import InMemoryGameStore def _registry() -> GameRegistry:
return GameRegistry([DummyEngine()])
def _session(game_id: str = "g1", code: str = "CODE01") -> GameSession:
engine = DummyEngine()
session = GameSession(
id=game_id,
game_type=engine.id,
join_code=code,
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="alice", team="A")],
)
engine.create(session, {"target": 5})
return session
class InMemoryGameStoreTest(unittest.TestCase): class InMemoryGameStoreTest(unittest.TestCase):
@async_test @async_test
async def test_save_load_roundtrip(self) -> None: async def test_save_load_roundtrip(self) -> None:
store = InMemoryGameStore() store = InMemoryGameStore(_registry())
state = engine.create_game("g1", "CODE01", "alice", "alice", target_score=16) session = _session()
engine.join_game(state, "bob", "bob") await store.save(session)
await store.save(state)
loaded = await store.load("g1") loaded = await store.load("g1")
self.assertIsNotNone(loaded) self.assertIsNotNone(loaded)
assert loaded is not None assert loaded is not None
self.assertEqual("CODE01", loaded.join_code) self.assertEqual("CODE01", loaded.join_code)
self.assertEqual(16, loaded.target_score) self.assertEqual("dummy", loaded.game_type)
self.assertEqual(["alice", "bob"], [p.sub for p in loaded.players]) self.assertEqual(5, loaded.state["target"])
self.assertEqual(["alice"], [p.user_sub for p in loaded.players])
# The loaded state is a deserialized copy, not the same object.
self.assertIsNot(loaded.state, session.state)
@async_test @async_test
async def test_game_type_roundtrip_and_default(self) -> None: async def test_unknown_game_type_rejected(self) -> None:
store = InMemoryGameStore() store = InMemoryGameStore(_registry())
state = engine.create_game( session = _session()
"g1b", "CODE1B", "alice", "alice", game_type="scopone_scientifico" session.game_type = "nope"
) with self.assertRaises(Exception):
await store.save(state) await store.save(session)
loaded = await store.load("g1b")
assert loaded is not None
self.assertEqual("scopone_scientifico", loaded.game_type)
# States serialized before game types existed load with the default.
legacy = state.to_json()
del legacy["game_type"]
from tavolo.game.state import GameState
self.assertEqual("scopone_scientifico", GameState.from_json(legacy).game_type)
@async_test @async_test
async def test_load_missing_returns_none(self) -> None: async def test_load_missing_returns_none(self) -> None:
store = InMemoryGameStore() store = InMemoryGameStore(_registry())
self.assertIsNone(await store.load("nope")) self.assertIsNone(await store.load("nope"))
self.assertIsNone(await store.find_by_code("NOPE01")) self.assertIsNone(await store.find_by_code("NOPE01"))
@async_test @async_test
async def test_find_by_code(self) -> None: async def test_find_by_code(self) -> None:
store = InMemoryGameStore() store = InMemoryGameStore(_registry())
state = engine.create_game("g2", "CODE02", "alice", "alice") await store.save(_session())
await store.save(state) found = await store.find_by_code("code01") # case-insensitive
found = await store.find_by_code("code02") # case-insensitive
self.assertIsNotNone(found) self.assertIsNotNone(found)
assert found is not None assert found is not None
self.assertEqual("g2", found.id) self.assertEqual("g1", found.id)
@async_test @async_test
async def test_load_returns_a_copy(self) -> None: async def test_load_returns_a_copy(self) -> None:
store = InMemoryGameStore() store = InMemoryGameStore(_registry())
state = engine.create_game("g3", "CODE03", "alice", "alice") await store.save(_session())
await store.save(state) first = await store.load("g1")
first = await store.load("g3")
assert first is not None assert first is not None
first.phase = "tampered" first.state["target"] = 999
second = await store.load("g3") second = await store.load("g1")
assert second is not None assert second is not None
self.assertEqual("lobby", second.phase) self.assertEqual(5, second.state["target"])
@async_test @async_test
async def test_publish_reaches_subscriber(self) -> None: async def test_publish_reaches_subscriber(self) -> None:
store = InMemoryGameStore() store = InMemoryGameStore(_registry())
state = engine.create_game("g4", "CODE04", "alice", "alice") await store.save(_session())
await store.save(state)
received = [] received = []
async with store.subscribe("g4") as events: async with store.subscribe("g1") as events:
await store.publish("g4") await store.publish("g1")
async for _ in events: async for _ in events:
received.append(True) received.append(True)
break break
@@ -89,7 +95,7 @@ class InMemoryGameStoreTest(unittest.TestCase):
@async_test @async_test
async def test_lock_serializes_concurrent_mutations(self) -> None: async def test_lock_serializes_concurrent_mutations(self) -> None:
store = InMemoryGameStore() store = InMemoryGameStore(_registry())
order = [] order = []
async def holder() -> None: async def holder() -> None:
@@ -110,7 +116,7 @@ class InMemoryGameStoreTest(unittest.TestCase):
@async_test @async_test
async def test_deadline_queue(self) -> None: async def test_deadline_queue(self) -> None:
store = InMemoryGameStore() store = InMemoryGameStore(_registry())
self.assertIsNone(await store.next_deadline()) self.assertIsNone(await store.next_deadline())
self.assertEqual([], await store.due_deadlines(now=100.0)) self.assertEqual([], await store.due_deadlines(now=100.0))
@@ -0,0 +1,158 @@
"""WebSocket live-play tests via kaya's ASGI websocket transport."""
from __future__ import annotations
import unittest
from httpx import ASGITransport, AsyncClient
from httpx_ws import WebSocketDisconnect, aconnect_ws
from httpx_ws.transport import ASGIWebSocketTransport
from helpers import async_test, make_platform, make_user, oidc_user, ws_users
class WebSocketTest(unittest.TestCase):
async def _started_game(self, client: AsyncClient, oidc, target: int = 3) -> dict:
"""Create a game and seat both players; return the started state."""
with oidc_user(oidc, "alice"):
created = await client.post(
"/api/games", json={"options": {"target": target}}
)
code = created.json()["join_code"]
with oidc_user(oidc, "bob"):
response = await client.post("/api/games/join", json={"code": code})
return response.json()
@async_test
async def test_move_updates_all_connections(self) -> None:
app, platform, _ = make_platform()
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await self._started_game(client, platform.oidc)
game_id = state["id"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("bob"), make_user("alice")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
first = await bob_ws.receive_json()
self.assertEqual("state", first["type"])
self.assertEqual("dummy", first["game"]["game_type"])
self.assertEqual(0, first["game"]["plays"])
self.assertEqual(game_id, first["game"]["id"])
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
alice_first = await alice_ws.receive_json()
self.assertEqual("state", alice_first["type"])
await bob_ws.send_json({"action": "play"})
bob_update = await bob_ws.receive_json()
alice_update = await alice_ws.receive_json()
for update in (bob_update, alice_update):
self.assertEqual("state", update["type"])
self.assertEqual(1, update["game"]["plays"])
@async_test
async def test_unknown_action_returns_error(self) -> None:
app, platform, _ = make_platform()
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await self._started_game(client, platform.oidc)
game_id = state["id"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("bob")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
await bob_ws.receive_json()
await bob_ws.send_json({"action": "dance"})
error = await bob_ws.receive_json()
self.assertEqual("error", error["type"])
self.assertEqual("illegal_move", error["code"])
@async_test
async def test_state_action_resyncs(self) -> None:
app, platform, _ = make_platform()
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await self._started_game(client, platform.oidc)
game_id = state["id"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("bob")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
await bob_ws.receive_json()
await bob_ws.send_json({"action": "state"})
resent = await bob_ws.receive_json()
self.assertEqual("state", resent["type"])
@async_test
async def test_game_over_broadcast(self) -> None:
app, platform, _ = make_platform()
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
# target 1: the first play ends the match.
state = await self._started_game(client, platform.oidc, target=1)
game_id = state["id"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("alice"), make_user("bob")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
await alice_ws.receive_json()
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
await bob_ws.receive_json()
await bob_ws.send_json({"action": "play"})
# Both connections see the final state...
alice_final = await alice_ws.receive_json()
bob_final = await bob_ws.receive_json()
self.assertTrue(alice_final["game"]["finished"])
self.assertTrue(bob_final["game"]["finished"])
# ...followed by the game_over announcement.
alice_over = await alice_ws.receive_json()
bob_over = await bob_ws.receive_json()
self.assertEqual("game_over", alice_over["type"])
self.assertEqual("game_over", bob_over["type"])
@async_test
async def test_unknown_game_is_closed(self) -> None:
app, _, _ = make_platform()
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("alice")]):
with self.assertRaises(WebSocketDisconnect) as caught:
async with aconnect_ws("/ws/games/no-such-game", ws_client):
pass
self.assertEqual(4404, caught.exception.code)
@async_test
async def test_non_player_is_closed(self) -> None:
app, platform, _ = make_platform()
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await self._started_game(client, platform.oidc)
game_id = state["id"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("mallory")]):
with self.assertRaises(WebSocketDisconnect) as caught:
async with aconnect_ws(f"/ws/games/{game_id}", ws_client):
pass
self.assertEqual(4403, caught.exception.code)
@async_test
async def test_unauthenticated_is_closed(self) -> None:
app, _, _ = make_platform()
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([]):
with self.assertRaises(WebSocketDisconnect) as caught:
async with aconnect_ws("/ws/games/whatever", ws_client):
pass
self.assertEqual(4401, caught.exception.code)
if __name__ == "__main__":
unittest.main()
+36
View File
@@ -0,0 +1,36 @@
# tavolo-scopone
Scopone scientifico — the four-player, fixed-partnership Italian card
game — as a [`tavolo-platform`](../tavolo-platform/README.md) game
implementation.
## Contents
- `state.py``ScoponeState` (pure game data: phases, players, hands,
table, scores, deadlines), `PlayerState`, `Card`, `Move`, with JSON
(de)serialization. No session envelope, no transport, no I/O.
- `engine.py` — the pure rules engine: deck, legal captures, plays,
auto-play, hand scoring (carte, denara, settebello, primiera, scope,
napola), hand-end acknowledgements. Deterministic and I/O-free apart
from logging, so the whole rule set is unit-testable.
- `errors.py` — scopone-specific errors (`CardNotInHand`); every other
failure mode is a shared `tavolo.platform.errors` subclass.
- `plugin.py``ScoponeEngine(GameEngine)`: the platform-facing adapter.
It translates create/join/websocket actions/deadlines into rules-engine
calls and back, validates the `target_score`/`napola` creation options,
and extracts the `MatchResult` (teams, winner, per-player scores and the
match summary persisted as the match's JSON `result`).
Timeouts are constructor arguments (`turn_timeout_seconds`,
`hand_ack_timeout_seconds`), wired from the environment by the
application composition root.
## Development (from `server/`)
```sh
.venv/bin/python -m unittest discover -s packages/tavolo-scopone/tests
.venv/bin/python -m mypy -p tavolo.scopone
```
`test_engine.py` covers the pure rules; `test_plugin.py` covers the
platform contract (actions, deadlines, serialization, results).
@@ -0,0 +1,22 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "tavolo-scopone"
version = "0.1.0"
description = "Scopone scientifico game implementation for the tavolo platform"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"tavolo-platform",
]
[tool.setuptools.packages.find]
where = ["src"]
namespaces = true
[tool.mypy]
python_version = "3.12"
ignore_missing_imports = true
plugins = []
@@ -0,0 +1,13 @@
"""Scopone scientifico: tavolo's first game implementation.
The pure rules (:mod:`tavolo.scopone.engine`, :mod:`tavolo.scopone.state`)
know nothing about HTTP, Redis or Postgres; :mod:`tavolo.scopone.plugin`
adapts them to the platform's
:class:`~tavolo.platform.engine.GameEngine` contract so the
game-independent platform can host them.
"""
from __future__ import annotations
from .plugin import ScoponeEngine
__all__ = ["ScoponeEngine"]
@@ -2,9 +2,11 @@
Every function here is deterministic and I/O-free (the only side effect is Every function here is deterministic and I/O-free (the only side effect is
debug logging): it mutates (or reads) debug logging): it mutates (or reads)
:class:`~tavolo.game.state.GameState` and raises :class:`~tavolo.scopone.state.ScoponeState` and raises
:class:`~tavolo.game.errors.GameError` subclasses on rule violations. This :class:`~tavolo.platform.errors.GameError` subclasses on rule violations.
makes the whole rule set unit-testable without Redis, Postgres or HTTP. This makes the whole rule set unit-testable without Redis, Postgres or
HTTP. The platform-facing adapter is
:class:`~tavolo.scopone.plugin.ScoponeEngine`.
Rules implemented Rules implemented
----------------- -----------------
@@ -37,15 +39,17 @@ from itertools import combinations
from logging import getLogger from logging import getLogger
from typing import Dict, List, Optional, Sequence, Tuple from typing import Dict, List, Optional, Sequence, Tuple
from .errors import ( from tavolo.platform.errors import (
AlreadyJoined, AlreadyJoined,
CardNotInHand,
GameFinished, GameFinished,
GameNotStarted, GameNotStarted,
IllegalMove, IllegalMove,
LobbyFull, LobbyFull,
NotYourTurn, NotYourTurn,
) )
from .errors import CardNotInHand
from .state import ( from .state import (
DEFAULT_TARGET_SCORE, DEFAULT_TARGET_SCORE,
PHASE_FINISHED, PHASE_FINISHED,
@@ -55,9 +59,9 @@ from .state import (
SUITS, SUITS,
TEAM_NAMES, TEAM_NAMES,
Card, Card,
GameState,
Move, Move,
PlayerState, PlayerState,
ScoponeState,
parse_card, parse_card,
) )
@@ -68,13 +72,12 @@ HAND_SIZE = 10
PLAYERS = 4 PLAYERS = 4
# Default seconds the hand-end summary waits before dealing anyway. Games # Default seconds the hand-end summary waits before dealing anyway. Games
# carry their own copy in ``GameState.hand_ack_timeout`` (configurable via # carry their own copy in ``ScoponeState.hand_ack_timeout``.
# the HAND_ACK_TIMEOUT_SECONDS environment variable).
DEFAULT_HAND_ACK_TIMEOUT_SECONDS = 30 DEFAULT_HAND_ACK_TIMEOUT_SECONDS = 30
# Default seconds a player has to play before the server plays a random # Default seconds a player has to play before the server plays a random
# legal card for them. Games carry their own copy in # legal card for them. Games carry their own copy in
# ``GameState.turn_timeout`` (configurable via TURN_TIMEOUT_SECONDS). # ``ScoponeState.turn_timeout``.
DEFAULT_TURN_TIMEOUT_SECONDS = 30 DEFAULT_TURN_TIMEOUT_SECONDS = 30
# Primiera card values: sevens are best, then sixes, then aces, then the # Primiera card values: sevens are best, then sixes, then aces, then the
@@ -131,35 +134,27 @@ def legal_captures(table: Sequence[Card], card: Card) -> List[List[Card]]:
def create_game( def create_game(
game_id: str,
join_code: str,
creator_sub: str, creator_sub: str,
creator_name: str, creator_name: str,
target_score: int = DEFAULT_TARGET_SCORE, target_score: int = DEFAULT_TARGET_SCORE,
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS, hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS, turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
game_type: str = "scopone_scientifico",
napola: bool = True, napola: bool = True,
) -> GameState: ) -> ScoponeState:
"""Create a lobby game with the creator seated first.""" """Create a lobby game with the creator seated first."""
if target_score < 1 or target_score > 100: if target_score < 1 or target_score > 100:
raise IllegalMove("target_score must be between 1 and 100") raise IllegalMove("target_score must be between 1 and 100")
return GameState( return ScoponeState(
id=game_id,
join_code=join_code,
creator_sub=creator_sub,
game_type=game_type,
target_score=target_score, target_score=target_score,
napola=napola, napola=napola,
phase=PHASE_LOBBY, phase=PHASE_LOBBY,
players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)], players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)],
hand_ack_timeout=hand_ack_timeout, hand_ack_timeout=hand_ack_timeout,
turn_timeout=turn_timeout, turn_timeout=turn_timeout,
created_at=datetime.now(timezone.utc).isoformat(),
) )
def join_game(state: GameState, sub: str, name: str) -> None: def join_game(state: ScoponeState, sub: str, name: str) -> None:
"""Seat ``sub`` in the next free chair, starting the match when full.""" """Seat ``sub`` in the next free chair, starting the match when full."""
if state.phase != PHASE_LOBBY: if state.phase != PHASE_LOBBY:
raise GameNotStarted("game has already started") raise GameNotStarted("game has already started")
@@ -173,7 +168,7 @@ def join_game(state: GameState, sub: str, name: str) -> None:
start_game(state) start_game(state)
def start_game(state: GameState) -> None: def start_game(state: ScoponeState) -> None:
"""Deal the first hand and switch the game to playing.""" """Deal the first hand and switch the game to playing."""
if len(state.players) != PLAYERS: if len(state.players) != PLAYERS:
raise GameNotStarted("need exactly four players to start") raise GameNotStarted("need exactly four players to start")
@@ -181,13 +176,13 @@ def start_game(state: GameState) -> None:
_deal_hand(state) _deal_hand(state)
def _set_turn_deadline(state: GameState) -> None: def _set_turn_deadline(state: ScoponeState) -> None:
"""Arm the auto-play deadline for whoever is on turn.""" """Arm the auto-play deadline for whoever is on turn."""
deadline = datetime.now(timezone.utc) + timedelta(seconds=state.turn_timeout) deadline = datetime.now(timezone.utc) + timedelta(seconds=state.turn_timeout)
state.turn_deadline = deadline.isoformat() state.turn_deadline = deadline.isoformat()
def _deal_hand(state: GameState) -> None: def _deal_hand(state: ScoponeState) -> None:
deck = shuffled_deck() deck = shuffled_deck()
for player in state.players: for player in state.players:
player.hand = [] player.hand = []
@@ -203,10 +198,10 @@ def _deal_hand(state: GameState) -> None:
for seat in range(PLAYERS): for seat in range(PLAYERS):
player = _player_at(state, (state.dealer + 1 + seat) % PLAYERS) player = _player_at(state, (state.dealer + 1 + seat) % PLAYERS)
player.hand.append(deck.pop()) player.hand.append(deck.pop())
log.debug("game %s: hand %d dealt (dealer seat %d)", state.id, state.hand_number, state.dealer) log.debug("hand %d dealt (dealer seat %d)", state.hand_number, state.dealer)
def _player_at(state: GameState, seat: int) -> PlayerState: def _player_at(state: ScoponeState, seat: int) -> PlayerState:
for player in state.players: for player in state.players:
if player.seat == seat: if player.seat == seat:
return player return player
@@ -214,7 +209,7 @@ def _player_at(state: GameState, seat: int) -> PlayerState:
def play( def play(
state: GameState, state: ScoponeState,
sub: str, sub: str,
card_code: str, card_code: str,
capture_codes: Optional[Sequence[str]] = None, capture_codes: Optional[Sequence[str]] = None,
@@ -223,8 +218,8 @@ def play(
``capture_codes`` selects which table cards to capture; it must be a ``capture_codes`` selects which table cards to capture; it must be a
legal capture (see :func:`legal_captures`) when one exists and empty legal capture (see :func:`legal_captures`) when one exists and empty
otherwise. Raises a :class:`~tavolo.game.errors.GameError` subclass on otherwise. Raises a :class:`~tavolo.platform.errors.GameError` subclass
any violation. on any violation.
""" """
if state.phase == PHASE_FINISHED: if state.phase == PHASE_FINISHED:
raise GameFinished("the match is over") raise GameFinished("the match is over")
@@ -297,7 +292,7 @@ def _match_option(
return None return None
def auto_play(state: GameState, rng: Optional[random.Random] = None) -> None: def auto_play(state: ScoponeState, rng: Optional[random.Random] = None) -> None:
"""Play a random legal move for the player currently on turn. """Play a random legal move for the player currently on turn.
A card is drawn at random from that player's hand; if it can capture, A card is drawn at random from that player's hand; if it can capture,
@@ -323,14 +318,14 @@ def auto_play(state: GameState, rng: Optional[random.Random] = None) -> None:
) )
def _end_hand(state: GameState) -> None: def _end_hand(state: ScoponeState) -> None:
"""Sweep the table and score the hand. """Sweep the table and score the hand.
If the match continues, the game pauses in the ``hand_end`` phase so If the match continues, the game pauses in the ``hand_end`` phase so
every player can read the scoring summary; the next hand is dealt by every player can read the scoring summary; the next hand is dealt by
:func:`acknowledge_hand` once all four players have acknowledged (or by :func:`acknowledge_hand` once all four players have acknowledged (or
the hand-end timeout in the websocket layer). If the match is over the by the hand-end timeout fired by the platform's deadline scheduler).
game goes to ``finished`` immediately. If the match is over the game goes to ``finished`` immediately.
""" """
state.turn_deadline = None state.turn_deadline = None
if state.table and state.last_taker is not None: if state.table and state.last_taker is not None:
@@ -348,8 +343,7 @@ def _end_hand(state: GameState) -> None:
a, b = state.scores a, b = state.scores
log.debug( log.debug(
"game %s: hand %d scored A+%d B+%d (totals %d-%d)", "hand %d scored A+%d B+%d (totals %d-%d)",
state.id,
state.hand_number, state.hand_number,
points[0], points[0],
points[1], points[1],
@@ -364,15 +358,13 @@ def _end_hand(state: GameState) -> None:
if napola.get(name) == 10: if napola.get(name) == 10:
state.phase = PHASE_FINISHED state.phase = PHASE_FINISHED
state.winner = team state.winner = team
state.finished_at = datetime.now(timezone.utc).isoformat() log.info("team %s swept the denari (napola) and wins", name)
log.info("game %s: team %s swept the denari (napola) and wins", state.id, name)
return return
reached = max(a, b) >= state.target_score reached = max(a, b) >= state.target_score
if reached and a != b: if reached and a != b:
state.phase = PHASE_FINISHED state.phase = PHASE_FINISHED
state.winner = 0 if a > b else 1 state.winner = 0 if a > b else 1
state.finished_at = datetime.now(timezone.utc).isoformat() log.debug("match ended, team %s wins", "A" if state.winner == 0 else "B")
log.debug("game %s: match ended, team %s wins", state.id, "A" if state.winner == 0 else "B")
return return
# Pause for the scoring summary instead of dealing immediately. # Pause for the scoring summary instead of dealing immediately.
@@ -382,7 +374,7 @@ def _end_hand(state: GameState) -> None:
state.hand_end_deadline = deadline.isoformat() state.hand_end_deadline = deadline.isoformat()
def acknowledge_hand(state: GameState, sub: str) -> None: def acknowledge_hand(state: ScoponeState, sub: str) -> None:
"""Record that ``sub`` has read the hand-end summary. """Record that ``sub`` has read the hand-end summary.
When all four players have acknowledged, the next hand is dealt. When all four players have acknowledged, the next hand is dealt.
@@ -437,7 +429,7 @@ def napola_score(captured: Sequence[Card]) -> int:
return run if run >= 3 else 0 return run if run >= 3 else 0
def hand_points(state: GameState) -> Tuple[List[int], Dict[str, object]]: def hand_points(state: ScoponeState) -> Tuple[List[int], Dict[str, object]]:
"""Compute the hand points for both teams (index 0 = team A).""" """Compute the hand points for both teams (index 0 = team A)."""
piles: List[List[Card]] = [[], []] piles: List[List[Card]] = [[], []]
scope: List[int] = [0, 0] scope: List[int] = [0, 0]
@@ -506,13 +498,13 @@ def hand_points(state: GameState) -> Tuple[List[int], Dict[str, object]]:
return points, details return points, details
def state_for_player(state: GameState, sub: str) -> Dict[str, object]: def state_for_player(state: ScoponeState, sub: str) -> Dict[str, object]:
"""Serialize ``state`` hiding other players' hands. """Serialize ``state`` hiding other players' hands.
Hands are reduced to a count, except for the requesting player's own Hands are reduced to a count, except for the requesting player's own
hand. Raises :class:`~tavolo.game.errors.GameNotFound`-style access via hand. Non-seated viewers simply see no hand at all. The platform
the caller; this function assumes ``sub`` may or may not be seated and merges the session envelope (``id``, ``join_code``, ``game_type``)
simply omits the hand for non-seated viewers. into the view itself.
""" """
viewer = state.player_for(sub) viewer = state.player_for(sub)
players: List[Dict[str, object]] = [] players: List[Dict[str, object]] = []
@@ -531,9 +523,6 @@ def state_for_player(state: GameState, sub: str) -> Dict[str, object]:
players.append(view) players.append(view)
payload: Dict[str, object] = { payload: Dict[str, object] = {
"id": state.id,
"join_code": state.join_code,
"game_type": state.game_type,
"phase": state.phase, "phase": state.phase,
"target_score": state.target_score, "target_score": state.target_score,
"napola": state.napola, "napola": state.napola,
@@ -0,0 +1,34 @@
"""Scopone scientifico errors.
Most failure modes are game-independent and live in
:mod:`tavolo.platform.errors`; only genuinely scopone-specific errors
are defined here.
"""
from __future__ import annotations
from tavolo.platform.errors import (
AlreadyJoined,
GameError,
GameFinished,
GameNotFound,
GameNotStarted,
IllegalMove,
LobbyFull,
NotYourTurn,
)
__all__ = [
"AlreadyJoined",
"CardNotInHand",
"GameError",
"GameFinished",
"GameNotFound",
"GameNotStarted",
"IllegalMove",
"LobbyFull",
"NotYourTurn",
]
class CardNotInHand(GameError):
"""The played card is not held by the player."""
@@ -0,0 +1,266 @@
"""The platform-facing adapter for scopone scientifico.
:class:`ScoponeEngine` implements
:class:`~tavolo.platform.engine.GameEngine` on top of the pure rules in
:mod:`tavolo.scopone.engine`: it translates platform calls (create, join,
websocket actions, deadlines) into rules-engine calls and back, keeping
all scopone knowledge inside this package. Nothing outside
``tavolo.scopone`` needs to know about phases, turns or hand scoring.
"""
from __future__ import annotations
from datetime import datetime, timezone
from logging import getLogger
from typing import Any, Dict, Mapping, Optional
from tavolo.platform.engine import (
Deadline,
GameEngine,
GameSession,
MatchResult,
PlayerResult,
Seat,
)
from tavolo.platform.errors import GameError, IllegalMove
from . import engine
from .state import (
DEFAULT_TARGET_SCORE,
PHASE_FINISHED,
PHASE_HAND_END,
PHASE_LOBBY,
PHASE_PLAYING,
TEAM_NAMES,
ScoponeState,
)
log = getLogger(__name__)
def _deadline_ms(iso: Optional[str]) -> Optional[int]:
"""Epoch milliseconds for an ISO-8601 deadline, ``None`` when absent
or unparseable."""
if not iso:
return None
try:
return int(datetime.fromisoformat(iso).timestamp() * 1000)
except ValueError:
return None
class ScoponeEngine(GameEngine):
"""Scopone scientifico as a platform game engine."""
id = "scopone_scientifico"
name = "Scopone scientifico"
description = (
"Four players in fixed partnerships, ten cards each and an empty "
"table. First team to the target score wins."
)
min_players = 4
max_players = 4
options_schema = {
"type": "object",
"properties": {
"target_score": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": DEFAULT_TARGET_SCORE,
"description": "Match points the winning team must reach.",
},
"napola": {
"type": "boolean",
"default": True,
"description": "Score the napola rule; a full denari "
"sweep wins the match",
},
},
}
def __init__(
self,
turn_timeout_seconds: int = engine.DEFAULT_TURN_TIMEOUT_SECONDS,
hand_ack_timeout_seconds: int = engine.DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
) -> None:
self._turn_timeout = turn_timeout_seconds
self._hand_ack_timeout = hand_ack_timeout_seconds
# -- lobby ------------------------------------------------------------
def create(self, session: GameSession, options: Mapping[str, Any]) -> None:
target_score = options.get("target_score", DEFAULT_TARGET_SCORE)
if isinstance(target_score, bool) or not isinstance(target_score, int):
raise IllegalMove("target_score must be an integer")
napola = options.get("napola", True)
if not isinstance(napola, bool):
raise IllegalMove("napola must be a boolean")
creator = session.players[0]
session.state = engine.create_game(
creator_sub=creator.user_sub,
creator_name=creator.display_name,
target_score=target_score,
hand_ack_timeout=self._hand_ack_timeout,
turn_timeout=self._turn_timeout,
napola=napola,
)
# The creator takes seat 0, i.e. team A.
session.players[0] = Seat(
user_sub=creator.user_sub,
display_name=creator.display_name,
team=TEAM_NAMES[0],
)
def join(self, session: GameSession, user_sub: str, display_name: str) -> None:
seat = len(session.players)
engine.join_game(session.state, user_sub, display_name)
# join_game raises before appending on any violation, so the seat
# list stays in sync with the engine's players.
session.players.append(
Seat(
user_sub=user_sub,
display_name=display_name,
team=TEAM_NAMES[seat % 2],
)
)
def in_lobby(self, session: GameSession) -> bool:
return session.state.phase == PHASE_LOBBY
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
state: ScoponeState = session.state
return {
"phase": state.phase,
"target_score": state.target_score,
"napola": state.napola,
}
# -- play -------------------------------------------------------------
def handle_action(
self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any]
) -> None:
state: ScoponeState = session.state
if action == "play":
card = payload.get("card")
capture = payload.get("capture")
if not isinstance(card, str):
raise IllegalMove("'card' must be a card code string")
if capture is not None and (
not isinstance(capture, list)
or any(not isinstance(item, str) for item in capture)
):
raise IllegalMove("'capture' must be a list of card codes")
try:
engine.play(state, user_sub, card, capture)
except ValueError:
raise IllegalMove("invalid card code")
elif action == "ack":
engine.acknowledge_hand(state, user_sub)
else:
raise IllegalMove(f"unknown action: {action!r}")
def view_for(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
return engine.state_for_player(session.state, user_sub)
def is_finished(self, session: GameSession) -> bool:
return session.state.phase == PHASE_FINISHED
def game_over_view(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
state: ScoponeState = session.state
return {
"scores": {"A": state.scores[0], "B": state.scores[1]},
"winner": None if state.winner is None else TEAM_NAMES[state.winner],
}
# -- (de)serialization -------------------------------------------------
def state_to_json(self, state: Any) -> Dict[str, Any]:
assert isinstance(state, ScoponeState)
return state.to_json()
def state_from_json(self, data: Mapping[str, Any]) -> Any:
return ScoponeState.from_json(dict(data))
# -- deadlines ----------------------------------------------------------
def next_deadline(self, session: GameSession) -> Optional[Deadline]:
state: ScoponeState = session.state
if state.phase == PHASE_PLAYING and state.turn_deadline:
due_ms = _deadline_ms(state.turn_deadline)
if due_ms is None:
return None
return Deadline(
kind="turn",
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
token=f"turn:{state.hand_number}:{state.turn}:{due_ms}",
)
if state.phase == PHASE_HAND_END and state.hand_end_deadline:
due_ms = _deadline_ms(state.hand_end_deadline)
if due_ms is None:
return None
return Deadline(
kind="hand_end",
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
token=f"hand_end:{state.hand_number}:{due_ms}",
)
return None
def fire_deadline(self, session: GameSession, kind: str, token: str) -> None:
current = self.next_deadline(session)
if current is None or current.kind != kind or current.token != token:
# Overtaken by events (a play landed in time, the hand was
# acknowledged, the deadline moved): nothing to do.
raise GameError("stale deadline")
state: ScoponeState = session.state
if kind == "turn":
engine.auto_play(state)
actor = state.last_move.seat if state.last_move is not None else None
log.info(
"auto-played for seat %s (turn timeout, hand %d)",
actor,
state.hand_number,
)
elif kind == "hand_end":
for player in state.players:
engine.acknowledge_hand(state, player.sub)
log.info(
"hand %d auto-advanced after the acknowledgement timeout",
state.hand_number,
)
else: # pragma: no cover - next_deadline never emits other kinds
raise GameError(f"unknown deadline kind: {kind!r}")
# -- results -------------------------------------------------------------
def result(self, session: GameSession) -> MatchResult:
state: ScoponeState = session.state
if state.winner is None:
raise GameError("no result: the match is not finished")
teams = [
[p.sub for p in state.players if p.team == 0],
[p.sub for p in state.players if p.team == 1],
]
return MatchResult(
teams=teams,
winner_team=state.winner,
players=[
PlayerResult(
user_sub=player.sub,
seat=player.seat,
won=player.team == state.winner,
team=TEAM_NAMES[player.team],
score=float(state.scores[player.team]),
details={"scope": player.scope},
)
for player in state.players
],
summary={
"team_a_score": state.scores[0],
"team_b_score": state.scores[1],
"winner_team": TEAM_NAMES[state.winner],
"target_score": state.target_score,
"hands_played": state.hand_number,
"hand_scores": state.hand_scores,
},
)
@@ -1,9 +1,14 @@
"""In-memory representation of a scopone scientifico game. """In-memory representation of a scopone scientifico game.
The whole mutable game lives in :class:`GameState`, which is serialized to The whole mutable game lives in :class:`ScoponeState`, which is serialized
and from plain JSON for storage in Redis (see :mod:`tavolo.store`). Keeping to and from plain JSON for storage inside the platform's session envelope
the representation JSON-native means the store needs no custom codecs and (see :mod:`tavolo.platform.store`). Keeping the representation JSON-native
the state is inspectable with ``redis-cli``. means the store needs no custom codecs and the state is inspectable with
``redis-cli``.
The state contains only game data: the platform owns the session envelope
(id, join code, seats, timestamps, stats persistence) see
:class:`tavolo.platform.engine.GameSession`.
Deck convention: a 40-card Italian deck. Suits are ``D`` (denari), Deck convention: a 40-card Italian deck. Suits are ``D`` (denari),
``C`` (coppe), ``S`` (spade) and ``B`` (bastoni); ranks are ``1``..``10``. ``C`` (coppe), ``S`` (spade) and ``B`` (bastoni); ranks are ``1``..``10``.
@@ -144,17 +149,10 @@ class PlayerState:
@dataclass @dataclass
class GameState: class ScoponeState:
id: str
join_code: str
creator_sub: str
# Which card game this state belongs to (see tavolo.games.GAME_TYPES).
# Defaults so states serialized before game types existed still load.
game_type: str = "scopone_scientifico"
target_score: int = DEFAULT_TARGET_SCORE target_score: int = DEFAULT_TARGET_SCORE
# Whether the napola rule is scored (denari run from the ace; a full # Whether the napola rule is scored (denari run from the ace; a full
# suit wins the match instantly). Default on, also for states # suit wins the match instantly). Default on.
# serialized before the option existed.
napola: bool = True napola: bool = True
phase: str = PHASE_LOBBY phase: str = PHASE_LOBBY
players: List[PlayerState] = field(default_factory=list) players: List[PlayerState] = field(default_factory=list)
@@ -167,10 +165,6 @@ class GameState:
last_taker: Optional[int] = None last_taker: Optional[int] = None
# Per-hand points awarded, for a compact audit trail in the API. # Per-hand points awarded, for a compact audit trail in the API.
hand_scores: List[Dict[str, Any]] = field(default_factory=list) hand_scores: List[Dict[str, Any]] = field(default_factory=list)
stats_saved: bool = False
# ISO-8601 timestamps, used when the match result is written to Postgres.
created_at: Optional[str] = None
finished_at: Optional[str] = None
# The most recent play in the current hand, for move announcements. # The most recent play in the current hand, for move announcements.
last_move: Optional[Move] = None last_move: Optional[Move] = None
# While phase == "hand_end": seats that acknowledged the summary, and # While phase == "hand_end": seats that acknowledged the summary, and
@@ -180,7 +174,7 @@ class GameState:
# Seconds the hand-end summary waits before dealing anyway. # Seconds the hand-end summary waits before dealing anyway.
hand_ack_timeout: int = 30 hand_ack_timeout: int = 30
# While phase == "playing": when the server plays a random legal card # While phase == "playing": when the server plays a random legal card
# for the player on turn. Copied from settings at creation. # for the player on turn.
turn_deadline: Optional[str] = None turn_deadline: Optional[str] = None
turn_timeout: int = 30 turn_timeout: int = 30
@@ -188,10 +182,6 @@ class GameState:
def to_json(self) -> Dict[str, Any]: def to_json(self) -> Dict[str, Any]:
return { return {
"id": self.id,
"join_code": self.join_code,
"creator_sub": self.creator_sub,
"game_type": self.game_type,
"target_score": self.target_score, "target_score": self.target_score,
"napola": self.napola, "napola": self.napola,
"phase": self.phase, "phase": self.phase,
@@ -204,9 +194,6 @@ class GameState:
"winner": self.winner, "winner": self.winner,
"last_taker": self.last_taker, "last_taker": self.last_taker,
"hand_scores": list(self.hand_scores), "hand_scores": list(self.hand_scores),
"stats_saved": self.stats_saved,
"created_at": self.created_at,
"finished_at": self.finished_at,
"last_move": self.last_move.to_json() if self.last_move else None, "last_move": self.last_move.to_json() if self.last_move else None,
"acked": list(self.acked), "acked": list(self.acked),
"hand_end_deadline": self.hand_end_deadline, "hand_end_deadline": self.hand_end_deadline,
@@ -216,12 +203,8 @@ class GameState:
} }
@staticmethod @staticmethod
def from_json(data: Dict[str, Any]) -> "GameState": def from_json(data: Dict[str, Any]) -> "ScoponeState":
return GameState( return ScoponeState(
id=str(data["id"]),
join_code=str(data["join_code"]),
creator_sub=str(data.get("creator_sub", "")),
game_type=str(data.get("game_type", "scopone_scientifico")),
target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)), target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)),
napola=bool(data.get("napola", True)), napola=bool(data.get("napola", True)),
phase=str(data.get("phase", PHASE_LOBBY)), phase=str(data.get("phase", PHASE_LOBBY)),
@@ -234,9 +217,6 @@ class GameState:
winner=data.get("winner"), winner=data.get("winner"),
last_taker=data.get("last_taker"), last_taker=data.get("last_taker"),
hand_scores=list(data.get("hand_scores", [])), hand_scores=list(data.get("hand_scores", [])),
stats_saved=bool(data.get("stats_saved", False)),
created_at=data.get("created_at"),
finished_at=data.get("finished_at"),
last_move=Move.from_json(data["last_move"]) if data.get("last_move") else None, last_move=Move.from_json(data["last_move"]) if data.get("last_move") else None,
acked=[int(s) for s in data.get("acked", [])], acked=[int(s) for s in data.get("acked", [])],
hand_end_deadline=data.get("hand_end_deadline"), hand_end_deadline=data.get("hand_end_deadline"),
@@ -4,19 +4,19 @@ from __future__ import annotations
import random import random
import unittest import unittest
from tavolo.game import engine from tavolo.scopone import engine
from tavolo.game.errors import ( from tavolo.scopone.errors import (
CardNotInHand, CardNotInHand,
GameFinished, GameFinished,
GameNotStarted, GameNotStarted,
IllegalMove, IllegalMove,
NotYourTurn, NotYourTurn,
) )
from tavolo.game.state import ( from tavolo.scopone.state import (
PHASE_FINISHED, PHASE_FINISHED,
PHASE_PLAYING, PHASE_PLAYING,
Card, Card,
GameState, ScoponeState,
PlayerState, PlayerState,
) )
@@ -34,12 +34,9 @@ def make_state(
scope=None, scope=None,
target: int = 11, target: int = 11,
last_taker=None, last_taker=None,
) -> GameState: ) -> ScoponeState:
"""Build a controlled game state directly (bypassing the deal).""" """Build a controlled game state directly (bypassing the deal)."""
state = GameState( state = ScoponeState(
id="game-1",
join_code="ABC123",
creator_sub="p0",
target_score=target, target_score=target,
phase=PHASE_PLAYING, phase=PHASE_PLAYING,
turn=turn, turn=turn,
@@ -315,22 +312,22 @@ class NapolaTest(unittest.TestCase):
state = make_state([["02D"], [], [], []], table=[]) state = make_state([["02D"], [], [], []], table=[])
self.assertTrue(state.napola) self.assertTrue(state.napola)
state.napola = False state.napola = False
self.assertFalse(GameState.from_json(state.to_json()).napola) self.assertFalse(ScoponeState.from_json(state.to_json()).napola)
# States serialized before the option existed default to enabled. # States serialized before the option existed default to enabled.
data = state.to_json() data = state.to_json()
del data["napola"] del data["napola"]
self.assertTrue(GameState.from_json(data).napola) self.assertTrue(ScoponeState.from_json(data).napola)
def test_create_game_napola_default_and_override(self) -> None: def test_create_game_napola_default_and_override(self) -> None:
self.assertTrue(engine.create_game("g", "CODE42", "p0", "p0").napola) self.assertTrue(engine.create_game("p0", "p0").napola)
self.assertFalse( self.assertFalse(
engine.create_game("g", "CODE42", "p0", "p0", napola=False).napola engine.create_game("p0", "p0", napola=False).napola
) )
class MatchFlowTest(unittest.TestCase): class MatchFlowTest(unittest.TestCase):
def test_join_starts_when_full(self) -> None: def test_join_starts_when_full(self) -> None:
state = engine.create_game("g", "CODE42", "p0", "p0", target_score=11) state = engine.create_game("p0", "p0", target_score=11)
self.assertEqual(1, len(state.players)) self.assertEqual(1, len(state.players))
engine.join_game(state, "p1", "p1") engine.join_game(state, "p1", "p1")
engine.join_game(state, "p2", "p2") engine.join_game(state, "p2", "p2")
@@ -382,7 +379,7 @@ class MatchFlowTest(unittest.TestCase):
self.assertNotIn("your_turn", other) self.assertNotIn("your_turn", other)
def test_full_random_match_reaches_completion(self) -> None: def test_full_random_match_reaches_completion(self) -> None:
state = engine.create_game("g", "CODE99", "p0", "p0", target_score=11) state = engine.create_game("p0", "p0", target_score=11)
for i in range(1, 4): for i in range(1, 4):
engine.join_game(state, f"p{i}", f"p{i}") engine.join_game(state, f"p{i}", f"p{i}")
@@ -405,11 +402,10 @@ class MatchFlowTest(unittest.TestCase):
self.assertEqual([], state.table) self.assertEqual([], state.table)
self.assertTrue(all(not p.hand for p in state.players)) self.assertTrue(all(not p.hand for p in state.players))
self.assertEqual(40, sum(len(p.captured) for p in state.players)) self.assertEqual(40, sum(len(p.captured) for p in state.players))
self.assertTrue(state.finished_at)
class HandEndAckTest(unittest.TestCase): class HandEndAckTest(unittest.TestCase):
def _hand_end_state(self) -> GameState: def _hand_end_state(self) -> ScoponeState:
"""Drive a game into the hand_end phase with a one-card hand.""" """Drive a game into the hand_end phase with a one-card hand."""
state = make_state([["02D"], [], [], []], state = make_state([["02D"], [], [], []],
table=["02C"], target=11) table=["02C"], target=11)
@@ -525,7 +521,7 @@ class AutoPlayTest(unittest.TestCase):
engine.auto_play(state) engine.auto_play(state)
def test_create_game_copies_turn_timeout_and_arms_deadline(self) -> None: def test_create_game_copies_turn_timeout_and_arms_deadline(self) -> None:
state = engine.create_game("g", "CODE98", "p0", "p0", turn_timeout=7) state = engine.create_game("p0", "p0", turn_timeout=7)
self.assertEqual(7, state.turn_timeout) self.assertEqual(7, state.turn_timeout)
for i in range(1, 4): for i in range(1, 4):
engine.join_game(state, f"p{i}", f"p{i}") engine.join_game(state, f"p{i}", f"p{i}")
@@ -0,0 +1,289 @@
"""ScoponeEngine adapter tests: the platform contract over the pure rules."""
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from tavolo.platform import GameSession, Seat
from tavolo.platform.errors import GameError, IllegalMove
from tavolo.scopone import ScoponeEngine
from tavolo.scopone.state import PHASE_FINISHED, PHASE_HAND_END, PHASE_PLAYING, ScoponeState
def _session(engine: ScoponeEngine, **options) -> GameSession:
session = GameSession(
id="s1",
game_type=engine.id,
join_code="CODE01",
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="Alice")],
)
engine.create(session, options)
return session
def _started(engine: ScoponeEngine, **options) -> GameSession:
session = _session(engine, **options)
for name in ("bob", "carol", "dave"):
engine.join(session, name, name.capitalize())
return session
class CreateTest(unittest.TestCase):
def test_create_seats_creator_on_team_a(self) -> None:
engine = ScoponeEngine()
session = _session(engine)
self.assertEqual("A", session.players[0].team)
self.assertIsInstance(session.state, ScoponeState)
self.assertEqual(11, session.state.target_score)
self.assertTrue(session.state.napola)
def test_create_options(self) -> None:
engine = ScoponeEngine()
session = _session(engine, target_score=16, napola=False)
self.assertEqual(16, session.state.target_score)
self.assertFalse(session.state.napola)
def test_create_rejects_bad_options(self) -> None:
engine = ScoponeEngine()
with self.assertRaises(IllegalMove):
_session(engine, target_score=0)
with self.assertRaises(IllegalMove):
_session(engine, target_score="eleven")
with self.assertRaises(IllegalMove):
_session(engine, napola="yes")
def test_timeouts_come_from_the_engine(self) -> None:
engine = ScoponeEngine(turn_timeout_seconds=7, hand_ack_timeout_seconds=9)
session = _session(engine)
self.assertEqual(7, session.state.turn_timeout)
self.assertEqual(9, session.state.hand_ack_timeout)
class JoinTest(unittest.TestCase):
def test_join_assigns_teams_and_starts(self) -> None:
engine = ScoponeEngine()
session = _session(engine)
self.assertTrue(engine.in_lobby(session))
for name, team in (("bob", "B"), ("carol", "A"), ("dave", "B")):
engine.join(session, name, name.capitalize())
self.assertEqual(team, session.players[-1].team)
self.assertFalse(engine.in_lobby(session))
self.assertEqual(PHASE_PLAYING, session.state.phase)
def test_join_errors(self) -> None:
from tavolo.platform.errors import AlreadyJoined, GameNotStarted
engine = ScoponeEngine()
session = _session(engine)
with self.assertRaises(AlreadyJoined):
engine.join(session, "alice", "Alice")
for name in ("bob", "carol", "dave"):
engine.join(session, name, name.capitalize())
# The lobby filled up and the match started: late joins and even
# re-joins are rejected as "already started".
with self.assertRaises(GameNotStarted):
engine.join(session, "erin", "Erin")
with self.assertRaises(GameNotStarted):
engine.join(session, "alice", "Alice")
class ActionTest(unittest.TestCase):
def test_unknown_action_rejected(self) -> None:
engine = ScoponeEngine()
session = _started(engine)
with self.assertRaises(IllegalMove):
engine.handle_action(session, "alice", "dance", {})
def test_play_validates_payload(self) -> None:
engine = ScoponeEngine()
session = _started(engine)
with self.assertRaises(IllegalMove):
engine.handle_action(session, "bob", "play", {"card": 42})
with self.assertRaises(IllegalMove):
engine.handle_action(session, "bob", "play", {})
with self.assertRaises(IllegalMove):
engine.handle_action(session, "bob", "play", {"card": "01D", "capture": "02C"})
def test_invalid_card_code_is_illegal_move(self) -> None:
engine = ScoponeEngine()
session = _started(engine)
with self.assertRaises(IllegalMove):
engine.handle_action(session, "bob", "play", {"card": "nope"})
def test_finished_match_rejects_actions(self) -> None:
from tavolo.platform.errors import GameFinished
engine = ScoponeEngine()
session = _started(engine, target_score=1)
# Drive to completion: keep playing legal moves until finished.
from tavolo.scopone import engine as rules
moves = 0
while not engine.is_finished(session) and moves < 200000:
state = session.state
if state.phase == "hand_end":
for p in state.players:
engine.handle_action(session, p.sub, "ack", {})
continue
player = next(p for p in state.players if p.seat == state.turn)
played = player.hand[0]
options = rules.legal_captures(state.table, played)
payload = {"card": played.code}
if options:
payload["capture"] = [c.code for c in options[0]]
engine.handle_action(session, player.sub, "play", payload)
moves += 1
self.assertTrue(engine.is_finished(session))
with self.assertRaises(GameFinished):
engine.handle_action(session, "alice", "play", {"card": "01D"})
class ViewTest(unittest.TestCase):
def test_view_for_hides_other_hands(self) -> None:
engine = ScoponeEngine()
session = _started(engine)
view = engine.view_for(session, "alice")
players = {p["seat"]: p for p in view["players"]}
self.assertIn("hand", players[0])
self.assertNotIn("hand", players[1])
# The envelope is the platform's job, not the view's.
self.assertNotIn("id", view)
self.assertNotIn("join_code", view)
self.assertNotIn("game_type", view)
def test_lobby_view(self) -> None:
engine = ScoponeEngine()
session = _session(engine, target_score=16)
lobby = engine.lobby_view(session)
self.assertEqual("lobby", lobby["phase"])
self.assertEqual(16, lobby["target_score"])
self.assertTrue(lobby["napola"])
class SerializationTest(unittest.TestCase):
def test_state_roundtrip(self) -> None:
engine = ScoponeEngine()
session = _started(engine)
restored = engine.state_from_json(engine.state_to_json(session.state))
self.assertIsInstance(restored, ScoponeState)
self.assertEqual(session.state.phase, restored.phase)
self.assertEqual(session.state.turn, restored.turn)
self.assertEqual(
[p.sub for p in session.state.players],
[p.sub for p in restored.players],
)
class DeadlineTest(unittest.TestCase):
def test_no_deadline_in_lobby(self) -> None:
engine = ScoponeEngine()
self.assertIsNone(engine.next_deadline(_session(engine)))
def test_turn_deadline_and_revalidation(self) -> None:
engine = ScoponeEngine()
session = _started(engine)
deadline = engine.next_deadline(session)
assert deadline is not None
self.assertEqual("turn", deadline.kind)
# A forged token is stale.
with self.assertRaises(GameError):
engine.fire_deadline(session, "turn", "turn:1:1:0")
turn_before = session.state.turn
engine.fire_deadline(session, deadline.kind, deadline.token)
self.assertNotEqual(turn_before, session.state.turn)
def test_stale_deadline_after_play(self) -> None:
from tavolo.scopone import engine as rules
engine = ScoponeEngine()
session = _started(engine)
deadline = engine.next_deadline(session)
assert deadline is not None
# A play lands in time: the armed deadline is overtaken.
state = session.state
player = next(p for p in state.players if p.seat == state.turn)
played = player.hand[0]
options = rules.legal_captures(state.table, played)
payload = {"card": played.code}
if options:
payload["capture"] = [c.code for c in options[0]]
engine.handle_action(session, player.sub, "play", payload)
with self.assertRaises(GameError):
engine.fire_deadline(session, deadline.kind, deadline.token)
def test_hand_end_deadline_advances(self) -> None:
engine = ScoponeEngine()
session = _started(engine)
state = session.state
# Force the hand-end phase with an imminent deadline.
state.phase = PHASE_HAND_END
state.hand_end_deadline = (
datetime.now(timezone.utc) + timedelta(seconds=60)
).isoformat()
deadline = engine.next_deadline(session)
assert deadline is not None
self.assertEqual("hand_end", deadline.kind)
engine.fire_deadline(session, deadline.kind, deadline.token)
self.assertEqual(PHASE_PLAYING, session.state.phase)
self.assertEqual(2, session.state.hand_number)
class ResultTest(unittest.TestCase):
def test_result_of_finished_match(self) -> None:
from tavolo.scopone import engine as rules
engine = ScoponeEngine()
session = _started(engine, target_score=1)
moves = 0
while not engine.is_finished(session) and moves < 200000:
state = session.state
if state.phase == "hand_end":
for p in state.players:
engine.handle_action(session, p.sub, "ack", {})
continue
player = next(p for p in state.players if p.seat == state.turn)
played = player.hand[0]
options = rules.legal_captures(state.table, played)
payload = {"card": played.code}
if options:
payload["capture"] = [c.code for c in options[0]]
engine.handle_action(session, player.sub, "play", payload)
moves += 1
result = engine.result(session)
self.assertEqual(2, len(result.teams))
self.assertIn(result.winner_team, (0, 1))
self.assertEqual(4, len(result.players))
for player in result.players:
self.assertEqual(
player.won, player.team == ("A" if result.winner_team == 0 else "B")
)
self.assertIn("team_a_score", result.summary)
self.assertIn("hands_played", result.summary)
def test_result_requires_finished_match(self) -> None:
engine = ScoponeEngine()
with self.assertRaises(GameError):
engine.result(_started(engine))
def test_game_over_view(self) -> None:
engine = ScoponeEngine()
session = _started(engine)
session.state.phase = PHASE_FINISHED
session.state.winner = 1
session.state.scores = [3, 11]
over = engine.game_over_view(session, "alice")
self.assertEqual({"A": 3, "B": 11}, over["scores"])
self.assertEqual("B", over["winner"])
def test_registry_metadata(self) -> None:
engine = ScoponeEngine()
self.assertEqual("scopone_scientifico", engine.id)
self.assertEqual(4, engine.min_players)
self.assertEqual(4, engine.max_players)
self.assertIn("target_score", engine.options_schema["properties"])
self.assertIn("napola", engine.options_schema["properties"])
if __name__ == "__main__":
unittest.main()
+10 -20
View File
@@ -3,22 +3,24 @@ requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
name = "tavolo" name = "tavolo-app"
version = "0.1.0" version = "0.1.0"
description = "Multiplayer card-game platform backend built on the kaya framework" description = "Tavolo multiplayer card-game application: platform + scopone game wiring"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"tavolo-platform",
"tavolo-scopone",
"kaya-core", "kaya-core",
"kaya-cors",
"kaya-session", "kaya-session",
"kaya-session-redis", "kaya-session-redis",
"kaya-oidc", "kaya-oidc",
"kaya-openapi", "kaya-openapi",
"kaya-rsgi", "kaya-rsgi",
"granian>=2.0", "granian>=2.0",
"tortoise-orm",
"aerich",
"asyncpg", "asyncpg",
"aerich",
"httpx", "httpx",
"PyJWT[crypto]", "PyJWT[crypto]",
"pwo", "pwo",
@@ -31,10 +33,13 @@ dev = [
"mypy", "mypy",
"httpx-ws", "httpx-ws",
] ]
otel = [
"kaya-otel>=0.0.4",
]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
namespaces = false namespaces = true
# Database migrations (aerich). See the Migrations section in README.md. # Database migrations (aerich). See the Migrations section in README.md.
[tool.aerich] [tool.aerich]
@@ -45,18 +50,3 @@ location = "./migrations"
python_version = "3.12" python_version = "3.12"
ignore_missing_imports = true ignore_missing_imports = true
plugins = [] plugins = []
# TortoiseORM auto-generates `<fk>_id` attributes on ForeignKeyField at
# runtime; without the (unavailable here) tortoise mypy plugin the stubs
# only declare the relation field. These are real attributes, not bugs.
[[tool.mypy.overrides]]
module = "tavolo.models"
disable_error_code = ["attr-defined"]
[[tool.mypy.overrides]]
module = "tavolo.routes.*"
disable_error_code = ["attr-defined"]
[[tool.mypy.overrides]]
module = "tavolo.game.*"
disable_error_code = ["attr-defined"]
+32 -25
View File
@@ -2,13 +2,19 @@
# This file is autogenerated by pip-compile with Python 3.14 # This file is autogenerated by pip-compile with Python 3.14
# by the following command: # by the following command:
# #
# pip-compile --allow-unsafe --extra-index-url=https://pypi.org/simple --index-url=https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --no-index --output-file=requirements.txt pyproject.toml # pip-compile --allow-unsafe --extra-index-url=https://pypi.org/simple --index-url=https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --output-file=requirements.txt pyproject.toml
#
# NOTE: the tavolo-platform and tavolo-scopone sibling packages are NOT
# pinned here: they are installed from ./packages in the Dockerfile and in
# development (pip install -e ./packages/* -e .). All of their third-party
# dependencies are direct dependencies of tavolo-app, so this lock covers
# the full closure.
# #
--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple
--extra-index-url https://pypi.org/simple --extra-index-url https://pypi.org/simple
aerich==0.10.1 aerich==0.10.1
# via tavolo (pyproject.toml) # via tavolo-app (pyproject.toml)
aiosqlite==0.22.1 aiosqlite==0.22.1
# via tortoise-orm # via tortoise-orm
anyio==4.15.1 anyio==4.15.1
@@ -19,7 +25,7 @@ anyio==4.15.1
asyncclick==8.4.2.1 asyncclick==8.4.2.1
# via aerich # via aerich
asyncpg==0.31.0 asyncpg==0.31.0
# via tavolo (pyproject.toml) # via tavolo-app (pyproject.toml)
certifi==2026.7.22 certifi==2026.7.22
# via # via
# httpcore # httpcore
@@ -35,7 +41,7 @@ dictdiffer==0.10.0
granian==2.8.3 granian==2.8.3
# via # via
# kaya-rsgi # kaya-rsgi
# tavolo (pyproject.toml) # tavolo-app (pyproject.toml)
h11==0.16.0 h11==0.16.0
# via httpcore # via httpcore
httpcore==1.0.9 httpcore==1.0.9
@@ -43,57 +49,58 @@ httpcore==1.0.9
httpx==0.28.1 httpx==0.28.1
# via # via
# kaya-oidc # kaya-oidc
# tavolo (pyproject.toml) # tavolo-app (pyproject.toml)
idna==3.19 idna==3.20
# via # via
# anyio # anyio
# httpx # httpx
iso8601==2.1.0 iso8601==2.1.0
# via tortoise-orm # via tortoise-orm
kaya-core==0.0.3 kaya-core==0.0.4
# via # via
# kaya-cors
# kaya-oidc # kaya-oidc
# kaya-openapi # kaya-openapi
# kaya-rsgi # kaya-rsgi
# kaya-session # kaya-session
# tavolo (pyproject.toml) # tavolo-app (pyproject.toml)
kaya-oidc==0.0.3 kaya-cors==0.0.4
# via tavolo (pyproject.toml) # via tavolo-app (pyproject.toml)
kaya-openapi==0.0.3 kaya-oidc==0.0.4
# via tavolo (pyproject.toml) # via tavolo-app (pyproject.toml)
kaya-rsgi==0.0.3 kaya-openapi==0.0.4
# via tavolo (pyproject.toml) # via tavolo-app (pyproject.toml)
kaya-session==0.0.3 kaya-rsgi==0.0.4
# via tavolo-app (pyproject.toml)
kaya-session==0.0.4
# via # via
# kaya-oidc # kaya-oidc
# kaya-session-redis # kaya-session-redis
# tavolo (pyproject.toml) # tavolo-app (pyproject.toml)
kaya-session-redis==0.0.3 kaya-session-redis==0.0.4
# via tavolo (pyproject.toml) # via tavolo-app (pyproject.toml)
pwo==0.1.2 pwo==0.1.2
# via # via
# kaya-core # kaya-core
# kaya-rsgi # kaya-rsgi
# kaya-session # kaya-session
# tavolo (pyproject.toml) # tavolo-app (pyproject.toml)
pycparser==3.0 pycparser==3.0
# via cffi # via cffi
pyjwt[crypto]==2.14.0 pyjwt[crypto]==2.14.0
# via # via
# kaya-oidc # kaya-oidc
# tavolo (pyproject.toml) # tavolo-app (pyproject.toml)
pypika-tortoise==0.6.5 pypika-tortoise==0.6.5
# via tortoise-orm # via tortoise-orm
pyyaml==6.0.3 pyyaml==6.0.3
# via tavolo (pyproject.toml) # via tavolo-app (pyproject.toml)
redis==8.1.0 redis==8.1.0
# via # via
# kaya-session-redis # kaya-session-redis
# tavolo (pyproject.toml) # tavolo-app (pyproject.toml)
tortoise-orm==1.1.8 tortoise-orm==1.1.8
# via # via aerich
# aerich
# tavolo (pyproject.toml)
typing-extensions==4.16.0 typing-extensions==4.16.0
# via # via
# anyio # anyio
-1
View File
@@ -1 +0,0 @@
"""Scopone scientifico backend built on the kaya framework."""
+1 -1
View File
@@ -16,7 +16,7 @@ TORTOISE_ORM = {
"connections": {"default": settings.database_url}, "connections": {"default": settings.database_url},
"apps": { "apps": {
"models": { "models": {
"models": ["tavolo.models", "aerich.models"], "models": ["tavolo.platform.models", "aerich.models"],
"default_connection": "default", "default_connection": "default",
} }
}, },
+142 -31
View File
@@ -1,54 +1,137 @@
"""Application entry point. """Application entry point.
Assembles the :class:`~kaya.core.KayaApp` with four mixins: Assembles the :class:`~kaya.core.KayaApp` with the kaya mixins plus the
platform:
- :class:`~kaya.session.SessionMixin` (sessions persisted in Redis via - :class:`~kaya.session.SessionMixin` (sessions persisted in Redis via
:class:`~kaya.session.redis.RedisSessionStore` when ``REDIS_URL`` is set, :class:`~kaya.session.redis.RedisSessionStore` when ``REDIS_URL`` is set,
otherwise an in-memory store — e.g. for tests) otherwise an in-memory store — e.g. for tests)
- :class:`~kaya.oidc.OIDCMixin` (OIDC login) - :class:`~kaya.oidc.OIDCMixin` (OIDC login)
- :class:`~tavolo.tortoise_mixin.TortoiseMixin` (Postgres match statistics; - :class:`~tavolo.platform.tortoise_mixin.TortoiseMixin` (Postgres match
skipped for ``/api/health`` and the OpenAPI documentation endpoints) statistics; skipped for ``/api/health`` and the OpenAPI documentation
endpoints)
- :class:`~kaya.openapi.OpenAPIMixin` (serves the OpenAPI document at - :class:`~kaya.openapi.OpenAPIMixin` (serves the OpenAPI document at
``/api/openapi.json`` and a Swagger UI at ``/api/docs``) ``/api/openapi.json`` and a Swagger UI at ``/api/docs``)
- :class:`~tavolo.platform.mixin.PlatformMixin` (game lobby, match
history, leaderboards and the live-play websocket, served by the
registered game engines)
- :class:`~tavolo.platform.deadlines.DeadlineSchedulerMixin` (fires the
engines' timeouts)
Live games are kept in :data:`game_store` (Redis when configured, in-memory A :class:`~kaya.cors.CorsMixin` is prepended when CORS is configured via the
otherwise). Routes and the websocket handlers are registered by importing ``CORS_*`` environment variables (see :mod:`tavolo.config`). A
their modules at the bottom; imports must happen after ``app`` is built. :class:`~kaya.otel.OTelMixin` (optional ``otel`` extra) is prepended when
``OTEL_ENABLED`` is set, adding OpenTelemetry traces and metrics.
Live games are kept in :data:`game_store` (Redis when configured,
in-memory otherwise). The SPA shell routes are registered at the bottom;
imports must happen after ``app`` is built.
""" """
from __future__ import annotations from __future__ import annotations
from importlib.metadata import version as _pkg_version from importlib.metadata import version as _pkg_version
from logging import getLogger from logging import getLogger
from typing import Optional
from kaya.core import KayaApp from kaya.core import KayaApp, KayaMixin
from kaya.cors import CorsMixin
from kaya.oidc import OIDCConfig, OIDCMixin from kaya.oidc import OIDCConfig, OIDCMixin
from kaya.openapi import OpenAPIMixin from kaya.openapi import OpenAPIMixin
from kaya.session import InMemorySessionStore, SessionMixin, SessionStore from kaya.session import InMemorySessionStore, SessionMixin, SessionStore
from kaya.session.redis import RedisSessionStore from kaya.session.redis import RedisSessionStore
from redis.asyncio import Redis from redis.asyncio import Redis
from tavolo.platform import GameRegistry, Platform, PlatformMixin
from tavolo.platform.deadlines import DeadlineScheduler, DeadlineSchedulerMixin
from tavolo.platform.store import GameStore, InMemoryGameStore, RedisGameStore
from tavolo.platform.tortoise_mixin import TortoiseMixin
from tavolo.scopone import ScoponeEngine
from .config import settings from .config import Settings, settings
from .deadlines import DeadlineSchedulerMixin
from .logging_config import configure_logging from .logging_config import configure_logging
from .store import GameStore, InMemoryGameStore, RedisGameStore
from .tortoise_mixin import TortoiseMixin
configure_logging(settings.logging_config) configure_logging(settings.logging_config)
log = getLogger(__name__) log = getLogger(__name__)
def cors_mixin_from_settings(settings: Settings) -> Optional[CorsMixin]:
"""Build a :class:`~kaya.cors.CorsMixin` from the CORS settings.
Returns ``None`` — CORS disabled — unless at least one of
``CORS_ALLOW_ORIGINS`` / ``CORS_ALLOW_ORIGIN_REGEX`` is configured.
Settings left unset fall back to the mixin's own defaults.
"""
if settings.cors_allow_origins is None and settings.cors_allow_origin_regex is None:
return None
return CorsMixin(
allow_origins=settings.cors_allow_origins or (),
allow_origin_regex=settings.cors_allow_origin_regex,
allow_methods=settings.cors_allow_methods or ("GET",),
allow_headers=settings.cors_allow_headers or (),
allow_credentials=settings.cors_allow_credentials,
expose_headers=settings.cors_expose_headers or (),
max_age=settings.cors_max_age,
)
def otel_mixin_from_settings(settings: Settings) -> Optional[KayaMixin]:
"""Build a :class:`~kaya.otel.OTelMixin` from the OTEL_* settings.
Returns ``None`` — telemetry disabled — unless ``OTEL_ENABLED`` is
truthy. kaya-otel is an optional dependency (the ``otel`` extra), so it
is imported lazily here: default installs and the test suite never need
the OpenTelemetry packages.
"""
if not settings.otel_enabled:
return None
try:
from kaya.otel import OTelMixin
except ImportError as exc:
raise RuntimeError(
"OTEL_ENABLED is set but kaya-otel is not installed; "
"install tavolo-app with the 'otel' extra"
) from exc
headers = dict(
pair.split("=", 1)
for pair in (settings.otel_exporter_headers or ())
if "=" in pair
)
return OTelMixin(
service_name=settings.otel_service_name,
endpoint=settings.otel_exporter_endpoint,
headers=headers or None,
excluded_paths=settings.otel_excluded_paths,
)
registry = GameRegistry()
registry.register(
ScoponeEngine(
turn_timeout_seconds=settings.turn_timeout_seconds,
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
)
)
log.info("registered games: %s", ", ".join(e.id for e in registry.all()))
log.debug(
"scopone timeouts: hand_ack=%ds turn=%ds",
settings.hand_ack_timeout_seconds,
settings.turn_timeout_seconds,
)
session_store: SessionStore session_store: SessionStore
game_store: GameStore
if settings.redis_url is not None: if settings.redis_url is not None:
# Lazy client: no connection is opened until a session is actually # Lazy client: no connection is opened until a session is actually
# loaded/saved, so importing this module never requires a live Redis. # loaded/saved, so importing this module never requires a live Redis.
session_store = RedisSessionStore(Redis.from_url(settings.redis_url)) session_store = RedisSessionStore(Redis.from_url(settings.redis_url))
game_store: GameStore = RedisGameStore( game_store = RedisGameStore(
Redis.from_url(settings.redis_url, decode_responses=False), Redis.from_url(settings.redis_url, decode_responses=False),
registry,
ttl_seconds=settings.game_ttl_seconds, ttl_seconds=settings.game_ttl_seconds,
) )
log.info("using Redis stores (sessions + live games, game TTL %ds)", settings.game_ttl_seconds) log.info("using Redis stores (sessions + live games, game TTL %ds)", settings.game_ttl_seconds)
else: else:
session_store = InMemorySessionStore() session_store = InMemorySessionStore()
game_store = InMemoryGameStore() game_store = InMemoryGameStore(registry)
log.info("REDIS_URL unset: using in-memory stores (sessions + live games)") log.info("REDIS_URL unset: using in-memory stores (sessions + live games)")
session_mixin = SessionMixin(session_store) session_mixin = SessionMixin(session_store)
@@ -66,30 +149,58 @@ oidc_mixin = OIDCMixin(
) )
openapi_mixin = OpenAPIMixin( openapi_mixin = OpenAPIMixin(
title="tavolo", title="tavolo",
version=_pkg_version("tavolo"), version=_pkg_version("tavolo-app"),
description="Scopone scientifico multiplayer API", description="Multiplayer card-game platform API",
spec_path="/api/openapi.json", spec_path="/api/openapi.json",
docs_path="/api/docs", docs_path="/api/docs",
) )
tortoise_mixin = TortoiseMixin( tortoise_mixin = TortoiseMixin(
database_url=settings.database_url, database_url=settings.database_url,
models_modules=["tavolo.models"], models_modules=["tavolo.platform.models"],
skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}), skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}),
) )
scheduler = DeadlineScheduler(
app = KayaApp(mixins=[session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin, game_store,
DeadlineSchedulerMixin(game_store)]) registry,
log.debug( heartbeat_ms=settings.deadline_heartbeat_ms,
"timeouts: hand_ack=%ds turn=%ds", )
settings.hand_ack_timeout_seconds, platform = Platform(
settings.turn_timeout_seconds, registry=registry,
game_store=game_store,
scheduler=scheduler,
oidc=oidc_mixin,
) )
# Register routes by importing modules. Order does not matter; each module mixins: list[KayaMixin] = [session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin,
# pulls ``app`` from here and decorates its handlers at import time. The PlatformMixin(platform), DeadlineSchedulerMixin(scheduler)]
# static SPA-shell catch-all is registered last and only matches paths no otel_mixin = otel_mixin_from_settings(settings)
# other route claimed (asset files under /static are served by Granian if otel_mixin is not None:
# itself and never reach the app). # First in the list: before hooks run in registration order (after hooks
from .routes import games, health, me, stats # noqa: E402,F401 # in reverse), so the span covers session loading, OIDC handling and the
from . import ws # noqa: E402,F401 # handler itself. CORS, when enabled, is still prepended before it so
from .routes import static # noqa: E402,F401 # preflight short-circuits stay untraced.
mixins.insert(0, otel_mixin)
log.info(
"OpenTelemetry enabled: service=%s endpoint=%s",
settings.otel_service_name,
settings.otel_exporter_endpoint or "(OTLP default)",
)
cors_mixin = cors_mixin_from_settings(settings)
if cors_mixin is not None:
# First in the list: preflight requests are answered before the session
# and OIDC hooks run.
mixins.insert(0, cors_mixin)
log.info(
"CORS enabled: origins=%s origin_regex=%s credentials=%s",
settings.cors_allow_origins,
settings.cors_allow_origin_regex,
settings.cors_allow_credentials,
)
app = KayaApp(mixins=mixins)
# Register the SPA shell. The catch-all only matches paths no other route
# claimed (asset files under /static are served by Granian itself and never
# reach the app).
from . import static # noqa: E402,F401
+62 -3
View File
@@ -7,7 +7,7 @@ from __future__ import annotations
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import Optional, Tuple
from urllib.parse import quote from urllib.parse import quote
@@ -20,6 +20,25 @@ def _env(name: str, default: Optional[str] = None) -> str:
return value return value
def _env_list(name: str) -> Optional[Tuple[str, ...]]:
"""Parse a comma-separated environment variable into a tuple of values.
Items are stripped and empty items dropped. Unset or empty variables
yield ``None``.
"""
value = os.environ.get(name)
if value is None or value.strip() == "":
return None
return tuple(part.strip() for part in value.split(",") if part.strip())
def _env_bool(name: str, default: bool = False) -> bool:
value = os.environ.get(name)
if value is None or value == "":
return default
return value.strip().lower() in ("1", "true", "yes", "on")
def _database_url_from_parts(engine: str, def _database_url_from_parts(engine: str,
user: str, user: str,
password: Optional[str], password: Optional[str],
@@ -71,10 +90,12 @@ class Settings:
# are served by Granian under /static (GRANIAN_STATIC_PATH_* env vars). # are served by Granian under /static (GRANIAN_STATIC_PATH_* env vars).
static_dir: str static_dir: str
# Seconds the between-hands scoring summary waits for acknowledgements # Seconds the between-hands scoring summary waits for acknowledgements
# before dealing the next hand anyway. # before dealing the next hand anyway (wired into the ScoponeEngine;
# see tavolo.app).
hand_ack_timeout_seconds: int hand_ack_timeout_seconds: int
# Seconds a player has to play before the server plays a random legal # Seconds a player has to play before the server plays a random legal
# card for them (covering disconnects and idle players). # card for them, covering disconnects and idle players (wired into the
# ScoponeEngine; see tavolo.app).
turn_timeout_seconds: int turn_timeout_seconds: int
# Upper bound on how long the deadline consumer sleeps between polls. # Upper bound on how long the deadline consumer sleeps between polls.
# Locally enqueued deadlines wake the consumer immediately; the # Locally enqueued deadlines wake the consumer immediately; the
@@ -84,6 +105,26 @@ class Settings:
# Path to a YAML logging configuration file (logging.config.dictConfig # Path to a YAML logging configuration file (logging.config.dictConfig
# schema). Unset uses the built-in default: DEBUG to the console. # schema). Unset uses the built-in default: DEBUG to the console.
logging_config: Optional[str] logging_config: Optional[str]
# CORS (kaya-cors' CorsMixin). Disabled unless CORS_ALLOW_ORIGINS or
# CORS_ALLOW_ORIGIN_REGEX is set; the app serves the SPA and the API
# from the same origin, so no CORS headers are needed by default.
cors_allow_origins: Optional[Tuple[str, ...]]
cors_allow_origin_regex: Optional[str]
cors_allow_methods: Optional[Tuple[str, ...]]
cors_allow_headers: Optional[Tuple[str, ...]]
cors_allow_credentials: bool
cors_expose_headers: Optional[Tuple[str, ...]]
cors_max_age: int
# OpenTelemetry (kaya-otel's OTelMixin). Disabled unless OTEL_ENABLED is
# truthy; the exporter endpoint falls back to the OTLP/HTTP default
# (localhost:4318) when OTEL_EXPORTER_OTLP_ENDPOINT is unset.
otel_enabled: bool
otel_service_name: str
otel_exporter_endpoint: Optional[str]
otel_exporter_headers: Optional[Tuple[str, ...]]
# Paths excluded from tracing and metrics (exact matches). Defaults to
# the health endpoint, which k8s probes would otherwise spam.
otel_excluded_paths: Tuple[str, ...]
@staticmethod @staticmethod
def from_env() -> "Settings": def from_env() -> "Settings":
@@ -120,6 +161,24 @@ class Settings:
turn_timeout_seconds=int(_env("TURN_TIMEOUT_SECONDS", "30")), turn_timeout_seconds=int(_env("TURN_TIMEOUT_SECONDS", "30")),
deadline_heartbeat_ms=int(_env("DEADLINE_HEARTBEAT_MS", "1000")), deadline_heartbeat_ms=int(_env("DEADLINE_HEARTBEAT_MS", "1000")),
logging_config=os.environ.get("LOGGING_CONFIG"), logging_config=os.environ.get("LOGGING_CONFIG"),
# CORS is disabled unless CORS_ALLOW_ORIGINS (a comma-separated
# list of origins, or "*" for any) or CORS_ALLOW_ORIGIN_REGEX
# is set.
cors_allow_origins=_env_list("CORS_ALLOW_ORIGINS"),
cors_allow_origin_regex=os.environ.get("CORS_ALLOW_ORIGIN_REGEX") or None,
cors_allow_methods=_env_list("CORS_ALLOW_METHODS"),
cors_allow_headers=_env_list("CORS_ALLOW_HEADERS"),
cors_allow_credentials=_env_bool("CORS_ALLOW_CREDENTIALS"),
cors_expose_headers=_env_list("CORS_EXPOSE_HEADERS"),
cors_max_age=int(_env("CORS_MAX_AGE", "600")),
# OpenTelemetry is opt-in: set OTEL_ENABLED=1 to export traces
# and metrics via OTLP/HTTP (requires the ``otel`` extra).
otel_enabled=_env_bool("OTEL_ENABLED"),
otel_service_name=_env("OTEL_SERVICE_NAME", "tavolo"),
otel_exporter_endpoint=os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") or None,
# Comma-separated key=value pairs, e.g. "Authorization=Bearer x".
otel_exporter_headers=_env_list("OTEL_EXPORTER_OTLP_HEADERS"),
otel_excluded_paths=_env_list("OTEL_EXCLUDED_PATHS") or ("/api/health",),
) )
-296
View File
@@ -1,296 +0,0 @@
"""Deadline-driven timeouts, independent of player connections.
Both in-match timeouts — the per-turn auto-play (``turn_deadline``) and
the hand-end summary auto-continue (``hand_end_deadline``) — are driven by
the absolute deadlines persisted on the game state, never by which players
(or whether any players) are connected.
Every mutation that sets a deadline enqueues an entry in the store's
shared deadline queue (a Redis sorted set in production, see
:mod:`tavolo.store`), and a background consumer running on **every**
worker polls the queue for due entries. An entry records the phase, hand,
turn and deadline (as integer epoch milliseconds) it was enqueued for;
before acting, the consumer revalidates all of it against the live state
under the per-game lock, so entries that were overtaken by events (a play
landed in time, the hand was acknowledged, the deadline moved) are simply
discarded.
Delivery is at-least-once: an entry is removed from the queue only after
it has been processed. If a worker dies mid-processing, the entry stays in
Redis and another worker's consumer picks it up — the lock plus
revalidation make the duplicate delivery a no-op. Entries whose game has
expired are dropped the first time they fire, so the queue is
self-cleaning.
"""
from __future__ import annotations
import asyncio
import json
import time
from datetime import datetime
from logging import getLogger
from typing import Any, Dict, Optional
from kaya.core import KayaApp, KayaMixin
from .config import settings
from .game import engine
from .game.errors import GameError
from .game.state import PHASE_HAND_END, PHASE_PLAYING, PHASE_FINISHED, GameState
from .stats import save_match_result
from .store import GameStore
log = getLogger(__name__)
# Entry kinds enqueued in the deadline queue.
KIND_TURN = "turn"
KIND_HAND_END = "hand_end"
# One consumer task and its wake-up event per event loop (tests run each
# test on a fresh loop).
_consumers: Dict[asyncio.AbstractEventLoop, asyncio.Task] = {}
_wake_events: Dict[asyncio.AbstractEventLoop, asyncio.Event] = {}
def encode(entry: Dict[str, Any]) -> str:
"""Canonical queue-member encoding for a deadline entry."""
return json.dumps(entry, sort_keys=True)
def _decode(member: Any) -> Optional[Dict[str, Any]]:
if isinstance(member, bytes):
member = member.decode("utf-8")
if not isinstance(member, str):
return None
try:
entry = json.loads(member)
except ValueError:
return None
return entry if isinstance(entry, dict) else None
def _deadline_ms(iso: Optional[str]) -> Optional[int]:
"""Epoch milliseconds for an ISO-8601 deadline, ``None`` when absent
or unparseable. Queue entries carry this integer (never the ISO
string) as their revalidation token."""
if not iso:
return None
try:
return int(datetime.fromisoformat(iso).timestamp() * 1000)
except ValueError:
return None
async def sync_deadline(store: GameStore, state: GameState) -> None:
"""Enqueue the deadline the current state carries, if any.
Called after every mutation that can set a deadline (plays, acks, game
start) and as a backstop when a client connects. Enqueueing is
idempotent: an identical entry is already queued with the same due
time, so re-adding it changes nothing.
"""
entry: Optional[Dict[str, Any]] = None
due_ms: Optional[int] = None
if state.phase == PHASE_PLAYING and state.turn_deadline:
due_ms = _deadline_ms(state.turn_deadline)
entry = {
"game_id": state.id,
"kind": KIND_TURN,
"hand": state.hand_number,
"turn": state.turn,
"deadline": due_ms,
}
elif state.phase == PHASE_HAND_END and state.hand_end_deadline:
due_ms = _deadline_ms(state.hand_end_deadline)
entry = {
"game_id": state.id,
"kind": KIND_HAND_END,
"hand": state.hand_number,
"deadline": due_ms,
}
if entry is None or due_ms is None:
if entry is not None:
log.warning("game %s: unparseable deadline", state.id)
return
ensure_consumer(store)
# The score derives from the same value carried in the member, so the
# two can never disagree.
await store.add_deadline(encode(entry), due_ms / 1000)
wake = _wake_events.get(asyncio.get_running_loop())
if wake is not None:
wake.set()
async def finalize_mutation(store: GameStore, state: GameState) -> None:
"""Persist a successful mutation, notify subscribers and enqueue the
next deadline.
Callers must hold the per-game lock. Handles the terminal transition:
the match result is written to Postgres once (guarded by
``stats_saved``).
"""
if state.phase == PHASE_FINISHED:
await save_match_result(state)
log.info(
"game %s finished: team %s wins %d-%d",
state.id,
"A" if state.winner == 0 else "B",
state.scores[0],
state.scores[1],
)
await store.save(state)
await store.publish(state.id)
await sync_deadline(store, state)
async def process_due(store: GameStore, member: Any) -> None:
"""Fire a single due deadline entry.
Revalidates the entry against the live state under the per-game lock;
stale or foreign entries are discarded without effect. The entry is
removed from the queue once handled (including "nothing to do"); if
handling fails (e.g. the lock cannot be acquired), the entry is left
in the queue so another consumer retries it.
"""
entry = _decode(member)
if entry is None:
log.warning("deadline consumer: dropping malformed entry %r", member)
await store.remove_deadline(member)
return
game_id = entry.get("game_id")
kind = entry.get("kind")
if not isinstance(game_id, str):
await store.remove_deadline(member)
return
async with store.lock(game_id):
state = await store.load(game_id)
if state is not None:
if kind == KIND_TURN:
await _fire_turn(store, state, entry)
elif kind == KIND_HAND_END:
await _fire_hand_end(store, state, entry)
await store.remove_deadline(member)
async def _fire_turn(store: GameStore, state: GameState, entry: Dict[str, Any]) -> None:
if (
state.phase != PHASE_PLAYING
or state.hand_number != entry.get("hand")
or state.turn != entry.get("turn")
or _deadline_ms(state.turn_deadline) != entry.get("deadline")
):
return
seat = state.turn
try:
engine.auto_play(state)
except GameError:
return
log.info(
"game %s: auto-played for %s (turn timeout, hand %d)",
state.id,
state.players[seat].sub if seat < len(state.players) else "?",
entry.get("hand"),
)
await finalize_mutation(store, state)
async def _fire_hand_end(store: GameStore, state: GameState, entry: Dict[str, Any]) -> None:
if (
state.phase != PHASE_HAND_END
or state.hand_number != entry.get("hand")
or _deadline_ms(state.hand_end_deadline) != entry.get("deadline")
):
return
for player in state.players:
engine.acknowledge_hand(state, player.sub)
log.info(
"game %s: hand %d auto-advanced after the acknowledgement timeout",
state.id,
entry.get("hand"),
)
await finalize_mutation(store, state)
# --- consumer lifecycle -------------------------------------------------------
def ensure_consumer(
store: GameStore, loop: Optional[asyncio.AbstractEventLoop] = None
) -> None:
"""Start the deadline consumer on the given (or running) loop if not
yet running.
Called lazily whenever a deadline is enqueued (the ASGI test transport
never fires the lifespan hooks, so the mixin's ``setup`` alone is not
enough) and on application startup. The explicit ``loop`` matters at
startup: under RSGI granian calls ``setup`` before the loop runs, so
``asyncio.get_running_loop()`` would fail there.
"""
if loop is None:
loop = asyncio.get_running_loop()
for old in list(_consumers):
if old.is_closed():
_consumers.pop(old, None)
_wake_events.pop(old, None)
task = _consumers.get(loop)
if task is None or task.done():
_wake_events[loop] = asyncio.Event()
_consumers[loop] = loop.create_task(_run(store, loop))
log.debug("deadline consumer started")
def stop_consumer(loop: asyncio.AbstractEventLoop) -> None:
task = _consumers.pop(loop, None)
_wake_events.pop(loop, None)
if task is not None:
task.cancel()
async def _run(store: GameStore, loop: asyncio.AbstractEventLoop) -> None:
wake = _wake_events[loop]
heartbeat = settings.deadline_heartbeat_ms / 1000
while True:
# Clear before polling so an enqueue racing the poll re-wakes us.
wake.clear()
delay = heartbeat
try:
for member in await store.due_deadlines(time.time()):
try:
await process_due(store, member)
except asyncio.CancelledError:
raise
except Exception:
# Left in the queue; retried on the next pass.
log.exception("deadline consumer: failed to process %r", member)
next_due = await store.next_deadline()
if next_due is not None:
delay = max(0.0, min(heartbeat, next_due - time.time()))
except asyncio.CancelledError:
raise
except Exception:
log.exception("deadline consumer: poll failed; retrying")
try:
await asyncio.wait_for(wake.wait(), timeout=delay)
except asyncio.TimeoutError:
pass
class DeadlineSchedulerMixin(KayaMixin):
"""Run the deadline consumer for the whole app lifetime.
Every worker (and every pod) runs the same consumer; coordination
happens exclusively through the shared deadline queue and the per-game
locks, so any worker may fire any game's deadline.
"""
def __init__(self, store: GameStore) -> None:
self._store = store
def apply(self, app: KayaApp) -> None:
pass
def setup(self, loop: asyncio.AbstractEventLoop) -> None:
ensure_consumer(self._store, loop)
def shutdown(self, loop: asyncio.AbstractEventLoop) -> None:
stop_consumer(loop)
-1
View File
@@ -1 +0,0 @@
"""Scopone scientifico domain package."""
-42
View File
@@ -1,42 +0,0 @@
"""Typed errors raised by the scopone engine.
Route/WebSocket handlers translate these into 4xx responses or ``error``
WebSocket messages; the engine itself stays transport-agnostic.
"""
from __future__ import annotations
class GameError(Exception):
"""Base class for every rule/validation failure."""
class IllegalMove(GameError):
"""The requested play violates the rules of scopone scientifico."""
class NotYourTurn(GameError):
"""A player attempted to play out of turn."""
class CardNotInHand(GameError):
"""The played card is not held by the player."""
class GameNotStarted(GameError):
"""An action was attempted before the game left the lobby."""
class GameFinished(GameError):
"""An action was attempted after the match ended."""
class LobbyFull(GameError):
"""A game already has four players."""
class AlreadyJoined(GameError):
"""A player tried to join a game they are already seated in."""
class GameNotFound(GameError):
"""No live game exists for the given id or join code."""
-42
View File
@@ -1,42 +0,0 @@
"""Registry of the card games the platform can host.
Only *scopone scientifico* is implemented for now; adding a game means a
new entry here plus its engine. The registry is the single source of truth
for the ``game_type`` carried by every live game (:mod:`tavolo.game.state`)
and persisted on each finished match (:mod:`tavolo.models`), which is what
makes match statistics game-scoped.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, Optional
@dataclass(frozen=True)
class GameType:
"""Metadata describing one playable card game."""
id: str
name: str
description: str
SCOPONE_SCIENTIFICO = "scopone_scientifico"
GAME_TYPES: Dict[str, GameType] = {
SCOPONE_SCIENTIFICO: GameType(
id=SCOPONE_SCIENTIFICO,
name="Scopone scientifico",
description=(
"Four players in fixed partnerships, ten cards each and an empty "
"table. First team to the target score wins."
),
),
}
DEFAULT_GAME_TYPE = SCOPONE_SCIENTIFICO
def get_game_type(game_type_id: str) -> Optional[GameType]:
"""Return the registered game type with id ``game_type_id``, if any."""
return GAME_TYPES.get(game_type_id)
-56
View File
@@ -1,56 +0,0 @@
"""Tortoise ORM models: match statistics persisted in Postgres.
Live game state lives in Redis (see :mod:`tavolo.store`); only completed
matches are written here. The two tables answer the question "every match
a player took part in, with the final score":
* :class:`Match` — one row per finished match with both teams' scores.
* :class:`MatchPlayer` — one row per participant, linking an OIDC
``sub`` to a seat/team and whether they won.
"""
from __future__ import annotations
from tortoise import fields
from tortoise.models import Model
class Match(Model):
"""A completed match of one of the registered game types."""
id = fields.UUIDField(pk=True)
# Which card game was played (tavolo.games.GAME_TYPES); the default
# backfills matches recorded before game types existed.
game_type = fields.CharField(max_length=32, db_index=True, default="scopone_scientifico")
team_a_score = fields.SmallIntField()
team_b_score = fields.SmallIntField()
# "A" or "B".
winner_team = fields.CharField(max_length=1)
target_score = fields.SmallIntField()
hands_played = fields.SmallIntField()
started_at = fields.DatetimeField()
finished_at = fields.DatetimeField()
players: fields.ReverseRelation["MatchPlayer"]
class Meta:
table = "match"
ordering = ["-finished_at"]
class MatchPlayer(Model):
"""Participation of one user in one match."""
id = fields.UUIDField(pk=True)
match: fields.ForeignKeyRelation[Match] = fields.ForeignKeyField(
"models.Match", related_name="players", on_delete=fields.CASCADE
)
# OIDC subject of the player; no local users table.
user_sub = fields.CharField(max_length=255, db_index=True)
display_name = fields.CharField(max_length=200)
seat = fields.SmallIntField()
team = fields.CharField(max_length=1)
won = fields.BooleanField()
class Meta:
table = "match_player"
unique_together = (("match", "user_sub"),)
-1
View File
@@ -1 +0,0 @@
"""HTTP route modules."""
-260
View File
@@ -1,260 +0,0 @@
"""Game lobby endpoints.
A game starts as a lobby: the creator is seated first and shares the
six-character ``join_code``. When the fourth player joins, the engine deals
the first hand and the match begins. Live play then happens over the
``/ws/games/{id}`` websocket (see :mod:`tavolo.ws`); these endpoints cover
creation, joining and snapshotting state.
"""
from __future__ import annotations
import secrets
import uuid
from logging import getLogger
from typing import Any, Dict, Optional
from kaya.core import HttpContext
from kaya.openapi import operation
from .. import auth, deadlines
from ..app import app, game_store, oidc_mixin
from ..auth import require_auth
from ..config import settings
from ..game import engine
from ..game.errors import GameError
from ..game.state import DEFAULT_TARGET_SCORE, PHASE_LOBBY, GameState
from ..games import GAME_TYPES, get_game_type
from ..http import JsonRequestError, read_json, read_json_optional, send_error, send_json
log = getLogger(__name__)
# Join codes avoid characters that are easy to confuse when read aloud.
_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
_CODE_LENGTH = 6
_MAX_CODE_ATTEMPTS = 20
def _now_code() -> str:
return "".join(secrets.choice(_CODE_ALPHABET) for _ in range(_CODE_LENGTH))
async def _unique_code() -> str:
for _ in range(_MAX_CODE_ATTEMPTS):
code = _now_code()
if await game_store.find_by_code(code) is None:
return code
raise RuntimeError("could not allocate a unique join code")
def _lobby_payload(state: GameState) -> Dict[str, Any]:
return {
"id": state.id,
"join_code": state.join_code,
"game_type": state.game_type,
"target_score": state.target_score,
"napola": state.napola,
"phase": state.phase,
"players": [
{"sub": p.sub, "name": p.name, "seat": p.seat, "team": "A" if p.seat % 2 == 0 else "B"}
for p in state.players
],
"seats_open": 4 - len(state.players),
}
@app.GET("/api/game-types")
@operation(summary="List available games",
description="Every card game the platform can host, for the "
"match-creation dropdown.",
tags=["games"],
responses={200: {"description": "The available game types"}})
async def list_game_types(ctx: HttpContext) -> None:
await send_json(ctx, 200, {
"results": [
{"id": g.id, "name": g.name, "description": g.description}
for g in GAME_TYPES.values()
]
})
@app.POST("/api/games")
@operation(summary="Create a game",
description="Creates a lobby game and seats the caller in seat 0. "
"Share the returned join_code with three other players.",
tags=["games"],
request_body={
"required": False,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"game_type": {
"type": "string",
"default": "scopone_scientifico",
"description": "One of the ids from GET /api/game-types",
},
"target_score": {"type": "integer", "minimum": 1, "maximum": 100},
"napola": {
"type": "boolean",
"default": True,
"description": "Score the napola rule; a full "
"denari sweep wins the match",
},
},
}
}
},
},
responses={
201: {"description": "The created lobby"},
400: {"description": "Invalid game_type, target_score or body"},
401: {"description": "Authentication required"},
})
@require_auth
async def create_game(ctx: HttpContext) -> None:
body: dict = {}
try:
body = await read_json_optional(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
target_score: Any = body.get("target_score", DEFAULT_TARGET_SCORE)
if isinstance(target_score, bool) or not isinstance(target_score, int):
await send_error(ctx, 400, "target_score must be an integer")
return
game_type: Any = body.get("game_type", "scopone_scientifico")
if not isinstance(game_type, str) or get_game_type(game_type) is None:
await send_error(ctx, 400, f"unknown game_type: {game_type!r}")
return
napola: Any = body.get("napola", True)
if not isinstance(napola, bool):
await send_error(ctx, 400, "napola must be a boolean")
return
user = oidc_mixin.get_user(ctx)
assert user is not None # enforced by @require_auth
game_id = str(uuid.uuid4())
join_code = await _unique_code()
try:
state = engine.create_game(
game_id=game_id,
join_code=join_code,
creator_sub=user.sub,
creator_name=auth.display_name(user),
target_score=target_score,
hand_ack_timeout=settings.hand_ack_timeout_seconds,
turn_timeout=settings.turn_timeout_seconds,
game_type=game_type,
napola=napola,
)
except GameError as exc:
await send_error(ctx, 400, str(exc))
return
await game_store.save(state)
log.info(
"game %s created by %s (%s, target score %d)",
game_id,
user.sub,
game_type,
target_score,
)
await send_json(ctx, 201, _lobby_payload(state))
@app.POST("/api/games/join")
@operation(summary="Join a game by code",
description="Seats the caller in the next free chair. Joining as the "
"fourth player starts the match.",
tags=["games"],
request_body={
"required": True,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {"code": {"type": "string"}},
"required": ["code"],
}
}
},
},
responses={
200: {"description": "Seated; game state (may be playing)"},
400: {"description": "Missing code"},
401: {"description": "Authentication required"},
404: {"description": "Unknown join code"},
409: {"description": "Already joined or lobby full"},
})
@require_auth
async def join_game(ctx: HttpContext) -> None:
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
code = body.get("code")
if not isinstance(code, str) or not code:
await send_error(ctx, 400, "code is required")
return
user = oidc_mixin.get_user(ctx)
assert user is not None
existing = await game_store.find_by_code(code)
if existing is None:
log.debug("join rejected for %s: unknown code %r", user.sub, code)
await send_error(ctx, 404, "unknown join code")
return
async with game_store.lock(existing.id):
state = await game_store.load(existing.id)
if state is None:
await send_error(ctx, 404, "unknown join code")
return
try:
engine.join_game(state, user.sub, auth.display_name(user))
except GameError as exc:
log.debug("join rejected for %s in game %s: %s", user.sub, state.id, exc)
await send_error(ctx, 409, str(exc))
return
await game_store.save(state)
await game_store.publish(state.id)
# When the fourth join started the match, the first turn deadline
# was armed; queue it so it fires even if nobody ever connects.
await deadlines.sync_deadline(game_store, state)
seat = next(p.seat for p in state.players if p.sub == user.sub)
if state.phase == PHASE_LOBBY:
log.info("%s joined game %s (seat %d, %d/4 players)", user.sub, state.id, seat, len(state.players))
else:
log.info("%s joined game %s (seat %d); match started", user.sub, state.id, seat)
await send_json(ctx, 200, engine.state_for_player(state, user.sub))
return
await send_json(ctx, 200, _lobby_payload(state))
@app.GET("/api/games/${game_id}")
@operation(summary="Get a game snapshot",
description="Only seated players may read a game; other players' "
"hands are hidden.",
tags=["games"],
responses={
200: {"description": "The personalized game state"},
401: {"description": "Authentication required"},
403: {"description": "Not a player in this game"},
404: {"description": "Game not found"},
})
@require_auth
async def get_game(ctx: HttpContext, game_id: str) -> None:
state = await game_store.load(game_id)
if state is None:
await send_error(ctx, 404, "game not found")
return
user = oidc_mixin.get_user(ctx)
assert user is not None
if not state.seated(user.sub):
await send_error(ctx, 403, "forbidden")
return
await send_json(ctx, 200, engine.state_for_player(state, user.sub))
-19
View File
@@ -1,19 +0,0 @@
"""Liveness probe."""
from __future__ import annotations
from kaya.core import HttpContext
from kaya.openapi import operation
from ..app import app
@app.GET("/api/health")
@operation(summary="Health check",
tags=["health"],
responses={200: {"description": "The service is up"}})
async def health(ctx: HttpContext) -> None:
await ctx.send_bytes(
200,
b'{"status":"ok"}',
{"content-type": ("application/json",)},
)
-25
View File
@@ -1,25 +0,0 @@
"""Whoami endpoint: lets the single-page app detect the login state."""
from __future__ import annotations
from kaya.core import HttpContext
from kaya.openapi import operation
from ..app import app, oidc_mixin
from ..auth import display_name, require_auth
from ..http import send_json
@app.GET("/api/me")
@operation(summary="Current user",
description="Returns the authenticated user's identity from the "
"session; 401 when not logged in.",
tags=["auth"],
responses={
200: {"description": "The current user"},
401: {"description": "Not logged in"},
})
@require_auth
async def me(ctx: HttpContext) -> None:
user = oidc_mixin.get_user(ctx)
assert user is not None # enforced by @require_auth
await send_json(ctx, 200, {"sub": user.sub, "name": display_name(user)})
-151
View File
@@ -1,151 +0,0 @@
"""Player statistics endpoints, served from Postgres.
Every finished match is persisted by :func:`tavolo.stats.save_match_result`.
These endpoints expose a player's own match history and a global
leaderboard aggregated from the same two tables.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
from kaya.core import HttpContext
from kaya.openapi import operation
from ..app import app, oidc_mixin
from ..auth import require_auth
from ..games import get_game_type
from ..http import extract_query_params, send_error, send_json
from ..models import Match, MatchPlayer
from ..openapi import PAGINATION_PARAMETERS
from ..pagination import CursorDecodeError, paginate, parse_cursor_params
GAME_TYPE_PARAMETER: Dict[str, Any] = {
"name": "game_type",
"in": "query",
"required": False,
"schema": {"type": "string"},
"description": "Only count matches of this game (id from GET /api/game-types).",
}
def _parse_game_type(query_string: str) -> Tuple[Optional[str], Optional[str]]:
"""Parse the ``game_type`` query parameter.
Returns ``(value, error)``: ``(None, None)`` when absent, ``(id, None)``
when valid, ``(None, message)`` when it names no registered game."""
values = extract_query_params(query_string).get("game_type")
if not values:
return None, None
game_type = values[0]
if get_game_type(game_type) is None:
return None, f"unknown game_type: {game_type!r}"
return game_type, None
async def _serialize_match(match: Match, viewer: str) -> Dict[str, Any]:
participants = await MatchPlayer.filter(match_id=match.id).order_by("seat")
return {
"id": str(match.id),
"game_type": match.game_type,
"team_a_score": match.team_a_score,
"team_b_score": match.team_b_score,
"winner_team": match.winner_team,
"target_score": match.target_score,
"hands_played": match.hands_played,
"started_at": match.started_at.isoformat(),
"finished_at": match.finished_at.isoformat(),
"you_won": any(p.user_sub == viewer and p.won for p in participants),
"players": [
{
"user_sub": p.user_sub,
"display_name": p.display_name,
"seat": p.seat,
"team": p.team,
"won": p.won,
}
for p in participants
],
}
@app.GET("/api/me/matches")
@operation(summary="List my matches",
description="Cursor-paginated history of finished matches the caller "
"played, newest first, with the final score.",
tags=["stats"],
parameters=[*PAGINATION_PARAMETERS, GAME_TYPE_PARAMETER],
responses={
200: {"description": "A page of matches"},
400: {"description": "Invalid pagination cursor or game_type"},
401: {"description": "Authentication required"},
})
@require_auth
async def my_matches(ctx: HttpContext) -> None:
try:
cursor = parse_cursor_params(ctx.query_string)
except CursorDecodeError as exc:
await send_error(ctx, 400, str(exc))
return
game_type, error = _parse_game_type(ctx.query_string)
if error is not None:
await send_error(ctx, 400, error)
return
user = oidc_mixin.get_user(ctx)
assert user is not None
queryset = Match.filter(players__user_sub=user.sub).distinct()
if game_type is not None:
queryset = queryset.filter(game_type=game_type)
matches, next_cursor = await paginate(
queryset, [("finished_at", "DESC"), ("id", "ASC")], cursor
)
results = [await _serialize_match(m, user.sub) for m in matches]
await send_json(ctx, 200, {"results": results, "next_cursor": next_cursor})
@app.GET("/api/leaderboard")
@operation(summary="Global leaderboard",
description="Aggregated wins, matches played and team points for every "
"player with at least one finished match. Sorted by wins.",
tags=["stats"],
parameters=[GAME_TYPE_PARAMETER],
responses={
200: {"description": "The leaderboard"},
400: {"description": "Unknown game_type"},
})
async def leaderboard(ctx: HttpContext) -> None:
game_type, error = _parse_game_type(ctx.query_string)
if error is not None:
await send_error(ctx, 400, error)
return
queryset = MatchPlayer.all()
if game_type is not None:
queryset = queryset.filter(match__game_type=game_type)
rows = await queryset.prefetch_related("match")
aggregate: Dict[str, Dict[str, Any]] = {}
for row in rows:
entry = aggregate.setdefault(
row.user_sub,
{
"user_sub": row.user_sub,
"display_name": row.display_name,
"matches": 0,
"wins": 0,
"points": 0,
},
)
entry["matches"] += 1
entry["wins"] += 1 if row.won else 0
match = row.match
if match is not None:
entry["points"] += (
match.team_a_score if row.team == "A" else match.team_b_score
)
# Keep the most recent display name seen.
entry["display_name"] = row.display_name
ranking: List[Dict[str, Any]] = sorted(
aggregate.values(),
key=lambda e: (e["wins"], e["points"], -e["matches"]),
reverse=True,
)
await send_json(ctx, 200, {"results": ranking})
@@ -17,8 +17,8 @@ from pathlib import Path
from kaya.core import HttpContext from kaya.core import HttpContext
from ..app import app from .app import app
from ..config import settings from .config import settings
async def _send_shell(ctx: HttpContext) -> None: async def _send_shell(ctx: HttpContext) -> None:
-70
View File
@@ -1,70 +0,0 @@
"""Copy finished match results from Redis into Postgres.
Called once when a game reaches the finished phase (guarded by the
``stats_saved`` flag on the state). The write is transactional so a match
never appears with only some of its players.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from logging import getLogger
from typing import Optional
from tortoise.transactions import in_transaction
from .game.state import PHASE_FINISHED, TEAM_NAMES, GameState
log = getLogger(__name__)
def _parse_timestamp(value: Optional[str]) -> datetime:
if value:
try:
return datetime.fromisoformat(value)
except ValueError:
pass
return datetime.now(timezone.utc)
async def save_match_result(state: GameState) -> None:
"""Persist ``state`` to Postgres if it is finished and not yet saved."""
if state.stats_saved or state.phase != PHASE_FINISHED or state.winner is None:
return
from .models import Match, MatchPlayer
started_at = _parse_timestamp(state.created_at)
finished_at = _parse_timestamp(state.finished_at)
async with in_transaction():
match = await Match.create(
id=uuid.uuid4(),
game_type=state.game_type,
team_a_score=state.scores[0],
team_b_score=state.scores[1],
winner_team=TEAM_NAMES[state.winner],
target_score=state.target_score,
hands_played=state.hand_number,
started_at=started_at,
finished_at=finished_at,
)
for player in state.players:
await MatchPlayer.create(
id=uuid.uuid4(),
match=match,
user_sub=player.sub,
display_name=player.name,
seat=player.seat,
team=TEAM_NAMES[player.team],
won=player.team == state.winner,
)
state.stats_saved = True
log.info(
"match result persisted: game %s (%s), team %s won %d-%d over %d hands",
state.id,
state.game_type,
TEAM_NAMES[state.winner],
state.scores[0],
state.scores[1],
state.hand_number,
)
-222
View File
@@ -1,222 +0,0 @@
"""WebSocket endpoint for live play.
Clients connect to ``/ws/games/{game_id}`` using their session cookie (the
OIDC login stores the user in the session, which the session mixin loads
onto the websocket). Only seated players are accepted.
Protocol
--------
Server -> client messages are JSON objects with a ``type``:
* ``state`` — the personalized game view (own hand visible, others hidden).
* ``game_over`` — sent once when the match ends, with the final scores.
* ``error`` — a rejected action or malformed message.
Client -> server messages are JSON objects::
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
{"action": "play", "card": "07D"}
{"action": "ack"}
{"action": "state"}
``capture`` lists the table cards to take and must be a legal capture when
one exists (see :func:`tavolo.game.engine.legal_captures`); it is omitted
when the played card cannot capture. ``ack`` acknowledges the hand-end
scoring summary; the next hand is dealt when all four players have
acknowledged or the timeout fires.
Mutations run under the per-game lock; after a successful move the new
state is saved to Redis and a change signal is published. Every connected
websocket is subscribed to that signal and re-renders the state, so all
players see the move immediately (and consistently across workers).
Timeouts do not depend on anyone being connected: both the per-turn
auto-play and the hand-end auto-continue are driven by the absolute
deadlines persisted on the game state, via the shared deadline queue
drained by a consumer on every worker (see :mod:`tavolo.deadlines`). A
disconnected or idle player therefore cannot stall the match, and a
worker dying cannot either.
"""
from __future__ import annotations
import asyncio
import json
from contextlib import suppress
from logging import getLogger
from typing import Any, Awaitable, Callable, Dict
from kaya.core import WebSocket
from . import auth, deadlines
from .app import app, game_store
from .game import engine
from .game.errors import GameError
from .game.state import PHASE_FINISHED, GameState
log = getLogger(__name__)
Send = Callable[[Dict[str, Any]], Awaitable[None]]
def _error(message: str, code: str = "invalid") -> Dict[str, Any]:
return {"type": "error", "code": code, "message": message}
def _state_message(state: GameState, sub: str) -> Dict[str, Any]:
return {"type": "state", "game": engine.state_for_player(state, sub)}
@app.websocket("/ws/games/${game_id}")
async def game_socket(ws: WebSocket, game_id: str) -> None:
user = auth.get_ws_user(ws)
if user is None:
log.debug("websocket %s rejected: no authenticated user", game_id)
await ws.close(4401)
return
state = await game_store.load(game_id)
if state is None:
log.debug("websocket rejected: unknown game %s", game_id)
await ws.close(4404)
return
if not state.seated(user.sub):
log.debug("websocket %s rejected: %s is not seated", game_id, user.sub)
await ws.close(4403)
return
await ws.accept()
log.info("%s connected to game %s", user.sub, game_id)
send_lock = asyncio.Lock()
async def send(payload: Dict[str, Any]) -> None:
async with send_lock:
await ws.send_text(json.dumps(payload))
await send(_state_message(state, user.sub))
# Backstop: make sure the current phase's deadline is queued even if
# its entry was lost (e.g. the queue was flushed while the game lived
# on thanks to its sliding TTL).
await deadlines.sync_deadline(game_store, state)
async with game_store.subscribe(game_id) as events:
forward = asyncio.create_task(
_forward(events, game_id, user.sub, send)
)
try:
async for message in ws:
if message.kind == "close":
break
if message.kind != "text" or not isinstance(message.data, str):
await send(_error("expected a text frame with a JSON object"))
continue
await _handle_message(send, game_id, user.sub, message.data)
finally:
forward.cancel()
with suppress(asyncio.CancelledError):
await forward
log.debug("%s disconnected from game %s", user.sub, game_id)
async def _forward(
events,
game_id: str,
sub: str,
send: Send,
) -> None:
async for _ in events:
state = await game_store.load(game_id)
if state is None:
return
await send(_state_message(state, sub))
if state.phase == PHASE_FINISHED:
await send(
{
"type": "game_over",
"scores": {"A": state.scores[0], "B": state.scores[1]},
"winner": "A" if state.winner == 0 else "B",
}
)
return
async def _handle_message(send: Send, game_id: str, sub: str, raw: str) -> None:
try:
data = json.loads(raw)
except (ValueError, TypeError):
log.debug("game %s: malformed message from %s (not JSON)", game_id, sub)
await send(_error("invalid JSON"))
return
if not isinstance(data, dict):
log.debug("game %s: malformed message from %s (not an object)", game_id, sub)
await send(_error("message must be a JSON object"))
return
action = data.get("action")
if action == "play":
await _handle_play(send, game_id, sub, data)
elif action == "ack":
await _handle_ack(send, game_id, sub)
elif action in ("state", "sync"):
state = await game_store.load(game_id)
if state is not None:
await send(_state_message(state, sub))
else:
await send(_error(f"unknown action: {action!r}"))
# --- hand-end acknowledgement ------------------------------------------------
async def _handle_ack(send: Send, game_id: str, sub: str) -> None:
async with game_store.lock(game_id):
state = await game_store.load(game_id)
if state is None:
await send(_error("game not found", code="not_found"))
return
try:
engine.acknowledge_hand(state, sub)
except GameError as exc:
await send(_error(str(exc), code="illegal_move"))
return
log.debug("game %s: %s acknowledged hand %d", game_id, sub, state.hand_number)
await game_store.save(state)
await game_store.publish(game_id)
# The fourth ack deals the next hand, which arms a new turn
# deadline; earlier acks change nothing and this is a no-op.
await deadlines.sync_deadline(game_store, state)
async def _handle_play(
send: Send, game_id: str, sub: str, data: Dict[str, Any]
) -> None:
card = data.get("card")
capture = data.get("capture")
if not isinstance(card, str):
await send(_error("'card' must be a card code string"))
return
if capture is not None and (
not isinstance(capture, list)
or any(not isinstance(item, str) for item in capture)
):
await send(_error("'capture' must be a list of card codes"))
return
async with game_store.lock(game_id):
state = await game_store.load(game_id)
if state is None:
await send(_error("game not found", code="not_found"))
return
try:
engine.play(state, sub, card, capture)
except GameError as exc:
log.debug("game %s: illegal move by %s: %s", game_id, sub, exc)
await send(_error(str(exc), code="illegal_move"))
return
except ValueError:
log.debug("game %s: invalid card code from %s: %r", game_id, sub, card)
await send(_error("invalid card code", code="illegal_move"))
return
log.debug("game %s: %s played %s (capture: %s)", game_id, sub, card, capture or "-")
await deadlines.finalize_mutation(game_store, state)
+3 -2
View File
@@ -1,4 +1,5 @@
"""Test helpers package.""" """Test helpers package for the tavolo-app integration suite."""
from .asynctest import async_test
from .oidc import make_user, oidc_user, ws_users from .oidc import make_user, oidc_user, ws_users
__all__ = ["make_user", "oidc_user", "ws_users"] __all__ = ["async_test", "make_user", "oidc_user", "ws_users"]
+35
View File
@@ -0,0 +1,35 @@
"""An ``async_test`` that also closes the app's Tortoise context.
``pwo.async_test`` runs every test in a fresh event loop (``asyncio.Runner``).
The app's :class:`~tavolo.platform.tortoise_mixin.TortoiseMixin` builds one
``TortoiseContext`` per loop, so without an explicit close each test orphans
an aiosqlite connection whose non-daemon worker thread keeps the interpreter
alive after the suite reports "OK".
"""
from __future__ import annotations
import asyncio
from functools import wraps
from typing import Any, Callable, Coroutine
from tavolo.app import scheduler, tortoise_mixin
def async_test(coro: Callable[..., Coroutine[Any, Any, None]]) -> Callable[..., None]:
"""Like ``pwo.async_test``, but close the Tortoise context and stop the
deadline consumer afterwards."""
@wraps(coro)
def wrapper(*args: Any, **kwargs: Any) -> None:
async def run() -> None:
loop = asyncio.get_running_loop()
try:
await coro(*args, **kwargs)
finally:
scheduler.stop_consumer(loop)
await tortoise_mixin.aclose()
with asyncio.Runner() as runner:
runner.run(run())
return wrapper
+4 -5
View File
@@ -1,8 +1,8 @@
"""Helpers for faking the OIDC authenticated user during tests. """Helpers for faking the OIDC authenticated user during tests.
HTTP handlers funnel through ``oidc_mixin.get_user``; websocket handlers HTTP handlers funnel through ``oidc_mixin.get_user``; websocket handlers
through :func:`tavolo.auth.get_ws_user`. Patching those two entry points through :func:`tavolo.platform.auth.get_ws_user`. Patching those two entry
lets route and websocket tests run entirely in-process with no IdP. points lets route and websocket tests run entirely in-process with no IdP.
""" """
from __future__ import annotations from __future__ import annotations
@@ -13,10 +13,9 @@ from typing import Iterator, Optional, Sequence
from kaya.oidc import OIDCUser from kaya.oidc import OIDCUser
# Import the app first: it pulls in the route modules, which import # Import the app first: it pulls in the route modules, which import
# ``tavolo.auth`` themselves. Importing ``auth`` before ``app`` would hit a # ``tavolo.platform.auth`` themselves.
# partially initialized module (same constraint as reimpasto).
from tavolo.app import oidc_mixin from tavolo.app import oidc_mixin
from tavolo import auth from tavolo.platform import auth
def make_user(sub: str, name: Optional[str] = None) -> OIDCUser: def make_user(sub: str, name: Optional[str] = None) -> OIDCUser:
+84
View File
@@ -81,5 +81,89 @@ class DatabaseUrlTests(unittest.TestCase):
) )
class CorsSettingsTests(unittest.TestCase):
def test_cors_disabled_by_default(self):
settings = _settings({})
self.assertIsNone(settings.cors_allow_origins)
self.assertIsNone(settings.cors_allow_origin_regex)
self.assertIsNone(settings.cors_allow_methods)
self.assertIsNone(settings.cors_allow_headers)
self.assertFalse(settings.cors_allow_credentials)
self.assertIsNone(settings.cors_expose_headers)
self.assertEqual(600, settings.cors_max_age)
def test_allow_origins_parses_comma_separated_list(self):
settings = _settings({
"CORS_ALLOW_ORIGINS": "https://a.example, https://b.example ,,https://c.example",
})
self.assertEqual(
("https://a.example", "https://b.example", "https://c.example"),
settings.cors_allow_origins,
)
def test_allow_origins_star_is_passed_through(self):
settings = _settings({"CORS_ALLOW_ORIGINS": "*"})
self.assertEqual(("*",), settings.cors_allow_origins)
def test_allow_origin_regex_is_passed_through(self):
settings = _settings({"CORS_ALLOW_ORIGIN_REGEX": r"https://.*\.example\.com"})
self.assertEqual(r"https://.*\.example\.com", settings.cors_allow_origin_regex)
def test_allow_methods_and_headers_parse_as_lists(self):
settings = _settings({
"CORS_ALLOW_METHODS": "GET,POST",
"CORS_ALLOW_HEADERS": "Authorization, X-Custom-Header",
"CORS_EXPOSE_HEADERS": "X-Total-Count",
})
self.assertEqual(("GET", "POST"), settings.cors_allow_methods)
self.assertEqual(("Authorization", "X-Custom-Header"), settings.cors_allow_headers)
self.assertEqual(("X-Total-Count",), settings.cors_expose_headers)
def test_allow_credentials_parses_boolean(self):
for value in ("1", "true", "TRUE", "yes", "on"):
self.assertTrue(_settings({"CORS_ALLOW_CREDENTIALS": value}).cors_allow_credentials)
for value in ("0", "false", "no", "off", "anything-else"):
self.assertFalse(_settings({"CORS_ALLOW_CREDENTIALS": value}).cors_allow_credentials)
def test_max_age_parses_int(self):
settings = _settings({"CORS_MAX_AGE": "3600"})
self.assertEqual(3600, settings.cors_max_age)
class OTelSettingsTests(unittest.TestCase):
def test_otel_disabled_by_default(self):
settings = _settings({})
self.assertFalse(settings.otel_enabled)
self.assertEqual("tavolo", settings.otel_service_name)
self.assertIsNone(settings.otel_exporter_endpoint)
self.assertIsNone(settings.otel_exporter_headers)
def test_otel_enabled_parses_boolean(self):
for value in ("1", "true", "TRUE", "yes", "on"):
self.assertTrue(_settings({"OTEL_ENABLED": value}).otel_enabled)
for value in ("0", "false", "no", "off", "anything-else"):
self.assertFalse(_settings({"OTEL_ENABLED": value}).otel_enabled)
def test_otel_settings_are_passed_through(self):
settings = _settings({
"OTEL_SERVICE_NAME": "cards",
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector:4318",
"OTEL_EXPORTER_OTLP_HEADERS": "Authorization=Bearer t, X-Tenant=one",
})
self.assertEqual("cards", settings.otel_service_name)
self.assertEqual("http://collector:4318", settings.otel_exporter_endpoint)
self.assertEqual(
("Authorization=Bearer t", "X-Tenant=one"),
settings.otel_exporter_headers,
)
def test_otel_excluded_paths_defaults_to_health_endpoint(self):
self.assertEqual(("/api/health",), _settings({}).otel_excluded_paths)
def test_otel_excluded_paths_parses_comma_separated_list(self):
settings = _settings({"OTEL_EXCLUDED_PATHS": "/api/health, /metrics"})
self.assertEqual(("/api/health", "/metrics"), settings.otel_excluded_paths)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+129
View File
@@ -0,0 +1,129 @@
"""Integration tests for the CORS configuration in :mod:`tavolo.app`.
The mixin under test is kaya-cors' :class:`~kaya.cors.CorsMixin`; these
tests only verify that :func:`tavolo.app.cors_mixin_from_settings` maps the
environment-driven :class:`~tavolo.config.Settings` onto it correctly. A
minimal ``KayaApp`` is used instead of the global ``app`` so the tests do
not depend on the environment the suite was imported with.
"""
from __future__ import annotations
import os
import unittest
from unittest.mock import patch
from httpx import ASGITransport, AsyncClient
from kaya.core import HttpContext, KayaApp
from tavolo.app import cors_mixin_from_settings
from tavolo.config import Settings
from tests.helpers import async_test
ORIGIN = "https://cards.example"
def _settings(env: dict) -> Settings:
with patch.dict(os.environ, env, clear=True):
return Settings.from_env()
def _app(settings: Settings) -> KayaApp:
mixin = cors_mixin_from_settings(settings)
assert mixin is not None
app = KayaApp(mixins=[mixin])
@app.GET("/api/health")
async def health(ctx: HttpContext) -> None:
await ctx.send_str(200, "ok")
return app
class CorsMixinFromSettingsTests(unittest.TestCase):
def test_disabled_when_unconfigured(self):
self.assertIsNone(cors_mixin_from_settings(_settings({})))
def test_enabled_by_allow_origins(self):
self.assertIsNotNone(cors_mixin_from_settings(
_settings({"CORS_ALLOW_ORIGINS": ORIGIN})))
def test_enabled_by_allow_origin_regex_alone(self):
self.assertIsNotNone(cors_mixin_from_settings(
_settings({"CORS_ALLOW_ORIGIN_REGEX": r"https://.*\.example\.com"})))
class CorsBehaviorTests(unittest.TestCase):
@async_test
async def test_request_without_origin_is_untouched(self) -> None:
app = _app(_settings({"CORS_ALLOW_ORIGINS": ORIGIN}))
async with AsyncClient(transport=ASGITransport(app=app),
base_url="http://127.0.0.1") as client:
response = await client.get("/api/health")
self.assertEqual(200, response.status_code)
self.assertNotIn("access-control-allow-origin", response.headers)
@async_test
async def test_simple_request_with_allowed_origin(self) -> None:
app = _app(_settings({"CORS_ALLOW_ORIGINS": ORIGIN}))
async with AsyncClient(transport=ASGITransport(app=app),
base_url="http://127.0.0.1") as client:
response = await client.get("/api/health", headers={"Origin": ORIGIN})
self.assertEqual(200, response.status_code)
self.assertEqual(ORIGIN, response.headers["access-control-allow-origin"])
@async_test
async def test_simple_request_with_disallowed_origin(self) -> None:
app = _app(_settings({"CORS_ALLOW_ORIGINS": ORIGIN}))
async with AsyncClient(transport=ASGITransport(app=app),
base_url="http://127.0.0.1") as client:
response = await client.get(
"/api/health", headers={"Origin": "https://mallory.example"})
self.assertEqual(200, response.status_code)
self.assertNotIn("access-control-allow-origin", response.headers)
@async_test
async def test_preflight_allowed(self) -> None:
app = _app(_settings({
"CORS_ALLOW_ORIGINS": ORIGIN,
"CORS_ALLOW_METHODS": "GET,POST",
"CORS_MAX_AGE": "3600",
}))
async with AsyncClient(transport=ASGITransport(app=app),
base_url="http://127.0.0.1") as client:
response = await client.options("/api/health", headers={
"Origin": ORIGIN,
"Access-Control-Request-Method": "POST",
})
self.assertEqual(200, response.status_code)
self.assertEqual(ORIGIN, response.headers["access-control-allow-origin"])
self.assertEqual("GET, POST", response.headers["access-control-allow-methods"])
self.assertEqual("3600", response.headers["access-control-max-age"])
@async_test
async def test_preflight_disallowed_origin(self) -> None:
app = _app(_settings({"CORS_ALLOW_ORIGINS": ORIGIN}))
async with AsyncClient(transport=ASGITransport(app=app),
base_url="http://127.0.0.1") as client:
response = await client.options("/api/health", headers={
"Origin": "https://mallory.example",
"Access-Control-Request-Method": "GET",
})
self.assertEqual(400, response.status_code)
self.assertIn("Disallowed CORS", response.text)
@async_test
async def test_credentials_echo_origin_and_set_flag(self) -> None:
app = _app(_settings({
"CORS_ALLOW_ORIGINS": "*",
"CORS_ALLOW_CREDENTIALS": "true",
}))
async with AsyncClient(transport=ASGITransport(app=app),
base_url="http://127.0.0.1") as client:
response = await client.get("/api/health", headers={"Origin": ORIGIN})
self.assertEqual(200, response.status_code)
self.assertEqual(ORIGIN, response.headers["access-control-allow-origin"])
self.assertEqual("true", response.headers["access-control-allow-credentials"])
if __name__ == "__main__":
unittest.main()
+105 -90
View File
@@ -1,8 +1,9 @@
"""Deadline-queue timeout tests. """Deadline-queue timeout tests (scopone game, full platform stack).
Timeouts must be driven by the persisted deadlines and the shared queue, Timeouts must be driven by the persisted deadlines and the shared queue,
not by connected sockets: these tests seed games, queue their deadlines not by connected sockets: these tests seed sessions, queue their
and let the background consumer fire them without a single websocket. deadlines and let the background consumer fire them without a single
websocket.
""" """
from __future__ import annotations from __future__ import annotations
@@ -11,48 +12,73 @@ import unittest
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
from pwo import async_test from tavolo.app import game_store, platform, scheduler
from tavolo.platform import GameSession, Seat
from tavolo import deadlines from tavolo.platform.deadlines import encode
from tavolo.app import game_store from tavolo.scopone.state import PlayerState, ScoponeState
from tavolo.game import engine from tests.helpers import async_test
from tavolo.game.state import GameState, PlayerState
PLAYERS = ("alice", "bob", "carol", "dave") PLAYERS = ("alice", "bob", "carol", "dave")
def _ms(iso: str) -> int: def _started_session(
"""Epoch milliseconds for an ISO-8601 timestamp (the queue-entry form).""" game_id: str,
return int(datetime.fromisoformat(iso).timestamp() * 1000) code: str,
turn_timeout: int = 3600,
hand_ack_timeout: int = 3600,
def _hand_end_state(game_id: str, deadline: str) -> GameState: ) -> GameSession:
"""A game paused on the hand-end summary, waiting for acks.""" engine = platform.registry.require("scopone_scientifico")
state = GameState( session = GameSession(
id=game_id, id=game_id,
game_type=engine.id,
join_code=code,
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="Alice")],
)
engine.create(session, {})
session.state.hand_ack_timeout = hand_ack_timeout
session.state.turn_timeout = turn_timeout
for name in PLAYERS[1:]:
engine.join(session, name, name.capitalize())
return session
def _hand_end_session(game_id: str, deadline: str) -> GameSession:
"""A session paused on the hand-end summary, waiting for acks."""
engine = platform.registry.require("scopone_scientifico")
session = GameSession(
id=game_id,
game_type=engine.id,
join_code="DLhend", join_code="DLhend",
creator_sub="alice", creator_sub="alice",
players=[
Seat(user_sub=name, display_name=name.capitalize(), team=team)
for name, team in zip(PLAYERS, ("A", "B", "A", "B"))
],
)
session.state = ScoponeState(
target_score=11, target_score=11,
phase="hand_end", phase="hand_end",
players=[
PlayerState(sub=name, name=name.capitalize(), seat=i)
for i, name in enumerate(PLAYERS)
],
hand_ack_timeout=3600,
# Long turn timeout: the next hand's auto-play must not interfere # Long turn timeout: the next hand's auto-play must not interfere
# with later tests sharing this store. # with later tests sharing this store.
turn_timeout=3600, turn_timeout=3600,
hand_end_deadline=deadline,
) )
state.players = [ return session
PlayerState(sub=name, name=name.capitalize(), seat=i)
for i, name in enumerate(PLAYERS)
]
state.hand_end_deadline = deadline
return state
async def _wait_for(predicate, timeout: float = 5.0) -> Optional[GameState]: async def _wait_for(predicate, timeout: float = 5.0) -> Optional[GameSession]:
"""Poll the store until ``predicate`` holds for the loaded state.""" """Poll the store until ``predicate`` holds for the loaded session."""
deadline = asyncio.get_running_loop().time() + timeout deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline: while asyncio.get_running_loop().time() < deadline:
state = await predicate() session = await predicate()
if state is not None: if session is not None:
return state return session
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
return None return None
@@ -60,36 +86,33 @@ async def _wait_for(predicate, timeout: float = 5.0) -> Optional[GameState]:
class ConnectionIndependenceTest(unittest.TestCase): class ConnectionIndependenceTest(unittest.TestCase):
@async_test @async_test
async def test_turn_timeout_fires_with_no_connections(self) -> None: async def test_turn_timeout_fires_with_no_connections(self) -> None:
state = engine.create_game( session = _started_session("dl-turn-1", "DLT001", turn_timeout=1)
"dl-turn-1", "DLT001", "alice", "Alice", assert session.state.turn_deadline is not None
target_score=11, turn_timeout=1, await game_store.save(session)
) await scheduler.sync_deadline(session)
for name in PLAYERS[1:]:
engine.join_game(state, name, name.capitalize())
assert state.turn_deadline is not None
await game_store.save(state)
await deadlines.sync_deadline(game_store, state)
# Nobody ever connects: the consumer must still auto-play for Bob # Nobody ever connects: the consumer must still auto-play for Bob
# (seat 1, first to act). # (seat 1, first to act).
result = await _wait_for( result = await _wait_for(
lambda: _turn_is(state.id, 2), lambda: _turn_is(session.id, 2),
) )
self.assertIsNotNone(result, "turn deadline never fired") self.assertIsNotNone(result, "turn deadline never fired")
assert result is not None assert result is not None
self.assertEqual(1, result.last_move.seat if result.last_move else None) self.assertEqual(
1, result.state.last_move.seat if result.state.last_move else None
)
# Defuse the follow-on turn deadlines so this game cannot keep # Defuse the follow-on turn deadlines so this game cannot keep
# auto-playing while later tests run. # auto-playing while later tests run.
result.turn_timeout = 3600 result.state.turn_timeout = 3600
await game_store.save(result) await game_store.save(result)
@async_test @async_test
async def test_hand_end_timeout_fires_with_no_connections(self) -> None: async def test_hand_end_timeout_fires_with_no_connections(self) -> None:
deadline = (datetime.now(timezone.utc) + timedelta(seconds=1)).isoformat() deadline = (datetime.now(timezone.utc) + timedelta(seconds=1)).isoformat()
state = _hand_end_state("dl-handend-1", deadline) session = _hand_end_session("dl-handend-1", deadline)
await game_store.save(state) await game_store.save(session)
await deadlines.sync_deadline(game_store, state) await scheduler.sync_deadline(session)
# Nobody acks (nobody is even connected): the deadline must deal # Nobody acks (nobody is even connected): the deadline must deal
# the next hand. # the next hand.
@@ -98,92 +121,84 @@ class ConnectionIndependenceTest(unittest.TestCase):
) )
self.assertIsNotNone(result, "hand-end deadline never fired") self.assertIsNotNone(result, "hand-end deadline never fired")
assert result is not None assert result is not None
self.assertEqual(2, result.hand_number) self.assertEqual(2, result.state.hand_number)
self.assertEqual([], result.acked) self.assertEqual([], result.state.acked)
async def _turn_is(game_id: str, turn: int) -> Optional[GameState]: async def _turn_is(game_id: str, turn: int) -> Optional[GameSession]:
state = await game_store.load(game_id) session = await game_store.load(game_id)
return state if state is not None and state.turn == turn else None return session if session is not None and session.state.turn == turn else None
async def _phase_is(game_id: str, phase: str) -> Optional[GameState]: async def _phase_is(game_id: str, phase: str) -> Optional[GameSession]:
state = await game_store.load(game_id) session = await game_store.load(game_id)
return state if state is not None and state.phase == phase else None return session if session is not None and session.state.phase == phase else None
class ProcessDueTest(unittest.TestCase): class ProcessDueTest(unittest.TestCase):
"""Direct ``process_due`` behaviour: revalidation and idempotency.""" """Direct ``process_due`` behaviour: engine revalidation and idempotency."""
@async_test @async_test
async def test_processing_twice_is_a_no_op(self) -> None: async def test_processing_twice_is_a_no_op(self) -> None:
# Simulates a worker dying after firing but before removing the # Simulates a worker dying after firing but before removing the
# entry: another worker re-delivers the same entry. # entry: another worker re-delivers the same entry.
engine = platform.registry.require("scopone_scientifico")
deadline = (datetime.now(timezone.utc) - timedelta(seconds=1)).isoformat() deadline = (datetime.now(timezone.utc) - timedelta(seconds=1)).isoformat()
state = _hand_end_state("dl-idem-1", deadline) session = _hand_end_session("dl-idem-1", deadline)
await game_store.save(state) await game_store.save(session)
member = deadlines.encode({ current = engine.next_deadline(session)
"game_id": state.id, assert current is not None
"kind": deadlines.KIND_HAND_END, member = encode({
"hand": state.hand_number, "game_id": session.id,
"deadline": _ms(deadline), "kind": current.kind,
"token": current.token,
}) })
await deadlines.process_due(game_store, member) await scheduler.process_due(member)
await deadlines.process_due(game_store, member) await scheduler.process_due(member)
result = await game_store.load(state.id) result = await game_store.load(session.id)
assert result is not None assert result is not None
# Advanced exactly once: hand 2, not hand 3. # Advanced exactly once: hand 2, not hand 3.
self.assertEqual("playing", result.phase) self.assertEqual("playing", result.state.phase)
self.assertEqual(2, result.hand_number) self.assertEqual(2, result.state.hand_number)
@async_test @async_test
async def test_stale_entry_is_discarded(self) -> None: async def test_stale_entry_is_discarded(self) -> None:
# A turn entry enqueued before a play landed in time: the state's # A turn entry enqueued with a forged token: the live state carries
# deadline has moved, so the entry must not fire. # a different deadline, so the entry must not fire.
state = engine.create_game( session = _started_session("dl-stale-1", "DLS001")
"dl-stale-1", "DLS001", "alice", "Alice", await game_store.save(session)
target_score=11, turn_timeout=3600, member = encode({
) "game_id": session.id,
for name in PLAYERS[1:]: "kind": "turn",
engine.join_game(state, name, name.capitalize()) "token": "turn:1:1:0", # not the live token
await game_store.save(state)
member = deadlines.encode({
"game_id": state.id,
"kind": deadlines.KIND_TURN,
"hand": state.hand_number,
"turn": state.turn,
# Not the live deadline (epoch milliseconds).
"deadline": 946684800000,
}) })
await game_store.add_deadline(member, due_at=0.0) await game_store.add_deadline(member, due_at=0.0)
await deadlines.process_due(game_store, member) await scheduler.process_due(member)
result = await game_store.load(state.id) result = await game_store.load(session.id)
assert result is not None assert result is not None
self.assertEqual(state.turn, result.turn) self.assertEqual(session.state.turn, result.state.turn)
# The entry was removed after processing. # The entry was removed after processing.
self.assertNotIn(member, await game_store.due_deadlines(float("inf"))) self.assertNotIn(member, await game_store.due_deadlines(float("inf")))
@async_test @async_test
async def test_entry_for_expired_game_is_dropped(self) -> None: async def test_entry_for_expired_game_is_dropped(self) -> None:
member = deadlines.encode({ member = encode({
"game_id": "dl-gone", "game_id": "dl-gone",
"kind": deadlines.KIND_TURN, "kind": "turn",
"hand": 1, "token": "turn:1:0:0",
"turn": 0,
"deadline": 946684800000,
}) })
await game_store.add_deadline(member, due_at=0.0) await game_store.add_deadline(member, due_at=0.0)
await deadlines.process_due(game_store, member) await scheduler.process_due(member)
self.assertNotIn(member, await game_store.due_deadlines(float("inf"))) self.assertNotIn(member, await game_store.due_deadlines(float("inf")))
@async_test @async_test
async def test_malformed_entry_is_dropped(self) -> None: async def test_malformed_entry_is_dropped(self) -> None:
await game_store.add_deadline("not json", due_at=0.0) await game_store.add_deadline("not json", due_at=0.0)
await deadlines.process_due(game_store, "not json") await scheduler.process_due("not json")
self.assertNotIn("not json", await game_store.due_deadlines(float("inf"))) self.assertNotIn("not json", await game_store.due_deadlines(float("inf")))
+88
View File
@@ -0,0 +1,88 @@
"""Unit tests for the OpenTelemetry wiring in :mod:`tavolo.app`.
The mixin under test is kaya-otel's :class:`~kaya.otel.OTelMixin`, an
optional dependency (the ``otel`` extra); these tests only verify that
:func:`tavolo.app.otel_mixin_from_settings` maps the ``OTEL_*`` settings
onto mixin construction. The ``kaya.otel`` module is stubbed in
``sys.modules`` so the suite does not need the extra installed.
"""
from __future__ import annotations
import os
import sys
import types
import unittest
from unittest.mock import patch
from tavolo.app import otel_mixin_from_settings
from tavolo.config import Settings
def _settings(env: dict) -> Settings:
with patch.dict(os.environ, env, clear=True):
return Settings.from_env()
class _StubOTelMixin:
def __init__(self, **kwargs):
self.kwargs = kwargs
def _stub_kaya_otel():
"""Install a fake ``kaya.otel`` module and return it."""
module = types.ModuleType("kaya.otel")
module.OTelMixin = _StubOTelMixin # type: ignore[attr-defined]
return patch.dict(sys.modules, {"kaya.otel": module})
class OTelMixinFromSettingsTests(unittest.TestCase):
def test_disabled_by_default(self):
self.assertIsNone(otel_mixin_from_settings(_settings({})))
def test_enabled_by_otel_enabled(self):
with _stub_kaya_otel():
mixin = otel_mixin_from_settings(_settings({"OTEL_ENABLED": "1"}))
self.assertIsNotNone(mixin)
def test_settings_are_passed_through(self):
with _stub_kaya_otel():
mixin = otel_mixin_from_settings(_settings({
"OTEL_ENABLED": "true",
"OTEL_SERVICE_NAME": "cards",
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector:4318",
"OTEL_EXPORTER_OTLP_HEADERS": "Authorization=Bearer t, X-Tenant=one",
}))
assert isinstance(mixin, _StubOTelMixin)
self.assertEqual({
"service_name": "cards",
"endpoint": "http://collector:4318",
"headers": {"Authorization": "Bearer t", "X-Tenant": "one"},
"excluded_paths": ("/api/health",),
}, mixin.kwargs)
def test_defaults_when_only_enabled(self):
with _stub_kaya_otel():
mixin = otel_mixin_from_settings(_settings({"OTEL_ENABLED": "on"}))
assert isinstance(mixin, _StubOTelMixin)
self.assertEqual("tavolo", mixin.kwargs["service_name"])
self.assertIsNone(mixin.kwargs["endpoint"])
self.assertIsNone(mixin.kwargs["headers"])
self.assertEqual(("/api/health",), mixin.kwargs["excluded_paths"])
def test_excluded_paths_are_passed_through(self):
with _stub_kaya_otel():
mixin = otel_mixin_from_settings(_settings({
"OTEL_ENABLED": "1",
"OTEL_EXCLUDED_PATHS": "/api/health,/metrics",
}))
assert isinstance(mixin, _StubOTelMixin)
self.assertEqual(("/api/health", "/metrics"), mixin.kwargs["excluded_paths"])
def test_missing_extra_raises_runtime_error(self):
with patch.dict(sys.modules, {"kaya.otel": None}):
with self.assertRaises(RuntimeError):
otel_mixin_from_settings(_settings({"OTEL_ENABLED": "1"}))
if __name__ == "__main__":
unittest.main()
+24 -9
View File
@@ -1,13 +1,12 @@
"""Game lobby route tests via kaya's ASGI transport.""" """Game lobby route tests via kaya's ASGI transport (scopone game)."""
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from pwo import async_test
from tavolo.app import app from tavolo.app import app
from tests.helpers import oidc_user from tests.helpers import async_test, oidc_user
class GamesRouteTest(unittest.TestCase): class GamesRouteTest(unittest.TestCase):
@@ -24,7 +23,9 @@ class GamesRouteTest(unittest.TestCase):
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"): with oidc_user("alice"):
created = await client.post("/api/games", json={"target_score": 16}) created = await client.post(
"/api/games", json={"options": {"target_score": 16}}
)
self.assertEqual(201, created.status_code) self.assertEqual(201, created.status_code)
body = created.json() body = created.json()
self.assertEqual("lobby", body["phase"]) self.assertEqual("lobby", body["phase"])
@@ -81,12 +82,16 @@ class GamesRouteTest(unittest.TestCase):
self.assertTrue(default.json()["napola"]) self.assertTrue(default.json()["napola"])
with oidc_user("alice"): with oidc_user("alice"):
disabled = await client.post("/api/games", json={"napola": False}) disabled = await client.post(
"/api/games", json={"options": {"napola": False}}
)
self.assertEqual(201, disabled.status_code) self.assertEqual(201, disabled.status_code)
self.assertFalse(disabled.json()["napola"]) self.assertFalse(disabled.json()["napola"])
with oidc_user("alice"): with oidc_user("alice"):
invalid = await client.post("/api/games", json={"napola": "yes"}) invalid = await client.post(
"/api/games", json={"options": {"napola": "yes"}}
)
self.assertEqual(400, invalid.status_code) self.assertEqual(400, invalid.status_code)
@async_test @async_test
@@ -121,9 +126,15 @@ class GamesRouteTest(unittest.TestCase):
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"): with oidc_user("alice"):
zero = await client.post("/api/games", json={"target_score": 0}) zero = await client.post(
text = await client.post("/api/games", json={"target_score": "eleven"}) "/api/games", json={"options": {"target_score": 0}}
huge = await client.post("/api/games", json={"target_score": 1000}) )
text = await client.post(
"/api/games", json={"options": {"target_score": "eleven"}}
)
huge = await client.post(
"/api/games", json={"options": {"target_score": 1000}}
)
self.assertEqual(400, zero.status_code) self.assertEqual(400, zero.status_code)
self.assertEqual(400, text.status_code) self.assertEqual(400, text.status_code)
self.assertEqual(400, huge.status_code) self.assertEqual(400, huge.status_code)
@@ -148,6 +159,10 @@ class GameTypesRouteTest(unittest.TestCase):
self.assertEqual(["scopone_scientifico"], [g["id"] for g in results]) self.assertEqual(["scopone_scientifico"], [g["id"] for g in results])
self.assertEqual("Scopone scientifico", results[0]["name"]) self.assertEqual("Scopone scientifico", results[0]["name"])
self.assertTrue(results[0]["description"]) self.assertTrue(results[0]["description"])
self.assertEqual(4, results[0]["min_players"])
self.assertEqual(4, results[0]["max_players"])
self.assertIn("target_score", results[0]["options_schema"]["properties"])
self.assertIn("napola", results[0]["options_schema"]["properties"])
@async_test @async_test
async def test_create_defaults_game_type(self) -> None: async def test_create_defaults_game_type(self) -> None:
+3 -4
View File
@@ -8,11 +8,10 @@ from pathlib import Path
from unittest import mock from unittest import mock
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from pwo import async_test
from tavolo.app import app from tavolo.app import app
from tavolo.config import settings from tavolo.config import settings
from tests.helpers import oidc_user from tests.helpers import async_test, oidc_user
class MeRouteTest(unittest.TestCase): class MeRouteTest(unittest.TestCase):
@@ -43,7 +42,7 @@ class StaticRouteTest(unittest.TestCase):
(Path(dist) / "index.html").write_text("<html>spa</html>") (Path(dist) / "index.html").write_text("<html>spa</html>")
patched = dataclasses.replace(settings, static_dir=dist) patched = dataclasses.replace(settings, static_dir=dist)
with mock.patch("tavolo.routes.static.settings", patched): with mock.patch("tavolo.static.settings", patched):
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
index = await client.get("/") index = await client.get("/")
@@ -59,7 +58,7 @@ class StaticRouteTest(unittest.TestCase):
@async_test @async_test
async def test_missing_dist_returns_404(self) -> None: async def test_missing_dist_returns_404(self) -> None:
patched = dataclasses.replace(settings, static_dir="/nonexistent-dist") patched = dataclasses.replace(settings, static_dir="/nonexistent-dist")
with mock.patch("tavolo.routes.static.settings", patched): with mock.patch("tavolo.static.settings", patched):
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/") response = await client.get("/")
+111 -183
View File
@@ -1,211 +1,139 @@
"""Match-statistics tests: Postgres persistence and the stats endpoints.""" """Scopone result-persistence tests: engine result to Postgres to API.
The platform suite covers the generic machinery against a toy game;
these tests pin the scopone-specific shape: the match ``result`` summary,
per-player scores/teams, Elo deltas and what the history and leaderboard
endpoints expose for a finished scopone match.
"""
from __future__ import annotations from __future__ import annotations
import unittest import unittest
import uuid
from datetime import datetime, timezone
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from pwo import async_test
from tavolo.app import app, tortoise_mixin from tavolo.app import app, platform, tortoise_mixin
from tavolo.game import engine from tavolo.platform import GameSession, Seat
from tavolo.game.state import GameState from tavolo.platform.elo import INITIAL_RATING
from tavolo.models import Match, MatchPlayer from tavolo.platform.models import Match, MatchPlayer, PlayerRating
from tavolo.stats import save_match_result from tavolo.platform.stats import save_match_result
from tests.helpers import oidc_user from tavolo.scopone import engine as rules
from tests.helpers import async_test, oidc_user
PLAYERS = ("alice", "bob", "carol", "dave")
async def _use_app_db(): async def _finished_session(target_score: int = 1) -> GameSession:
"""Bind the same Tortoise context the app uses for this event loop and engine = platform.registry.require("scopone_scientifico")
return it, so tests can seed rows the route handlers will see.""" session = GameSession(
id="stats-scope-1",
game_type=engine.id,
join_code="SS0001",
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="Alice")],
)
engine.create(session, {"target_score": target_score})
for name in PLAYERS[1:]:
engine.join(session, name, name.capitalize())
moves = 0
while not engine.is_finished(session) and moves < 200000:
state = session.state
if state.phase == "hand_end":
for player in state.players:
engine.handle_action(session, player.sub, "ack", {})
continue
player = next(p for p in state.players if p.seat == state.turn)
played = player.hand[0]
options = rules.legal_captures(state.table, played)
payload = {"card": played.code}
if options:
payload["capture"] = [c.code for c in options[0]]
engine.handle_action(session, player.sub, "play", payload)
moves += 1
assert engine.is_finished(session)
return session
class ScoponeStatsTest(unittest.TestCase):
@async_test
async def test_finished_match_persisted_with_scopone_summary(self) -> None:
await tortoise_mixin._bind() await tortoise_mixin._bind()
ctx = tortoise_mixin._ctx ctx = tortoise_mixin._ctx
assert ctx is not None assert ctx is not None
return ctx engine = platform.registry.require("scopone_scientifico")
session = await _finished_session()
def _finished_state() -> GameState:
# Team A sweeps the (single-card) table with carte + denara and reaches
# a target of 2, ending the match.
state = GameState(
id="stats-game",
join_code="STATS1",
creator_sub="alice",
target_score=2,
phase=engine.PHASE_PLAYING,
turn=0,
table=[engine.parse_card("02C")],
)
from tavolo.game.state import PlayerState, Card
state.players = [
PlayerState(sub="alice", name="alice", seat=0, hand=[Card.parse("02D")]),
PlayerState(sub="bob", name="bob", seat=1),
PlayerState(sub="carol", name="carol", seat=2),
PlayerState(sub="dave", name="dave", seat=3),
]
return state
class SaveMatchResultTest(unittest.TestCase):
@async_test
async def test_finished_match_is_persisted_once(self) -> None:
ctx = await _use_app_db()
state = _finished_state()
engine.play(state, "alice", "02D", ["02C"])
self.assertEqual(engine.PHASE_FINISHED, state.phase)
with ctx: with ctx:
await save_match_result(state) await save_match_result(session, engine)
await save_match_result(state) # idempotent
self.assertEqual(1, await Match.all().count()) self.assertEqual(1, await Match.all().count())
self.assertEqual(4, await MatchPlayer.all().count()) self.assertEqual(4, await MatchPlayer.all().count())
match = await Match.all().first() match = await Match.all().first()
assert match is not None assert match is not None
self.assertEqual(state.scores[0], match.team_a_score)
self.assertEqual("A", match.winner_team)
# The game type travels from the live state onto the row.
self.assertEqual("scopone_scientifico", match.game_type) self.assertEqual("scopone_scientifico", match.game_type)
winners = await MatchPlayer.filter(won=True) summary = match.result
self.assertEqual({"alice", "carol"}, {p.user_sub for p in winners}) self.assertEqual(1, summary["target_score"])
self.assertIn("team_a_score", summary)
self.assertIn("team_b_score", summary)
self.assertIn("winner_team", summary)
self.assertIn("hands_played", summary)
self.assertIn("hand_scores", summary)
players = {p.user_sub: p for p in await MatchPlayer.all()}
async def _seed_two_matches(game_types: tuple = ("scopone_scientifico", "scopone_scientifico")) -> None: winner_team = summary["winner_team"]
ctx = await _use_app_db() for sub, row in players.items():
with ctx: if row.won:
for index, (a_score, b_score, winner, finished) in enumerate( self.assertEqual(winner_team, row.team)
[ else:
(11, 5, "A", datetime(2026, 1, 1, 10, tzinfo=timezone.utc)), self.assertNotEqual(winner_team, row.team)
(8, 11, "B", datetime(2026, 1, 2, 10, tzinfo=timezone.utc)),
]
):
match = await Match.create(
id=uuid.uuid4(),
game_type=game_types[index],
team_a_score=a_score,
team_b_score=b_score,
winner_team=winner,
target_score=11,
hands_played=2 + index,
started_at=datetime(2025, 12, 31, tzinfo=timezone.utc),
finished_at=finished,
)
seats = [
("alice", 0, "A"),
("bob", 1, "B"),
("carol", 2, "A"),
("dave", 3, "B"),
]
for sub, seat, team in seats:
await MatchPlayer.create(
id=uuid.uuid4(),
match=match,
user_sub=sub,
display_name=sub,
seat=seat,
team=team,
won=(team == winner),
)
class StatsRouteTest(unittest.TestCase):
@async_test
async def test_my_matches_newest_first(self) -> None:
await _seed_two_matches()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
response = await client.get("/api/me/matches")
self.assertEqual(200, response.status_code)
results = response.json()["results"]
self.assertEqual(2, len(results))
self.assertEqual("B", results[0]["winner_team"]) # newest first
self.assertFalse(results[0]["you_won"])
self.assertTrue(results[1]["you_won"])
self.assertEqual(4, len(results[0]["players"]))
self.assertIn("next_cursor", response.json())
@async_test
async def test_my_matches_pagination(self) -> None:
await _seed_two_matches()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
first = await client.get("/api/me/matches?limit=1")
cursor = first.json()["next_cursor"]
self.assertIsNotNone(cursor)
second = await client.get(f"/api/me/matches?limit=1&cursor={cursor}")
self.assertEqual(1, len(first.json()["results"]))
self.assertEqual(1, len(second.json()["results"]))
self.assertNotEqual(
first.json()["results"][0]["id"],
second.json()["results"][0]["id"],
)
@async_test
async def test_my_matches_requires_auth(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/me/matches")
self.assertEqual(401, response.status_code)
@async_test
async def test_leaderboard_aggregates(self) -> None:
await _seed_two_matches()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/leaderboard")
self.assertEqual(200, response.status_code)
by_sub = {row["user_sub"]: row for row in response.json()["results"]}
self.assertEqual(2, by_sub["alice"]["matches"])
self.assertEqual(1, by_sub["alice"]["wins"]) # team A won match 1
self.assertEqual(19, by_sub["alice"]["points"])
self.assertEqual(1, by_sub["bob"]["wins"]) # team B won match 2
self.assertEqual(16, by_sub["bob"]["points"])
# Alice leads on points after tying Bob on wins.
self.assertEqual("alice", response.json()["results"][0]["user_sub"])
class GameTypeFilterTest(unittest.TestCase):
"""Stats endpoints scope results by the match's game type."""
@async_test
async def test_my_matches_filter_by_game_type(self) -> None:
# The second seed names a game the registry does not know; rows are
# written directly, so this only exercises the SQL filter.
await _seed_two_matches(game_types=("scopone_scientifico", "other_game"))
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
all_matches = await client.get("/api/me/matches")
scoped = await client.get("/api/me/matches?game_type=scopone_scientifico")
unknown = await client.get("/api/me/matches?game_type=briscola")
self.assertEqual(2, len(all_matches.json()["results"]))
self.assertEqual( self.assertEqual(
{"scopone_scientifico", "other_game"}, summary["team_a_score"] if row.team == "A"
{m["game_type"] for m in all_matches.json()["results"]}, else summary["team_b_score"],
row.score,
) )
scoped_results = scoped.json()["results"] # Winners share a team, losers the other.
self.assertEqual(1, len(scoped_results)) winners = {sub for sub, row in players.items() if row.won}
self.assertEqual("scopone_scientifico", scoped_results[0]["game_type"]) self.assertEqual(2, len(winners))
self.assertEqual(400, unknown.status_code)
@async_test @async_test
async def test_leaderboard_filter_by_game_type(self) -> None: async def test_finished_match_updates_elo(self) -> None:
await _seed_two_matches(game_types=("scopone_scientifico", "other_game")) await tortoise_mixin._bind()
ctx = tortoise_mixin._ctx
assert ctx is not None
engine = platform.registry.require("scopone_scientifico")
session = await _finished_session()
with ctx:
await save_match_result(session, engine)
ratings = {r.user_sub: r.rating for r in await PlayerRating.all()}
self.assertEqual(4, len(ratings))
self.assertEqual(
{INITIAL_RATING + 16, INITIAL_RATING - 16}, set(ratings.values())
)
@async_test
async def test_history_and_leaderboard_expose_scopone_result(self) -> None:
await tortoise_mixin._bind()
ctx = tortoise_mixin._ctx
assert ctx is not None
engine = platform.registry.require("scopone_scientifico")
session = await _finished_session()
with ctx:
await save_match_result(session, engine)
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
scoped = await client.get("/api/leaderboard?game_type=scopone_scientifico") with oidc_user("alice"):
unknown = await client.get("/api/leaderboard?game_type=briscola") history = await client.get("/api/me/matches")
self.assertEqual(200, scoped.status_code) self.assertEqual(200, history.status_code)
by_sub = {row["user_sub"]: row for row in scoped.json()["results"]} results = history.json()["results"]
# Only the first match counts: one match per player, team A won. self.assertEqual(1, len(results))
self.assertEqual(1, by_sub["alice"]["matches"]) self.assertEqual("scopone_scientifico", results[0]["game_type"])
self.assertEqual(1, by_sub["alice"]["wins"]) self.assertIn("team_a_score", results[0]["result"])
self.assertEqual(0, by_sub["bob"]["wins"]) self.assertIn("your_elo_delta", results[0])
self.assertEqual(400, unknown.status_code) self.assertEqual(4, len(results[0]["players"]))
board = await client.get("/api/leaderboard")
self.assertEqual(200, board.status_code)
by_sub = {r["user_sub"]: r for r in board.json()["results"]}
self.assertEqual(4, len(by_sub))
self.assertTrue(all(r["matches"] == 1 for r in by_sub.values()))
if __name__ == "__main__": if __name__ == "__main__":
+56 -19
View File
@@ -7,16 +7,45 @@ import unittest
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from httpx_ws import WebSocketDisconnect, aconnect_ws from httpx_ws import WebSocketDisconnect, aconnect_ws
from httpx_ws.transport import ASGIWebSocketTransport from httpx_ws.transport import ASGIWebSocketTransport
from pwo import async_test
from tavolo.app import app, game_store from tavolo.app import app, game_store, platform
from tavolo.game import engine from tavolo.platform import GameSession, Seat
from tavolo.game.state import Card, GameState, PlayerState from tavolo.scopone.state import Card, PlayerState, ScoponeState
from tests.helpers import make_user, oidc_user, ws_users from tests.helpers import async_test, make_user, oidc_user, ws_users
PLAYERS = ("alice", "bob", "carol", "dave") PLAYERS = ("alice", "bob", "carol", "dave")
def _started_session(
engine,
game_id: str,
code: str,
hand_ack_timeout: int = 30,
turn_timeout: int = 30,
) -> GameSession:
session = GameSession(
id=game_id,
game_type=engine.id,
join_code=code,
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="Alice")],
)
engine.create(
session,
{
"target_score": 11,
"napola": True,
},
)
# Apply per-test timeouts (the plugin normally copies them from its
# own constructor arguments).
session.state.hand_ack_timeout = hand_ack_timeout
session.state.turn_timeout = turn_timeout
for name in PLAYERS[1:]:
engine.join(session, name, name.capitalize())
return session
class WebSocketTest(unittest.TestCase): class WebSocketTest(unittest.TestCase):
async def _started_game(self, client: AsyncClient) -> dict: async def _started_game(self, client: AsyncClient) -> dict:
"""Create a game and seat four players; return the playing state.""" """Create a game and seat four players; return the playing state."""
@@ -133,24 +162,34 @@ class WebSocketTest(unittest.TestCase):
async def _seed_last_play_state(hand_ack_timeout: int = 30) -> str: async def _seed_last_play_state(hand_ack_timeout: int = 30) -> str:
"""Seed a game where a single play ends the hand: p0 holds the only """Seed a game where a single play ends the hand: p0 holds the only
card left and can capture the only table card.""" card left and can capture the only table card."""
state = GameState( engine = platform.registry.require("scopone_scientifico")
session = GameSession(
id="hand-end-1", id="hand-end-1",
game_type=engine.id,
join_code="HEND01", join_code="HEND01",
creator_sub="alice", creator_sub="alice",
players=[
Seat(user_sub="alice", display_name="Alice", team="A"),
Seat(user_sub="bob", display_name="Bob", team="B"),
Seat(user_sub="carol", display_name="Carol", team="A"),
Seat(user_sub="dave", display_name="Dave", team="B"),
],
)
session.state = ScoponeState(
target_score=11, target_score=11,
phase="playing", phase="playing",
turn=0, turn=0,
table=[Card.parse("02C")], table=[Card.parse("02C")],
) players=[
state.players = [
PlayerState(sub="alice", name="Alice", seat=0, hand=[Card.parse("02D")]), PlayerState(sub="alice", name="Alice", seat=0, hand=[Card.parse("02D")]),
PlayerState(sub="bob", name="Bob", seat=1), PlayerState(sub="bob", name="Bob", seat=1),
PlayerState(sub="carol", name="Carol", seat=2), PlayerState(sub="carol", name="Carol", seat=2),
PlayerState(sub="dave", name="Dave", seat=3), PlayerState(sub="dave", name="Dave", seat=3),
] ],
state.hand_ack_timeout = hand_ack_timeout hand_ack_timeout=hand_ack_timeout,
await game_store.save(state) )
return state.id await game_store.save(session)
return session.id
class HandEndWebSocketTest(unittest.TestCase): class HandEndWebSocketTest(unittest.TestCase):
@@ -241,18 +280,16 @@ class HandEndWebSocketTest(unittest.TestCase):
class TurnTimeoutWebSocketTest(unittest.TestCase): class TurnTimeoutWebSocketTest(unittest.TestCase):
@async_test @async_test
async def test_turn_timeout_auto_plays_a_card(self) -> None: async def test_turn_timeout_auto_plays_a_card(self) -> None:
state = engine.create_game( engine = platform.registry.require("scopone_scientifico")
"turn-timeout-1", "TT0001", "alice", "Alice", session = _started_session(
target_score=11, turn_timeout=1, engine, "turn-timeout-1", "TT0001", turn_timeout=1
) )
for name in PLAYERS[1:]: await game_store.save(session)
engine.join_game(state, name, name.capitalize())
await game_store.save(state)
ws_transport = ASGIWebSocketTransport(app=app) ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("alice")]): with ws_users([make_user("alice")]):
async with aconnect_ws(f"/ws/games/{state.id}", ws_client) as ws: async with aconnect_ws(f"/ws/games/{session.id}", ws_client) as ws:
first = await ws.receive_json() first = await ws.receive_json()
# Bob (seat 1) is first to act and never connects. # Bob (seat 1) is first to act and never connects.
self.assertEqual(1, first["game"]["turn"]) self.assertEqual(1, first["game"]["turn"])
+17 -2
View File
@@ -35,9 +35,12 @@ pub async fn game_types() -> Result<Vec<GameTypeInfo>, String> {
Ok(page.results) Ok(page.results)
} }
pub async fn create_game(game_type: &str, target_score: i32, napola: bool) -> Result<GameView, String> { /// Create a lobby game. `options` is the game-specific creation object
/// described by the engine's `options_schema` (see
/// [`crate::model::GameTypeInfo::option_fields`]).
pub async fn create_game(game_type: &str, options: serde_json::Value) -> Result<GameView, String> {
let resp = Request::post("/api/games") let resp = Request::post("/api/games")
.json(&serde_json::json!({ "game_type": game_type, "target_score": target_score, "napola": napola })) .json(&serde_json::json!({ "game_type": game_type, "options": options }))
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.send() .send()
.await .await
@@ -91,6 +94,18 @@ pub async fn my_matches(cursor: Option<&str>) -> Result<MatchesPage, String> {
resp.json().await.map_err(|e| e.to_string()) resp.json().await.map_err(|e| e.to_string())
} }
/// Fetch the caller's Elo ratings (one row per game type played).
pub async fn my_ratings() -> Result<RatingsPage, String> {
let resp = Request::get("/api/me/ratings")
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
resp.json().await.map_err(|e| e.to_string())
}
pub async fn leaderboard() -> Result<LeaderboardPage, String> { pub async fn leaderboard() -> Result<LeaderboardPage, String> {
let resp = Request::get("/api/leaderboard") let resp = Request::get("/api/leaderboard")
.send() .send()
+2 -2
View File
@@ -70,7 +70,7 @@ pub fn summary_rows(summary: HandSummary) -> View {
// Denara // Denara
award_row( award_row(
card_img("02D".to_string(), "score-mini"), card_img("02D".to_string(), "score-mini"),
"Denara", "Denari",
match &summary.award.denara { match &summary.award.denara {
Some(t) => { Some(t) => {
let (w, l) = winner_first(summary.denara.a, summary.denara.b, Some(t)); let (w, l) = winner_first(summary.denara.a, summary.denara.b, Some(t));
@@ -271,7 +271,7 @@ pub fn hand_summary_modal(
if let Some(s) = socket.get_clone() { if let Some(s) = socket.get_clone() {
s.ack(); s.ack();
} }
}) { "Understood next hand" } }) { "Understood, next hand" }
} }
}; };
+399 -7
View File
@@ -182,29 +182,73 @@ pub struct MatchPlayer {
pub user_sub: String, pub user_sub: String,
pub display_name: String, pub display_name: String,
pub seat: usize, pub seat: usize,
pub team: String, /// Game-defined team label; absent for games without fixed teams.
#[serde(default)]
pub team: Option<String>,
pub won: bool, pub won: bool,
/// Points the player scored in this match.
#[serde(default)]
pub score: f64,
/// Elo change this match produced for the player; absent for matches
/// recorded before ratings existed.
#[serde(default)]
pub elo_delta: Option<i32>,
/// Game-specific extras reported by the engine.
#[serde(default)]
pub details: serde_json::Value,
} }
/// The game-specific outcome of a finished match, as reported by the
/// engine (for scopone: the teams' final scores, the winner, the target
/// score, hands played and the per-hand audit). Games define their own
/// shape, so callers read it through [`MatchSummary::result_str`] and
/// [`MatchSummary::result_i64`], which return `None` for absent or
/// mistyped values.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)] #[allow(dead_code)]
pub struct MatchSummary { pub struct MatchSummary {
pub id: String, pub id: String,
#[serde(default)] #[serde(default)]
pub game_type: String, pub game_type: String,
pub team_a_score: i32, #[serde(default)]
pub team_b_score: i32, pub result: serde_json::Value,
pub winner_team: String,
pub target_score: i32,
pub hands_played: i32,
pub started_at: String, pub started_at: String,
pub finished_at: String, pub finished_at: String,
#[serde(default)] #[serde(default)]
pub you_won: bool, pub you_won: bool,
/// The viewer's Elo change in this match; absent when unrated.
#[serde(default)]
pub your_elo_delta: Option<i32>,
#[serde(default)] #[serde(default)]
pub players: Vec<MatchPlayer>, pub players: Vec<MatchPlayer>,
} }
impl MatchSummary {
/// Read a string field from the game-specific result, if present.
pub fn result_str(&self, key: &str) -> Option<String> {
self.result
.get(key)
.and_then(|v| v.as_str())
.map(str::to_string)
}
/// Read an integer field from the game-specific result, if present.
/// JSON floats with an integral value (e.g. `11.0`) are accepted.
pub fn result_i64(&self, key: &str) -> Option<i64> {
self.result.get(key).and_then(|v| {
v.as_i64().or_else(|| {
v.as_f64().and_then(|f| {
if f.fract() == 0.0 {
Some(f as i64)
} else {
None
}
})
})
})
}
}
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct MatchesPage { pub struct MatchesPage {
#[serde(default)] #[serde(default)]
@@ -213,14 +257,47 @@ pub struct MatchesPage {
pub next_cursor: Option<String>, pub next_cursor: Option<String>,
} }
fn default_elo() -> i32 {
1500
}
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)] #[allow(dead_code)]
pub struct LeaderboardEntry { pub struct LeaderboardEntry {
pub user_sub: String, pub user_sub: String,
pub display_name: String, pub display_name: String,
/// Chess-style Elo rating for the requested game type.
#[serde(default = "default_elo")]
pub elo: i32,
pub matches: i32, pub matches: i32,
pub wins: i32, pub wins: i32,
pub points: i32, /// Aggregated points; the backend reports a float.
#[serde(default)]
pub points: f64,
}
/// Render aggregated points: integral values without decimals.
pub fn fmt_points(points: f64) -> String {
if points.fract() == 0.0 {
format!("{}", points as i64)
} else {
format!("{points:.1}")
}
}
/// The caller's Elo rating for one game type (GET /api/me/ratings).
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct PlayerRating {
pub game_type: String,
pub rating: i32,
pub matches_played: i32,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RatingsPage {
#[serde(default)]
pub results: Vec<PlayerRating>,
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
@@ -237,6 +314,15 @@ pub struct GameTypeInfo {
pub name: String, pub name: String,
#[serde(default)] #[serde(default)]
pub description: String, pub description: String,
/// Player-count range of the game; zero when the backend predates them.
#[serde(default)]
pub min_players: usize,
#[serde(default)]
pub max_players: usize,
/// JSON-schema fragment describing the game-specific creation options
/// accepted by POST /api/games (see [`OptionField`]).
#[serde(default)]
pub options_schema: serde_json::Value,
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
@@ -245,6 +331,88 @@ pub struct GameTypesPage {
pub results: Vec<GameTypeInfo>, pub results: Vec<GameTypeInfo>,
} }
/// One creation option rendered from a game's `options_schema`.
#[derive(Debug, Clone, PartialEq)]
pub enum OptionKind {
Integer { min: Option<i64>, max: Option<i64> },
Boolean,
Text,
Enum { values: Vec<String> },
}
#[derive(Debug, Clone, PartialEq)]
pub struct OptionField {
pub key: String,
pub title: String,
pub kind: OptionKind,
pub default: serde_json::Value,
}
fn schema_string(schema: &serde_json::Value, key: &str) -> Option<String> {
schema.get(key).and_then(|v| v.as_str()).map(str::to_string)
}
fn schema_i64(schema: &serde_json::Value, key: &str) -> Option<i64> {
schema.get(key).and_then(|v| {
v.as_i64().or_else(|| {
v.as_f64()
.and_then(|f| if f.fract() == 0.0 { Some(f as i64) } else { None })
})
})
}
impl GameTypeInfo {
/// Parse the game's `options_schema` properties into renderable
/// fields, in alphabetical key order. Properties of unrecognized
/// types are skipped.
pub fn option_fields(&self) -> Vec<OptionField> {
let Some(properties) = self.options_schema.get("properties").and_then(|v| v.as_object()) else {
return Vec::new();
};
properties
.iter()
.filter_map(|(key, schema)| {
let title = schema_string(schema, "title")
.or_else(|| schema_string(schema, "description"))
.unwrap_or_else(|| key.clone());
let default = schema.get("default").cloned().unwrap_or(serde_json::Value::Null);
let kind = match schema.get("type").and_then(|v| v.as_str()) {
Some("integer") => OptionKind::Integer {
min: schema_i64(schema, "minimum"),
max: schema_i64(schema, "maximum"),
},
Some("boolean") => OptionKind::Boolean,
Some("string") if schema.get("enum").and_then(|v| v.as_array()).is_some() => {
OptionKind::Enum {
values: schema["enum"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default(),
}
}
Some("string") => OptionKind::Text,
_ => return None,
};
Some(OptionField { key: key.clone(), title, kind, default })
})
.collect()
}
/// The schema defaults as a JSON object, for initializing the
/// creation form (and as the request body when the user changes
/// nothing).
pub fn default_options(&self) -> serde_json::Map<String, serde_json::Value> {
self.option_fields()
.into_iter()
.map(|f| (f.key, f.default))
.collect()
}
}
/// Map a card code (e.g. `07D`) to its asset path. /// Map a card code (e.g. `07D`) to its asset path.
pub fn card_asset(code: &str) -> String { pub fn card_asset(code: &str) -> String {
format!("/assets/cards/{code}.svg") format!("/assets/cards/{code}.svg")
@@ -285,6 +453,7 @@ pub fn card_label(code: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use serde_json::json;
#[test] #[test]
fn card_asset_maps_code() { fn card_asset_maps_code() {
@@ -299,4 +468,227 @@ mod tests {
assert_eq!("Re di bastoni", card_label("10B")); assert_eq!("Re di bastoni", card_label("10B"));
assert_eq!("7 di denari", card_label("07D")); assert_eq!("7 di denari", card_label("07D"));
} }
fn scopone_match() -> MatchSummary {
serde_json::from_value(json!({
"id": "m1",
"game_type": "scopone_scientifico",
"result": {
"team_a_score": 11,
"team_b_score": 7.0,
"winner_team": "A",
"target_score": 11,
"hands_played": 3,
},
"started_at": "2026-01-01T00:00:00+00:00",
"finished_at": "2026-01-01T01:00:00+00:00",
"you_won": true,
"players": [
{"user_sub": "a", "display_name": "a", "seat": 0,
"team": "A", "won": true, "score": 11.0},
{"user_sub": "b", "display_name": "b", "seat": 1,
"team": null, "won": false},
],
}))
.unwrap()
}
#[test]
fn match_summary_reads_scopone_result() {
let m = scopone_match();
assert_eq!(Some(11), m.result_i64("team_a_score"));
// Integral floats are accepted.
assert_eq!(Some(7), m.result_i64("team_b_score"));
assert_eq!(Some("A".to_string()), m.result_str("winner_team"));
assert_eq!(None, m.result_str("team_a_score"));
assert_eq!(None, m.result_i64("missing"));
assert_eq!(None, m.result_i64("winner_team"));
}
#[test]
fn match_summary_tolerates_empty_result() {
let m: MatchSummary = serde_json::from_value(json!({
"id": "m2",
"started_at": "x",
"finished_at": "y",
}))
.unwrap();
assert_eq!(None, m.result_i64("team_a_score"));
assert_eq!(None, m.result_str("winner_team"));
assert!(m.players.is_empty());
}
#[test]
fn match_player_team_is_optional() {
let m = scopone_match();
assert_eq!(Some("A".to_string()), m.players[0].team);
assert_eq!(None, m.players[1].team);
assert_eq!(11.0, m.players[0].score);
assert_eq!(0.0, m.players[1].score);
}
#[test]
fn fmt_points_drops_integral_decimals() {
assert_eq!("11", fmt_points(11.0));
assert_eq!("0", fmt_points(0.0));
assert_eq!("2.5", fmt_points(2.5));
}
#[test]
fn leaderboard_points_accept_floats() {
let e: LeaderboardEntry = serde_json::from_value(json!({
"user_sub": "a",
"display_name": "a",
"matches": 2,
"wins": 1,
"points": 19.0,
}))
.unwrap();
assert_eq!(19.0, e.points);
assert_eq!(1500, e.elo);
}
fn scopone_game_type() -> GameTypeInfo {
serde_json::from_value(json!({
"id": "scopone_scientifico",
"name": "Scopone scientifico",
"description": "d",
"min_players": 4,
"max_players": 4,
"options_schema": {
"type": "object",
"properties": {
"target_score": {
"type": "integer", "minimum": 1, "maximum": 100,
"default": 11,
},
"napola": {"type": "boolean", "default": true},
},
},
}))
.unwrap()
}
#[test]
fn option_fields_parse_scopone_schema() {
let fields = scopone_game_type().option_fields();
assert_eq!(2, fields.len());
let by_key: std::collections::HashMap<_, _> =
fields.into_iter().map(|f| (f.key.clone(), f)).collect();
let target = &by_key["target_score"];
assert!(matches!(
target.kind,
OptionKind::Integer { min: Some(1), max: Some(100) }
));
assert_eq!(json!(11), target.default);
assert_eq!(OptionKind::Boolean, by_key["napola"].kind);
assert_eq!(json!(true), by_key["napola"].default);
}
#[test]
fn option_fields_skip_unknown_types() {
let g: GameTypeInfo = serde_json::from_value(json!({
"id": "x",
"name": "x",
"options_schema": {
"type": "object",
"properties": {
"mystery": {"type": "object"},
"mode": {"type": "string", "enum": ["a", "b"], "default": "a"},
"nick": {"type": "string"},
},
},
}))
.unwrap();
let fields = g.option_fields();
assert_eq!(2, fields.len());
assert_eq!(
OptionKind::Enum { values: vec!["a".to_string(), "b".to_string()] },
fields[0].kind
);
assert_eq!(OptionKind::Text, fields[1].kind);
}
#[test]
fn default_options_collect_schema_defaults() {
let opts = scopone_game_type().default_options();
assert_eq!(Some(&json!(11)), opts.get("target_score"));
assert_eq!(Some(&json!(true)), opts.get("napola"));
}
#[test]
fn game_view_parses_new_lobby_payload() {
// Exact shape of the platform's lobby payload: envelope fields,
// seats with team labels, seats_open, plus the engine's
// lobby_view (phase/target_score/napola for scopone).
let g: GameView = serde_json::from_value(json!({
"id": "11111111-2222-3333-4444-555555555555",
"join_code": "ABC123",
"game_type": "scopone_scientifico",
"players": [
{"sub": "alice", "name": "Alice", "seat": 0, "team": "A"},
],
"seats_open": 3,
"phase": "lobby",
"target_score": 16,
"napola": false,
}))
.unwrap();
assert_eq!("ABC123", g.join_code);
assert_eq!(Some(3), g.seats_open);
assert_eq!("lobby", g.phase);
assert_eq!(16, g.target_score);
assert!(!g.napola);
assert_eq!("A", g.players[0].team);
}
#[test]
fn game_view_parses_new_snapshot_payload() {
// Envelope merged with the engine view; hands hidden for others.
let g: GameView = serde_json::from_value(json!({
"id": "11111111-2222-3333-4444-555555555555",
"join_code": "ABC123",
"game_type": "scopone_scientifico",
"phase": "playing",
"target_score": 11,
"napola": true,
"hand_number": 1,
"dealer": 0,
"turn": 1,
"scores": {"A": 0, "B": 0},
"winner": null,
"table": [],
"players": [
{"sub": "alice", "name": "Alice", "seat": 0, "team": "A",
"cards_left": 10, "captured_count": 0, "scope": 0},
{"sub": "bob", "name": "Bob", "seat": 1, "team": "B",
"cards_left": 10, "captured_count": 0, "scope": 0,
"hand": ["01D"]},
],
"last_hand": null,
"last_move": null,
"acknowledged": [],
"hand_end_deadline": null,
"turn_deadline": "2026-01-01T00:00:30+00:00",
"your_turn": true,
"legal_moves": {},
}))
.unwrap();
assert_eq!("playing", g.phase);
assert_eq!(None, g.players[0].hand);
assert_eq!(Some(vec!["01D".to_string()]), g.players[1].hand);
assert_eq!(Some(true), g.your_turn);
}
#[test]
fn game_type_without_schema_has_no_fields() {
let g: GameTypeInfo = serde_json::from_value(json!({
"id": "legacy",
"name": "Legacy",
}))
.unwrap();
assert!(g.option_fields().is_empty());
assert!(g.default_options().is_empty());
assert_eq!(0, g.min_players);
}
} }
+34 -5
View File
@@ -329,13 +329,20 @@ pub fn GamePage(id: String) -> View {
} }
} }
Some(g) if g.phase == "lobby" => lobby_view(g), Some(g) if g.phase == "lobby" => lobby_view(g),
Some(g) => table_view(g, on_hand_card, selected, now), // The live table is scopone-specific; other games get a
// fallback until they ship their own play view.
Some(g) if g.game_type == "scopone_scientifico" => {
table_view(g, on_hand_card, selected, now)
}
Some(g) => unsupported_game_view(g),
}) })
(move || capture_choice.get_clone().map(|(card, options)| { (move || capture_choice.get_clone().map(|(card, options)| {
capture_picker(card, options, socket, capture_choice) capture_picker(card, options, socket, capture_choice)
})) }))
(move || match game.get_clone() { (move || match game.get_clone() {
Some(g) if g.phase == "hand_end" => hand_summary_modal(g, socket, now), Some(g) if g.phase == "hand_end" && g.game_type == "scopone_scientifico" => {
hand_summary_modal(g, socket, now)
}
_ => view! {}, _ => view! {},
}) })
(move || game_over_view(over.get_clone(), game.get_clone())) (move || game_over_view(over.get_clone(), game.get_clone()))
@@ -343,9 +350,13 @@ pub fn GamePage(id: String) -> View {
} }
} }
/// Lobby view while waiting for the fourth player. /// Lobby view while waiting for the remaining players. The seat count
/// comes from the payload (`seats_open`), so games with other player
/// counts render correctly.
fn lobby_view(game: GameView) -> View { fn lobby_view(game: GameView) -> View {
let seats_open = 4usize.saturating_sub(game.players.len()); let seats_open = game
.seats_open
.unwrap_or_else(|| 4usize.saturating_sub(game.players.len()));
let join_code = game.join_code.clone(); let join_code = game.join_code.clone();
let players = game let players = game
.players .players
@@ -374,6 +385,18 @@ fn lobby_view(game: GameView) -> View {
} }
} }
/// Fallback for live games the web client has no play view for yet.
fn unsupported_game_view(game: GameView) -> View {
let game_type = game.game_type.clone();
view! {
div(class="panel status-panel") {
h2 { "Unsupported game" }
p { "Live play for “" (game_type) "” isn't supported in the web client yet." }
p { a(href="/") { "Back to lobby" } }
}
}
}
fn player_for_seat(game: &GameView, seat: usize) -> Option<PlayerView> { fn player_for_seat(game: &GameView, seat: usize) -> Option<PlayerView> {
game.players.iter().find(|p| p.seat == seat).cloned() game.players.iter().find(|p| p.seat == seat).cloned()
} }
@@ -537,8 +560,14 @@ fn capture_picker(
} }
} }
/// End-of-match overlay. /// End-of-match overlay (scopone's team score; other games render
/// nothing until they ship their own play view).
fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>) -> View { fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>) -> View {
if let Some(g) = &game {
if g.game_type != "scopone_scientifico" {
return view! {};
}
}
let result = over.or_else(|| { let result = over.or_else(|| {
game.clone() game.clone()
.filter(|g| g.phase == "finished") .filter(|g| g.phase == "finished")
+126 -25
View File
@@ -1,10 +1,72 @@
//! Match history page. //! Match history page.
//!
//! Rendering is game-generic: teams, scores and the winner are read from
//! each match's game-specific `result` object, degrading gracefully when
//! a game reports a different shape.
use std::collections::BTreeSet;
use wasm_bindgen_futures::spawn_local; use wasm_bindgen_futures::spawn_local;
use sycamore::prelude::*; use sycamore::prelude::*;
use crate::api; use crate::api;
use crate::components::toast::toast; use crate::components::toast::toast;
use crate::model::MatchesPage; use crate::model::{GameTypeInfo, MatchSummary, MatchesPage};
/// Distinct team labels in seat order; players without a team render in
/// a shared "Players" column.
fn team_columns(m: &MatchSummary) -> Vec<Option<String>> {
let mut teams = BTreeSet::new();
for p in &m.players {
teams.insert(p.team.clone());
}
let mut ordered: Vec<Option<String>> = teams.into_iter().collect();
// Seated teams first (in first-seat order), then the team-less column.
ordered.sort_by_key(|t| match t {
Some(_) => (0, String::new()),
None => (1, String::new()),
});
ordered
}
fn team_names(m: &MatchSummary, team: &Option<String>) -> String {
m.players
.iter()
.filter(|p| &p.team == team)
.map(|p| p.display_name.clone())
.collect::<Vec<_>>()
.join(" & ")
}
fn team_header(team: &Option<String>) -> String {
match team {
Some(t) => format!("Team {t}"),
None => "Players".to_string(),
}
}
/// "11 7" when the result carries both team scores, "—" otherwise.
fn score_text(m: &MatchSummary) -> String {
match (m.result_i64("team_a_score"), m.result_i64("team_b_score")) {
(Some(a), Some(b)) => format!("{a} {b}"),
_ => "".to_string(),
}
}
/// "Team A" from `winner_team`, a plain `winner`, or "—".
fn winner_text(m: &MatchSummary) -> String {
if let Some(team) = m.result_str("winner_team") {
return format!("Team {team}");
}
m.result_str("winner").unwrap_or_else(|| "".to_string())
}
fn game_name(game_types: &[GameTypeInfo], game_type: &str) -> String {
game_types
.iter()
.find(|g| g.id == game_type)
.map(|g| g.name.clone())
.unwrap_or_else(|| game_type.to_string())
}
#[component] #[component]
pub fn HistoryPage() -> View { pub fn HistoryPage() -> View {
@@ -13,6 +75,7 @@ pub fn HistoryPage() -> View {
let cursor = create_signal(Option::<String>::None); let cursor = create_signal(Option::<String>::None);
// Accumulated rows across "load more" clicks. // Accumulated rows across "load more" clicks.
let rows = create_signal(Vec::<crate::model::MatchSummary>::new()); let rows = create_signal(Vec::<crate::model::MatchSummary>::new());
let game_types = create_signal(Vec::<GameTypeInfo>::new());
let load = move |next: Option<String>| { let load = move |next: Option<String>| {
spawn_local(async move { spawn_local(async move {
@@ -27,6 +90,12 @@ pub fn HistoryPage() -> View {
}); });
}; };
spawn_local(async move {
if let Ok(types) = api::game_types().await {
game_types.set(types);
}
});
let load2 = load; let load2 = load;
spawn_local(async move { spawn_local(async move {
match api::my_matches(None).await { match api::my_matches(None).await {
@@ -53,47 +122,79 @@ pub fn HistoryPage() -> View {
p(class="status") { "No matches played yet." } p(class="status") { "No matches played yet." }
}, },
Some(_) => { Some(_) => {
let names = game_types.get_clone();
let table_rows = rows let table_rows = rows
.get_clone() .get_clone()
.into_iter() .into_iter()
.map(|m| { .map(|m| {
let team_a: String = m let finished = m
.players .finished_at
.replace('T', " ")
.chars()
.take(16)
.collect::<String>();
let game = game_name(&names, &m.game_type);
let mut cells: Vec<View> = team_columns(&m)
.iter() .iter()
.filter(|p| p.team == "A") .map(|t| team_names(&m, t))
.map(|p| p.display_name.clone()) .map(|c| view! { td { (c) } })
.collect::<Vec<_>>() .collect();
.join(" & "); let score = score_text(&m);
let team_b: String = m let winner = winner_text(&m);
.players
.iter()
.filter(|p| p.team == "B")
.map(|p| p.display_name.clone())
.collect::<Vec<_>>()
.join(" & ");
let outcome = if m.you_won { "Won" } else { "Lost" }; let outcome = if m.you_won { "Won" } else { "Lost" };
view! { let elo_delta = m
tr { .your_elo_delta
td { (m.finished_at.replace('T', " ").chars().take(16).collect::<String>()) } .map(|d| {
td { (team_a) } if d >= 0 {
td { (team_b) } format!("+{d}")
td { (m.team_a_score) " " (m.team_b_score) } } else {
td { "Team " (m.winner_team) } d.to_string()
td(class=if m.you_won { "won" } else { "lost" }) { (outcome) }
}
} }
}) })
.unwrap_or_else(|| "".to_string());
let cls = if m.you_won { "won" } else { "lost" };
let mut row_cells = vec![
view! { td { (finished) } },
view! { td { (game) } },
];
row_cells.append(&mut cells);
row_cells.push(view! { td { (score) } });
row_cells.push(view! { td { (winner) } });
row_cells.push(view! { td(class=cls) { (outcome) } });
row_cells.push(view! { td(class=cls) { (elo_delta) } });
view! { tr { (row_cells) } }
})
.collect::<Vec<_>>(); .collect::<Vec<_>>();
// Team columns are per-row (matches may mix games), so
// the header shows the union across the loaded rows.
let mut header_teams = BTreeSet::new();
for m in rows.get_clone().iter() {
for t in team_columns(m) {
header_teams.insert(t);
}
}
let mut header_teams: Vec<Option<String>> =
header_teams.into_iter().collect();
header_teams.sort_by_key(|t| match t {
Some(_) => (0, String::new()),
None => (1, String::new()),
});
let header_cells: Vec<View> = header_teams
.iter()
.map(team_header)
.map(|h| view! { th { (h) } })
.collect();
view! { view! {
table(class="matches") { table(class="matches") {
thead { thead {
tr { tr {
th { "Finished" } th { "Finished" }
th { "Team A" } th { "Game" }
th { "Team B" } (header_cells)
th { "Score" } th { "Score" }
th { "Winner" } th { "Winner" }
th { "You" } th { "You" }
th { "Elo" }
} }
} }
tbody { (table_rows) } tbody { (table_rows) }
+4 -2
View File
@@ -4,7 +4,7 @@ use sycamore::prelude::*;
use crate::api; use crate::api;
use crate::components::toast::toast; use crate::components::toast::toast;
use crate::model::LeaderboardPage; use crate::model::{fmt_points, LeaderboardPage};
#[component] #[component]
pub fn LeaderboardPage() -> View { pub fn LeaderboardPage() -> View {
@@ -39,9 +39,10 @@ pub fn LeaderboardPage() -> View {
tr { tr {
td { (i + 1) } td { (i + 1) }
td { (e.display_name.clone()) } td { (e.display_name.clone()) }
td { (e.elo) }
td { (e.wins) } td { (e.wins) }
td { (e.matches) } td { (e.matches) }
td { (e.points) } td { (fmt_points(e.points)) }
} }
} }
}) })
@@ -52,6 +53,7 @@ pub fn LeaderboardPage() -> View {
tr { tr {
th { "#" } th { "#" }
th { "Player" } th { "Player" }
th { "Elo" }
th { "Wins" } th { "Wins" }
th { "Matches" } th { "Matches" }
th { "Points" } th { "Points" }
+239 -20
View File
@@ -1,11 +1,17 @@
//! Lobby page: login prompt, match creation and joining by code. //! Lobby page: login prompt, match creation and joining by code.
//!
//! Match creation is game-generic: the form is rendered from the selected
//! game's `options_schema` (see [`crate::model::GameTypeInfo`]), so new
//! games get a working creation UI without frontend changes.
use std::collections::HashMap;
use wasm_bindgen_futures::spawn_local; use wasm_bindgen_futures::spawn_local;
use sycamore::prelude::*; use sycamore::prelude::*;
use sycamore_router::navigate; use sycamore_router::navigate;
use crate::api; use crate::api;
use crate::components::toast::toast; use crate::components::toast::toast;
use crate::model::{GameTypeInfo, User}; use crate::model::{GameTypeInfo, OptionField, OptionKind, PlayerRating, User};
/// Used when the game-types fetch fails: match creation must still work. /// Used when the game-types fetch fails: match creation must still work.
fn fallback_game_types() -> Vec<GameTypeInfo> { fn fallback_game_types() -> Vec<GameTypeInfo> {
@@ -13,22 +19,197 @@ fn fallback_game_types() -> Vec<GameTypeInfo> {
id: "scopone_scientifico".to_string(), id: "scopone_scientifico".to_string(),
name: "Scopone scientifico".to_string(), name: "Scopone scientifico".to_string(),
description: String::new(), description: String::new(),
min_players: 4,
max_players: 4,
options_schema: serde_json::Value::Null,
}] }]
} }
fn json_to_string(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
_ => String::new(),
}
}
/// One creation option: renders the control matching the schema kind and
/// writes the parsed value back into the shared `options` map.
#[derive(Props)]
struct OptionInputProps {
field: OptionField,
options: Signal<HashMap<String, serde_json::Value>>,
}
#[component]
fn OptionInput(props: OptionInputProps) -> View {
let field = props.field;
let options: Signal<HashMap<String, serde_json::Value>> = props.options;
let key = field.key.clone();
let title = field.title.clone();
match field.kind.clone() {
OptionKind::Boolean => {
let initial = options
.get_clone()
.get(&key)
.and_then(|v| v.as_bool())
.unwrap_or_else(|| field.default.as_bool().unwrap_or(false));
let flag = create_signal(initial);
let write_key = key.clone();
let id_attr = key.clone();
let for_attr = key;
create_effect(move || {
let value = flag.get_clone();
options.update(|m| {
m.insert(write_key.clone(), serde_json::Value::Bool(value));
});
});
view! {
label(class="check", r#for=for_attr) {
input(id=id_attr, r#type="checkbox", bind:checked=flag)
" " (title)
}
}
}
OptionKind::Enum { values } => {
let initial = options
.get_clone()
.get(&key)
.map(json_to_string)
.unwrap_or_else(|| json_to_string(&field.default));
let value = create_signal(initial);
let default = field.default.clone();
let write_key = key.clone();
create_effect(move || {
let text = value.get_clone();
let parsed = if text.is_empty() {
default.clone()
} else {
serde_json::Value::String(text)
};
options.update(|m| {
m.insert(write_key.clone(), parsed);
});
});
let items = create_signal(values);
let id_attr = key.clone();
let for_attr = key;
view! {
label(r#for=for_attr) { (title) }
select(id=id_attr, bind:value=value) {
Keyed(
list=items,
view=|v| {
let val = v.clone();
let label = v;
view! { option(value=val) { (label) } }
},
key=|v| v.clone(),
)
}
}
}
OptionKind::Integer { min, max } => {
let initial = options
.get_clone()
.get(&key)
.map(json_to_string)
.unwrap_or_else(|| json_to_string(&field.default));
let value = create_signal(initial);
let default = field.default.clone();
let write_key = key.clone();
create_effect(move || {
let text = value.get_clone();
let parsed = text
.parse::<i64>()
.map(serde_json::Value::from)
.unwrap_or_else(|_| default.clone());
options.update(|m| {
m.insert(write_key.clone(), parsed);
});
});
let min_attr = min.map(|m| m.to_string()).unwrap_or_default();
let max_attr = max.map(|m| m.to_string()).unwrap_or_default();
let id_attr = key.clone();
let for_attr = key;
view! {
label(r#for=for_attr) { (title) }
input(
id=id_attr,
r#type="number",
min=min_attr,
max=max_attr,
bind:value=value,
)
}
}
OptionKind::Text => {
let initial = options
.get_clone()
.get(&key)
.map(json_to_string)
.unwrap_or_else(|| json_to_string(&field.default));
let value = create_signal(initial);
let write_key = key.clone();
create_effect(move || {
let text = value.get_clone();
options.update(|m| {
m.insert(write_key.clone(), serde_json::Value::String(text));
});
});
let id_attr = key.clone();
let for_attr = key;
view! {
label(r#for=for_attr) { (title) }
input(id=id_attr, r#type="text", bind:value=value)
}
}
}
}
#[component] #[component]
pub fn LobbyPage() -> View { pub fn LobbyPage() -> View {
// Outer None = still loading; Some(None) = logged out. // Outer None = still loading; Some(None) = logged out.
let user = create_signal(Option::<Option<User>>::None); let user = create_signal(Option::<Option<User>>::None);
// The player's Elo ratings, one row per game type played.
let ratings = create_signal(Vec::<PlayerRating>::new());
let error = create_signal(Option::<String>::None); let error = create_signal(Option::<String>::None);
let code = create_signal(String::new()); let code = create_signal(String::new());
let game_types = create_signal(fallback_game_types()); let game_types = create_signal(fallback_game_types());
let selected_game = create_signal("scopone_scientifico".to_string()); let selected_game = create_signal("scopone_scientifico".to_string());
let napola = create_signal(true); // Current creation-form values (option key -> JSON value), reset to
// the schema defaults whenever the selection changes.
let options = create_signal(HashMap::<String, serde_json::Value>::new());
let fields = create_signal(Vec::<OptionField>::new());
create_effect(move || {
let id = selected_game.get_clone();
let game = game_types.get_clone().into_iter().find(|g| g.id == id);
match game {
Some(g) => {
options.set(g.default_options().into_iter().collect());
fields.set(g.option_fields());
}
None => {
options.set(HashMap::new());
fields.set(Vec::new());
}
}
});
spawn_local(async move { spawn_local(async move {
match api::me().await { match api::me().await {
Ok(me) => user.set(Some(me)), Ok(me) => {
if me.is_some() {
spawn_local(async move {
if let Ok(p) = api::my_ratings().await {
ratings.set(p.results);
}
});
}
user.set(Some(me));
}
Err(e) => { Err(e) => {
error.set(Some(e)); error.set(Some(e));
user.set(Some(None)); user.set(Some(None));
@@ -46,11 +227,12 @@ pub fn LobbyPage() -> View {
} }
}); });
let on_create = move |target: i32| { let on_create = move |_| {
let game_type = selected_game.get_clone(); let game_type = selected_game.get_clone();
let napola = napola.get(); let opts: serde_json::Map<String, serde_json::Value> =
options.get_clone().into_iter().collect();
spawn_local(async move { spawn_local(async move {
match api::create_game(&game_type, target, napola).await { match api::create_game(&game_type, serde_json::Value::Object(opts)).await {
Ok(game) => navigate(&format!("/game/{}", game.id)), Ok(game) => navigate(&format!("/game/{}", game.id)),
Err(e) => error.set(Some(e)), Err(e) => error.set(Some(e)),
} }
@@ -72,7 +254,7 @@ pub fn LobbyPage() -> View {
view! { view! {
div(class="lobby") { div(class="lobby") {
h1 { "Scopone scientifico" } h1 { "tavolo" }
(toast(error)) (toast(error))
(move || match user.get_clone() { (move || match user.get_clone() {
None => view! { p(class="status") { "Loading…" } }, None => view! { p(class="status") { "Loading…" } },
@@ -88,7 +270,16 @@ pub fn LobbyPage() -> View {
Some(Some(me)) => view! { Some(Some(me)) => view! {
div(class="lobby-grid") { div(class="lobby-grid") {
nav(class="top-nav") { nav(class="top-nav") {
span(class="whoami") { "Signed in as " strong { (me.name.clone()) } } span(class="whoami") {
"Signed in as " strong { (me.name.clone()) }
(move || ratings
.get_clone()
.into_iter()
.find(|r| r.game_type == selected_game.get_clone())
.map(|r| view! {
span(class="rating") { " · Elo " (r.rating) }
}))
}
a(href="/history") { "My matches" } a(href="/history") { "My matches" }
a(href="/leaderboard") { "Leaderboard" } a(href="/leaderboard") { "Leaderboard" }
a(href="/auth/logout", rel="external") { "Log out" } a(href="/auth/logout", rel="external") { "Log out" }
@@ -103,20 +294,48 @@ pub fn LobbyPage() -> View {
key=|g| g.id.clone(), key=|g| g.id.clone(),
) )
} }
label(class="check", r#for="napola") { (move || game_types
input(id="napola", r#type="checkbox", bind:checked=napola) .get_clone()
" Napola" .into_iter()
.find(|g| g.id == selected_game.get_clone())
.map(|g| {
let players_note = match (g.min_players, g.max_players) {
(0, 0) => None,
(min, max) if min == max => {
Some(format!("{min} players"))
} }
p(class="hint") { (min, max) => {
"A-2-3 of denari scores 3 points, plus 1 per extra " Some(format!("{min}{max} players"))
"consecutive denari card; sweeping the whole suit "
"wins the match instantly."
} }
p { "First team to reach the target score wins." } };
div(class="target-buttons") { let description: View = if g.description.is_empty() {
button(class="button", on:click=move |_| on_create(11)) { "Target 11" } view! {}
button(class="button", on:click=move |_| on_create(16)) { "Target 16" } } else {
button(class="button", on:click=move |_| on_create(21)) { "Target 21" } let text = g.description.clone();
view! {
p(class="hint") { (text) }
}
};
let note: View = match players_note {
Some(note) => view! {
p(class="hint") { (note) }
},
None => view! {},
};
view! {
(description)
(note)
}
}))
Keyed(
list=fields,
view=move |f| view! {
OptionInput(field=f.clone(), options=options)
},
key=|f| f.key.clone(),
)
button(class="button primary", on:click=on_create) {
"Create match"
} }
} }
div(class="panel") { div(class="panel") {
+4
View File
@@ -49,6 +49,10 @@ body {
margin-right: auto; margin-right: auto;
} }
.whoami .rating {
color: var(--muted, #888);
}
.panel { .panel {
background: var(--panel); background: var(--panel);
border-radius: 12px; border-radius: 12px;