From 5a73601ddf9e24c9bbb48ef531a9e5686b1bda01 Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Sat, 19 Sep 2026 07:28:58 +0000 Subject: [PATCH] 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). --- README.md | 8 +- server/Dockerfile | 6 +- server/README.md | 207 +++++--- .../3_20260919065917_generic_results.py | 73 +++ server/packages/tavolo-platform/README.md | 64 +++ .../packages/tavolo-platform/pyproject.toml | 38 ++ .../src/tavolo/platform/__init__.py | 53 ++ .../src/tavolo/platform}/auth.py | 37 +- .../src/tavolo/platform}/backfill_elo.py | 41 +- .../src/tavolo/platform/deadlines.py | 239 +++++++++ .../src/tavolo/platform}/elo.py | 0 .../src/tavolo/platform/engine.py | 247 ++++++++++ .../src/tavolo/platform/errors.py | 43 ++ .../src/tavolo/platform}/http.py | 0 .../src/tavolo/platform/mixin.py | 50 ++ .../src/tavolo/platform}/models.py | 42 +- .../src/tavolo/platform}/openapi.py | 0 .../src/tavolo/platform}/pagination.py | 0 .../src/tavolo/platform/py.typed | 0 .../src/tavolo/platform/registry.py | 60 +++ .../src/tavolo/platform/routes/__init__.py | 1 + .../src/tavolo/platform/routes/games.py | 287 +++++++++++ .../src/tavolo/platform/routes/health.py | 23 + .../src/tavolo/platform/routes/me.py | 30 ++ .../src/tavolo/platform/routes/stats.py | 191 ++++++++ .../src/tavolo/platform/stats.py | 110 +++++ .../src/tavolo/platform}/store.py | 148 ++++-- .../src/tavolo/platform}/tortoise_mixin.py | 0 .../tavolo-platform/src/tavolo/platform/ws.py | 210 ++++++++ .../packages/tavolo-platform/tests/helpers.py | 348 +++++++++++++ .../tavolo-platform/tests/test_deadlines.py | 187 +++++++ .../tavolo-platform}/tests/test_elo.py | 4 +- .../tavolo-platform/tests/test_routes.py | 204 ++++++++ .../tavolo-platform/tests/test_stats.py | 392 +++++++++++++++ .../tavolo-platform}/tests/test_store.py | 97 ++-- .../packages/tavolo-platform/tests/test_ws.py | 158 ++++++ server/packages/tavolo-scopone/README.md | 36 ++ server/packages/tavolo-scopone/pyproject.toml | 22 + .../src/tavolo/scopone/__init__.py | 13 + .../src/tavolo/scopone}/engine.py | 85 ++-- .../src/tavolo/scopone/errors.py | 34 ++ .../src/tavolo/scopone/plugin.py | 266 ++++++++++ .../src/tavolo/scopone/py.typed | 0 .../src/tavolo/scopone}/state.py | 48 +- .../tavolo-scopone}/tests/test_engine.py | 32 +- .../tavolo-scopone/tests/test_plugin.py | 289 +++++++++++ server/pyproject.toml | 26 +- server/requirements.txt | 44 +- server/src/tavolo/__init__.py | 1 - server/src/tavolo/aerich_config.py | 2 +- server/src/tavolo/app.py | 85 ++-- server/src/tavolo/config.py | 6 +- server/src/tavolo/deadlines.py | 296 ----------- server/src/tavolo/game/__init__.py | 1 - server/src/tavolo/game/errors.py | 42 -- server/src/tavolo/games.py | 42 -- server/src/tavolo/routes/__init__.py | 1 - server/src/tavolo/routes/games.py | 260 ---------- server/src/tavolo/routes/health.py | 19 - server/src/tavolo/routes/me.py | 25 - server/src/tavolo/routes/stats.py | 189 ------- server/src/tavolo/{routes => }/static.py | 4 +- server/src/tavolo/stats.py | 118 ----- server/src/tavolo/ws.py | 222 --------- server/tests/helpers/__init__.py | 2 +- server/tests/helpers/asynctest.py | 9 +- server/tests/helpers/oidc.py | 9 +- server/tests/test_deadlines.py | 192 ++++---- server/tests/test_routes_games.py | 30 +- server/tests/test_routes_me.py | 4 +- server/tests/test_stats.py | 462 ++++-------------- server/tests/test_websocket.py | 78 ++- 72 files changed, 4490 insertions(+), 2102 deletions(-) create mode 100644 server/migrations/models/3_20260919065917_generic_results.py create mode 100644 server/packages/tavolo-platform/README.md create mode 100644 server/packages/tavolo-platform/pyproject.toml create mode 100644 server/packages/tavolo-platform/src/tavolo/platform/__init__.py rename server/{src/tavolo => packages/tavolo-platform/src/tavolo/platform}/auth.py (56%) rename server/{src/tavolo => packages/tavolo-platform/src/tavolo/platform}/backfill_elo.py (55%) create mode 100644 server/packages/tavolo-platform/src/tavolo/platform/deadlines.py rename server/{src/tavolo => packages/tavolo-platform/src/tavolo/platform}/elo.py (100%) create mode 100644 server/packages/tavolo-platform/src/tavolo/platform/engine.py create mode 100644 server/packages/tavolo-platform/src/tavolo/platform/errors.py rename server/{src/tavolo => packages/tavolo-platform/src/tavolo/platform}/http.py (100%) create mode 100644 server/packages/tavolo-platform/src/tavolo/platform/mixin.py rename server/{src/tavolo => packages/tavolo-platform/src/tavolo/platform}/models.py (59%) rename server/{src/tavolo => packages/tavolo-platform/src/tavolo/platform}/openapi.py (100%) rename server/{src/tavolo => packages/tavolo-platform/src/tavolo/platform}/pagination.py (100%) create mode 100644 server/packages/tavolo-platform/src/tavolo/platform/py.typed create mode 100644 server/packages/tavolo-platform/src/tavolo/platform/registry.py create mode 100644 server/packages/tavolo-platform/src/tavolo/platform/routes/__init__.py create mode 100644 server/packages/tavolo-platform/src/tavolo/platform/routes/games.py create mode 100644 server/packages/tavolo-platform/src/tavolo/platform/routes/health.py create mode 100644 server/packages/tavolo-platform/src/tavolo/platform/routes/me.py create mode 100644 server/packages/tavolo-platform/src/tavolo/platform/routes/stats.py create mode 100644 server/packages/tavolo-platform/src/tavolo/platform/stats.py rename server/{src/tavolo => packages/tavolo-platform/src/tavolo/platform}/store.py (58%) rename server/{src/tavolo => packages/tavolo-platform/src/tavolo/platform}/tortoise_mixin.py (100%) create mode 100644 server/packages/tavolo-platform/src/tavolo/platform/ws.py create mode 100644 server/packages/tavolo-platform/tests/helpers.py create mode 100644 server/packages/tavolo-platform/tests/test_deadlines.py rename server/{ => packages/tavolo-platform}/tests/test_elo.py (95%) create mode 100644 server/packages/tavolo-platform/tests/test_routes.py create mode 100644 server/packages/tavolo-platform/tests/test_stats.py rename server/{ => packages/tavolo-platform}/tests/test_store.py (58%) create mode 100644 server/packages/tavolo-platform/tests/test_ws.py create mode 100644 server/packages/tavolo-scopone/README.md create mode 100644 server/packages/tavolo-scopone/pyproject.toml create mode 100644 server/packages/tavolo-scopone/src/tavolo/scopone/__init__.py rename server/{src/tavolo/game => packages/tavolo-scopone/src/tavolo/scopone}/engine.py (88%) create mode 100644 server/packages/tavolo-scopone/src/tavolo/scopone/errors.py create mode 100644 server/packages/tavolo-scopone/src/tavolo/scopone/plugin.py create mode 100644 server/packages/tavolo-scopone/src/tavolo/scopone/py.typed rename server/{src/tavolo/game => packages/tavolo-scopone/src/tavolo/scopone}/state.py (82%) rename server/{ => packages/tavolo-scopone}/tests/test_engine.py (96%) create mode 100644 server/packages/tavolo-scopone/tests/test_plugin.py delete mode 100644 server/src/tavolo/__init__.py delete mode 100644 server/src/tavolo/deadlines.py delete mode 100644 server/src/tavolo/game/__init__.py delete mode 100644 server/src/tavolo/game/errors.py delete mode 100644 server/src/tavolo/games.py delete mode 100644 server/src/tavolo/routes/__init__.py delete mode 100644 server/src/tavolo/routes/games.py delete mode 100644 server/src/tavolo/routes/health.py delete mode 100644 server/src/tavolo/routes/me.py delete mode 100644 server/src/tavolo/routes/stats.py rename server/src/tavolo/{routes => }/static.py (96%) delete mode 100644 server/src/tavolo/stats.py delete mode 100644 server/src/tavolo/ws.py diff --git a/README.md b/README.md index d413448..8422168 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,9 @@ scientifico** — the four-player, fixed-partnership Italian card game: - **`server/`** — backend: Python + [kaya](https://github.com/woggioni/kaya) 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). - **`web/`** — frontend: Rust + [Sycamore](https://sycamore.dev) compiled to WebAssembly, built with [Trunk](https://trunkrs.dev). Card images are the @@ -53,9 +56,10 @@ Backend (from `server/`): cd server python3 -m venv .venv .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/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: diff --git a/server/Dockerfile b/server/Dockerfile index d69d333..d5c61fd 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -57,8 +57,12 @@ RUN --mount=type=cache,target=/root/.cache/pip \ 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 . + /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/ diff --git a/server/README.md b/server/README.md index 42f79c2..4a74cf3 100644 --- a/server/README.md +++ b/server/README.md @@ -1,14 +1,26 @@ -# tavolo +# tavolo (backend) The backend for a multiplayer card-game platform, built on the -[kaya](../kaya) framework. The first game implemented is **scopone -scientifico**, the four-player, fixed-partnership variant of the classic -Italian card game. +[kaya](../kaya) framework. It is a monorepo of three distributions +sharing the `tavolo` namespace (see +[`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 -is kept in **Redis** (with real-time play over WebSocket), and completed -matches — every participant and the final score — are persisted to -**Postgres** for history and leaderboard statistics. +- **`tavolo-platform`** (`packages/tavolo-platform/`) — everything + game-independent: the lobby API, the live-game store, the WebSocket + endpoint, the deadline scheduler, match persistence and the Elo + 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) @@ -58,8 +70,8 @@ All configuration comes from environment variables (see `.env.example`): | `OIDC_CLIENT_SECRET` | unset | OIDC client secret | | `OIDC_REDIRECT_URI` | `http://localhost:8080/auth/callback` | Login callback URL | | `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis | -| `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the between-hands scoring summary waits for acknowledgements | -| `TURN_TIMEOUT_SECONDS` | `30` | Seconds a player has to play before the server plays a random legal card for them | +| `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 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) | | `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 | @@ -69,7 +81,7 @@ All configuration comes from environment variables (see `.env.example`): | `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[otel]`) | +| `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) | @@ -115,52 +127,58 @@ loggers: ### Redis (live games) -- `tavolo:game:` — the whole match as JSON: players (seat 0/2 = team A, - 1/3 = team B), hands, table, captured piles, scope, current turn, dealer, - scores, phase (`lobby` → `playing` → `finished`). Sliding TTL - (`GAME_TTL_SECONDS`). +- `tavolo:game:` — the whole session as JSON: the platform-owned + envelope (`id`, `game_type`, `join_code`, seats, timestamps) plus the + game-specific `state` blob, which only the registered engine + interprets. Sliding TTL (`GAME_TTL_SECONDS`). - `tavolo:code:` — the 6-character join code → game id index. - `tavolo:game::lock` — a short-lived lock serializing every mutation. - `tavolo:game::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. - `tavolo:deadlines` — a sorted set (score = due timestamp) of pending - timeouts: turn auto-plays and hand-end auto-continues. Every worker runs - a consumer that fires due entries under the per-game lock, so timeouts - do not depend on any player being connected and survive the death of - any worker (delivery is at-least-once; entries are revalidated against - the live state before firing). + timeouts (e.g. scopone's turn auto-plays and hand-end auto-continues). + Every worker runs a consumer that fires due entries under the per-game + lock by calling the engine's `fire_deadline`, so timeouts do not depend + on any player being connected and survive the death of any worker + (delivery is at-least-once; engines revalidate the entry's token + against the live state before firing). ### Postgres (statistics, via Tortoise ORM + aerich migrations) - `match` — one row per finished match: the game played (`game_type`, one of the ids from `GET /api/game-types`, indexed so statistics can be - scoped per game), both teams' final scores, winner, target score, hands - played, start/finish timestamps. + scoped per game), start/finish timestamps, and the game-specific outcome + 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, - seat, team, whether they won and the Elo change the match produced - (`elo_delta`). 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 -(once, guarded by a flag on the Redis state); the finished state stays in -Redis until its TTL expires so clients can still fetch the final board. +When a match ends, the engine's result is written transactionally to +Postgres (once, guarded by a flag on the session); the finished session +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.elo`): -everyone starts at 1500, a team's rating is the mean of its two 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 both members of a team, zero-sum between 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): +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 -DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo \ - .venv/bin/python -m tavolo.backfill_elo +.venv/bin/python -m tavolo.platform.backfill_elo \ + --database-url postgres://tavolo:tavolo@localhost:5432/tavolo ``` ## REST API @@ -170,13 +188,13 @@ All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`, | Method | Path | Description | |---|---|---| -| `GET` | `/api/game-types` | The card games the platform can host (for the creation dropdown) | -| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "target_score": 11, "napola": true}`. Returns `{id, join_code}` | -| `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The fourth player triggers the deal | +| `GET` | `/api/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", "options": {"target_score": 11, "napola": true}}`. Returns the lobby (`{id, join_code, players, seats_open, ...}`) | +| `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/me/matches` | Cursor-paginated match history with final scores and per-player Elo deltas (`?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/me/ratings` | The caller's Elo rating per game type | -| `GET` | `/api/leaderboard` | Elo rating, aggregated wins / matches / team points per player, sorted by Elo (`?game_type=`) | +| `GET` | `/api/leaderboard` | Elo rating, aggregated wins / matches / points per player, sorted by Elo (`?game_type=`) | ## WebSocket protocol @@ -192,7 +210,10 @@ Server → client messages are JSON objects with a `type`: - `{"type": "game_over", "scores": {"A": 11, "B": 7}, "winner": "A"}` - `{"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 {"action": "play", "card": "07D", "capture": ["02D", "05C"]} @@ -244,34 +265,46 @@ must dismiss. A play attempted in this phase is rejected with an - Hand points: `carte` (most captured cards), `denara` (most diamonds), `settebello` (7♦), `primiera` (best 7/6/5/4 per suit, all four suits required), plus one point per scopa. Ties award nothing. -- Optional *napola* rule (per-game `napola` flag on `POST /api/games`, - default on): the longest run of consecutive denari starting from the - ace scores one point per card once it reaches three cards (A-2-3 = 3, - A-2-3-4 = 4, …). A team that captures the whole denari suit (ace to - king) wins the match instantly, regardless of the score. +- Optional *napola* rule (per-game `napola` creation option, default on): + the longest run of consecutive denari starting from the ace scores one + point per card once it reaches three cards (A-2-3 = 3, A-2-3-4 = 4, …). + A team that captures the whole denari suit (ace to king) wins the match + instantly, regardless of the score. - The match ends when a team reaches the target score (default 11, - configurable per game) with a clear lead; a tie at or above the target is - broken by another hand. + configurable per game via the `target_score` creation option) with a + clear lead; a tie at or above the target is broken by another hand. ## Development ```sh python3 -m venv .venv .venv/bin/pip install -r requirements.txt -.venv/bin/pip install -e '.[dev]' +.venv/bin/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/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 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 The Postgres schema is owned by aerich migrations in `migrations/`. The `db-migrate` compose service runs `aerich upgrade` before the app starts. -To add a migration after changing `src/tavolo/models.py`: +To add a migration after changing +`packages/tavolo-platform/src/tavolo/platform/models.py`: ```sh DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo .venv/bin/aerich migrate @@ -284,31 +317,43 @@ baseline.) ## Layout -Everything lives under `server/`: - ``` -src/tavolo/ -├── app.py # composition root: session/OIDC/Tortoise/OpenAPI mixins -├── config.py # env -> frozen Settings -├── auth.py # auth helpers (HTTP + WebSocket) -├── http.py # JSON request/response helpers -├── pagination.py # keyset (cursor) pagination -├── openapi.py # shared OpenAPI parameter fragments -├── tortoise_mixin.py # TortoiseORM lifecycle (HTTP + WebSocket) -├── aerich_config.py # aerich CLI configuration -├── models.py # Match, MatchPlayer, PlayerRating (Postgres) -├── elo.py # chess-style Elo math (1500 start, K=32) -├── stats.py # finished match -> Postgres persistence + Elo update -├── backfill_elo.py # recompute all ratings from the match history -├── store.py # Redis / in-memory live-game store (+ deadline queue) -├── deadlines.py # connection-independent timeout scheduler -├── ws.py # WebSocket live-play endpoint -├── game/ -│ ├── state.py # GameState / PlayerState / Card, JSON (de)serialization -│ ├── engine.py # pure scopone scientifico rules -│ └── errors.py # typed rule violations -└── routes/ - ├── health.py # GET /api/health - ├── games.py # lobby: create / join / snapshot - └── stats.py # match history + leaderboard + Elo ratings +packages/tavolo-platform/ # game-independent platform (own pyproject + tests) + src/tavolo/platform/ + ├── __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) + ├── http.py # JSON request/response helpers + ├── pagination.py # keyset (cursor) pagination + ├── openapi.py # shared OpenAPI parameter fragments + ├── tortoise_mixin.py # TortoiseORM lifecycle (HTTP + WebSocket) + └── routes/ + ├── health.py # GET /api/health + ├── me.py # GET /api/me + ├── 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) ``` diff --git a/server/migrations/models/3_20260919065917_generic_results.py b/server/migrations/models/3_20260919065917_generic_results.py new file mode 100644 index 0000000..68cb287 --- /dev/null +++ b/server/migrations/models/3_20260919065917_generic_results.py @@ -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==" +) diff --git a/server/packages/tavolo-platform/README.md b/server/packages/tavolo-platform/README.md new file mode 100644 index 0000000..e85c2b9 --- /dev/null +++ b/server/packages/tavolo-platform/README.md @@ -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. diff --git a/server/packages/tavolo-platform/pyproject.toml b/server/packages/tavolo-platform/pyproject.toml new file mode 100644 index 0000000..f600060 --- /dev/null +++ b/server/packages/tavolo-platform/pyproject.toml @@ -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 `_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"] diff --git a/server/packages/tavolo-platform/src/tavolo/platform/__init__.py b/server/packages/tavolo-platform/src/tavolo/platform/__init__.py new file mode 100644 index 0000000..2cfc065 --- /dev/null +++ b/server/packages/tavolo-platform/src/tavolo/platform/__init__.py @@ -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", +] diff --git a/server/src/tavolo/auth.py b/server/packages/tavolo-platform/src/tavolo/platform/auth.py similarity index 56% rename from server/src/tavolo/auth.py rename to server/packages/tavolo-platform/src/tavolo/platform/auth.py index 85479c8..5ce2199 100644 --- a/server/src/tavolo/auth.py +++ b/server/packages/tavolo-platform/src/tavolo/platform/auth.py @@ -1,17 +1,15 @@ """Authentication helpers on top of the kaya-oidc mixin. -Tavolo has no application roles: every authenticated user may create and -join games. Authorization beyond login is game membership, checked against -the live game state in Redis. +The platform has no application roles: every authenticated user may +create and join games. Authorization beyond login is game membership, +checked against the seats of the live session. """ from __future__ import annotations from typing import Any, Callable, Mapping, Optional from kaya.core import HttpContext, WebSocket -from kaya.oidc import OIDCUser - -from .app import oidc_mixin +from kaya.oidc import OIDCMixin, OIDCUser def get_ws_user(ws: WebSocket) -> Optional[OIDCUser]: @@ -38,22 +36,25 @@ def display_name(user: OIDCUser) -> str: return user.sub -def require_auth(handler: Callable[..., Any]) -> Callable[..., Any]: - """Decorator: gate a handler on being authenticated (any OIDC user). +def require_auth(oidc: OIDCMixin) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + """Build a decorator gating a handler on being authenticated. Responds ``401`` with a JSON error envelope when unauthenticated — unlike kaya's built-in ``OIDCMixin.require_auth`` which redirects to the login page (wrong for a JSON API). """ - async def guarded(ctx: HttpContext, *args: Any, **kwargs: Any) -> None: - if oidc_mixin.get_user(ctx) is None: - await ctx.send_bytes( - 401, - b'{"error":"unauthenticated"}', - {"content-type": ("application/json",)}, - ) - return - await handler(ctx, *args, **kwargs) + def decorator(handler: Callable[..., Any]) -> Callable[..., Any]: + async def guarded(ctx: HttpContext, *args: Any, **kwargs: Any) -> None: + if oidc.get_user(ctx) is None: + await ctx.send_bytes( + 401, + b'{"error":"unauthenticated"}', + {"content-type": ("application/json",)}, + ) + return + await handler(ctx, *args, **kwargs) - return guarded + return guarded + + return decorator diff --git a/server/src/tavolo/backfill_elo.py b/server/packages/tavolo-platform/src/tavolo/platform/backfill_elo.py similarity index 55% rename from server/src/tavolo/backfill_elo.py rename to server/packages/tavolo-platform/src/tavolo/platform/backfill_elo.py index 4db9432..9b1aac5 100644 --- a/server/src/tavolo/backfill_elo.py +++ b/server/packages/tavolo-platform/src/tavolo/platform/backfill_elo.py @@ -2,14 +2,14 @@ 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 once after -deploying the ratings feature to backfill pre-existing matches, or any -time ratings need to be rebuilt:: +and each ``match_player.elo_delta`` from scratch. Run any time ratings +need to be rebuilt:: - python -m tavolo.backfill_elo + 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 @@ -17,7 +17,6 @@ from typing import Dict, List from tortoise.transactions import in_transaction -from .config import settings from .stats import apply_elo from .tortoise_mixin import TortoiseMixin @@ -34,12 +33,22 @@ async def backfill_elo() -> int: matches = await Match.all().order_by("finished_at", "id") for match in matches: players = await MatchPlayer.filter(match_id=match.id) - team_members: Dict[str, List[str]] = defaultdict(list) + by_team: Dict[str, List[str]] = defaultdict(list) for player in players: - team_members[player.team].append(player.user_sub) - deltas = await apply_elo( - match.game_type, match.winner_team, team_members + 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() @@ -48,9 +57,16 @@ async def backfill_elo() -> int: 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=settings.database_url, - models_modules=["tavolo.models"], + database_url=args.database_url, + models_modules=["tavolo.platform.models"], ) await mixin._bind() try: @@ -58,8 +74,7 @@ async def _main() -> None: log.info("elo backfill complete: %d matches replayed", replayed) print(f"Recomputed ratings from {replayed} matches.") finally: - if mixin._ctx is not None: - await mixin._ctx.close_connections() + await mixin.aclose() if __name__ == "__main__": diff --git a/server/packages/tavolo-platform/src/tavolo/platform/deadlines.py b/server/packages/tavolo-platform/src/tavolo/platform/deadlines.py new file mode 100644 index 0000000..894de9d --- /dev/null +++ b/server/packages/tavolo-platform/src/tavolo/platform/deadlines.py @@ -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) diff --git a/server/src/tavolo/elo.py b/server/packages/tavolo-platform/src/tavolo/platform/elo.py similarity index 100% rename from server/src/tavolo/elo.py rename to server/packages/tavolo-platform/src/tavolo/platform/elo.py diff --git a/server/packages/tavolo-platform/src/tavolo/platform/engine.py b/server/packages/tavolo-platform/src/tavolo/platform/engine.py new file mode 100644 index 0000000..b422461 --- /dev/null +++ b/server/packages/tavolo-platform/src/tavolo/platform/engine.py @@ -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 {} diff --git a/server/packages/tavolo-platform/src/tavolo/platform/errors.py b/server/packages/tavolo-platform/src/tavolo/platform/errors.py new file mode 100644 index 0000000..1817c20 --- /dev/null +++ b/server/packages/tavolo-platform/src/tavolo/platform/errors.py @@ -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.""" diff --git a/server/src/tavolo/http.py b/server/packages/tavolo-platform/src/tavolo/platform/http.py similarity index 100% rename from server/src/tavolo/http.py rename to server/packages/tavolo-platform/src/tavolo/platform/http.py diff --git a/server/packages/tavolo-platform/src/tavolo/platform/mixin.py b/server/packages/tavolo-platform/src/tavolo/platform/mixin.py new file mode 100644 index 0000000..011f409 --- /dev/null +++ b/server/packages/tavolo-platform/src/tavolo/platform/mixin.py @@ -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) diff --git a/server/src/tavolo/models.py b/server/packages/tavolo-platform/src/tavolo/platform/models.py similarity index 59% rename from server/src/tavolo/models.py rename to server/packages/tavolo-platform/src/tavolo/platform/models.py index 9a67891..f8d9969 100644 --- a/server/src/tavolo/models.py +++ b/server/packages/tavolo-platform/src/tavolo/platform/models.py @@ -1,15 +1,17 @@ """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": +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 with both teams' scores. +* :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/team and whether they won. + ``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.elo`). + :mod:`tavolo.platform.elo`). """ from __future__ import annotations @@ -23,17 +25,13 @@ 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() + # 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"] @@ -53,11 +51,17 @@ class MatchPlayer(Model): 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) + # Game-defined team label; null for games without fixed teams. + team = fields.CharField(max_length=32, null=True) won = fields.BooleanField() - # Elo change this match produced for the player (see tavolo.elo); - # null for matches recorded before ratings existed. + # 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" @@ -70,7 +74,7 @@ class PlayerRating(Model): id = fields.UUIDField(pk=True) # OIDC subject of the player; no local users table. user_sub = fields.CharField(max_length=255) - # Which card game the rating applies to (tavolo.games.GAME_TYPES). + # 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) diff --git a/server/src/tavolo/openapi.py b/server/packages/tavolo-platform/src/tavolo/platform/openapi.py similarity index 100% rename from server/src/tavolo/openapi.py rename to server/packages/tavolo-platform/src/tavolo/platform/openapi.py diff --git a/server/src/tavolo/pagination.py b/server/packages/tavolo-platform/src/tavolo/platform/pagination.py similarity index 100% rename from server/src/tavolo/pagination.py rename to server/packages/tavolo-platform/src/tavolo/platform/pagination.py diff --git a/server/packages/tavolo-platform/src/tavolo/platform/py.typed b/server/packages/tavolo-platform/src/tavolo/platform/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/server/packages/tavolo-platform/src/tavolo/platform/registry.py b/server/packages/tavolo-platform/src/tavolo/platform/registry.py new file mode 100644 index 0000000..1096a22 --- /dev/null +++ b/server/packages/tavolo-platform/src/tavolo/platform/registry.py @@ -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()) diff --git a/server/packages/tavolo-platform/src/tavolo/platform/routes/__init__.py b/server/packages/tavolo-platform/src/tavolo/platform/routes/__init__.py new file mode 100644 index 0000000..d117088 --- /dev/null +++ b/server/packages/tavolo-platform/src/tavolo/platform/routes/__init__.py @@ -0,0 +1 @@ +"""HTTP endpoints of the platform: health, identity, lobby, statistics.""" diff --git a/server/packages/tavolo-platform/src/tavolo/platform/routes/games.py b/server/packages/tavolo-platform/src/tavolo/platform/routes/games.py new file mode 100644 index 0000000..3a14a52 --- /dev/null +++ b/server/packages/tavolo-platform/src/tavolo/platform/routes/games.py @@ -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)) diff --git a/server/packages/tavolo-platform/src/tavolo/platform/routes/health.py b/server/packages/tavolo-platform/src/tavolo/platform/routes/health.py new file mode 100644 index 0000000..15261ad --- /dev/null +++ b/server/packages/tavolo-platform/src/tavolo/platform/routes/health.py @@ -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",)}, + ) diff --git a/server/packages/tavolo-platform/src/tavolo/platform/routes/me.py b/server/packages/tavolo-platform/src/tavolo/platform/routes/me.py new file mode 100644 index 0000000..6f37899 --- /dev/null +++ b/server/packages/tavolo-platform/src/tavolo/platform/routes/me.py @@ -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)}) diff --git a/server/packages/tavolo-platform/src/tavolo/platform/routes/stats.py b/server/packages/tavolo-platform/src/tavolo/platform/routes/stats.py new file mode 100644 index 0000000..ce676ae --- /dev/null +++ b/server/packages/tavolo-platform/src/tavolo/platform/routes/stats.py @@ -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 + ] + }) diff --git a/server/packages/tavolo-platform/src/tavolo/platform/stats.py b/server/packages/tavolo-platform/src/tavolo/platform/stats.py new file mode 100644 index 0000000..ddd23ff --- /dev/null +++ b/server/packages/tavolo-platform/src/tavolo/platform/stats.py @@ -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), + ) diff --git a/server/src/tavolo/store.py b/server/packages/tavolo-platform/src/tavolo/platform/store.py similarity index 58% rename from server/src/tavolo/store.py rename to server/packages/tavolo-platform/src/tavolo/platform/store.py index 9b16873..7dc9e62 100644 --- a/server/src/tavolo/store.py +++ b/server/packages/tavolo-platform/src/tavolo/platform/store.py @@ -1,31 +1,37 @@ """Persistence for live games. -Game state is small, mutable and short-lived, which makes Redis a natural -fit: the whole match is a single JSON value under ``tavolo:game:`` with -a sliding TTL, and a join-code index maps the short code a player shares to -that id. Completed matches are copied to Postgres (see -:mod:`tavolo.models`); Redis keeps serving the finished state until it -expires. +Game sessions are small, mutable and short-lived, which makes Redis a +natural fit: the whole match is a single JSON value under +``tavolo:game:`` with a sliding TTL, and a join-code index maps the +short code a player shares to that id. Completed matches are copied to +Postgres (see :mod:`tavolo.platform.models`); Redis keeps serving the +finished session until it expires. Two implementations satisfy the same interface: * :class:`RedisGameStore` — production, used when ``REDIS_URL`` is set. * :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 channel as a simple "something changed" signal; every open websocket -reloads the state and renders the personalized view. Publishing only a -signal (never the state) means updated state reaches connections on every -worker without leaking hidden hands into the channel. +reloads the session and renders the personalized view. Publishing only a +signal (never the state) means updated state reaches connections on +every worker without leaking hidden information into the channel. -Timeouts (turn auto-play, hand-end auto-continue) are driven by a shared -delayed-deadline queue: producers enqueue an opaque ``member`` string with -a due timestamp, and a consumer on every worker polls for due entries. -Delivery is at-least-once — entries are removed only after they are -processed — so a worker dying mid-processing cannot lose a deadline; -consumers revalidate entries against the live state under the per-game -lock, which makes duplicate deliveries harmless. +Timeouts are driven by a shared delayed-deadline queue: producers +enqueue an opaque ``member`` string with a due timestamp, and a consumer +on every worker polls for due entries (see +:mod:`tavolo.platform.deadlines`). Delivery is at-least-once — entries +are removed only after they are processed — so a worker dying +mid-processing cannot lose a deadline; engines revalidate entries +against the live state, which makes duplicate deliveries harmless. """ from __future__ import annotations @@ -33,12 +39,14 @@ import asyncio import contextlib import json from abc import ABC, abstractmethod +from datetime import datetime 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 .game.state import GameState +from .engine import GameSession, Seat +from .registry import GameRegistry log = getLogger(__name__) @@ -51,20 +59,66 @@ DEADLINES_KEY = "tavolo:deadlines" _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): """Abstract persistence + notification layer for live games.""" @abstractmethod - async def load(self, game_id: str) -> Optional[GameState]: - """Return the live state for ``game_id`` or ``None``.""" + async def load(self, game_id: str) -> Optional[GameSession]: + """Return the live session for ``game_id`` or ``None``.""" @abstractmethod - async def save(self, state: GameState) -> None: - """Persist ``state``, refreshing its TTL and code index.""" + async def save(self, session: GameSession) -> None: + """Persist ``session``, refreshing its TTL and code index.""" @abstractmethod - async def find_by_code(self, code: str) -> Optional[GameState]: - """Return the live state for a join ``code`` or ``None``.""" + async def find_by_code(self, code: str) -> Optional[GameSession]: + """Return the live session for a join ``code`` or ``None``.""" @abstractmethod def lock(self, game_id: str) -> AsyncContextManager[None]: @@ -76,7 +130,7 @@ class GameStore(ABC): @abstractmethod 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 async def add_deadline(self, member: str, due_at: float) -> None: @@ -104,8 +158,9 @@ def _channel(game_id: str) -> str: 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._registry = registry self._ttl = ttl_seconds def lock(self, game_id: str): @@ -114,8 +169,7 @@ class RedisGameStore(GameStore): return self._redis.lock(f"{GAME_KEY_PREFIX}{game_id}:lock", 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}") if raw is None: log.debug("redis load %s: miss", game_id) @@ -123,18 +177,17 @@ class RedisGameStore(GameStore): if isinstance(raw, bytes): raw = raw.decode("utf-8") 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: - - payload = json.dumps(state.to_json()) + async def save(self, session: GameSession) -> None: + payload = json.dumps(session_to_json(session, self._registry)) async with self._redis.pipeline(transaction=True) as pipe: - pipe.set(f"{GAME_KEY_PREFIX}{state.id}", payload, ex=self._ttl) - pipe.set(f"{CODE_KEY_PREFIX}{state.join_code}", state.id, ex=self._ttl) + pipe.set(f"{GAME_KEY_PREFIX}{session.id}", payload, ex=self._ttl) + pipe.set(f"{CODE_KEY_PREFIX}{session.join_code}", session.id, ex=self._ttl) 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()}") if game_id is None: return None @@ -186,8 +239,9 @@ async def _redis_events(pubsub) -> AsyncIterator[None]: class InMemoryGameStore(GameStore): """Process-local store used by tests and when Redis is not configured.""" - def __init__(self) -> None: - self._games: Dict[str, GameState] = {} + def __init__(self, registry: GameRegistry) -> None: + self._registry = registry + self._games: Dict[str, str] = {} self._codes: Dict[str, str] = {} self._locks: Dict[str, asyncio.Lock] = {} self._subscribers: Dict[str, Set[asyncio.Queue]] = {} @@ -205,15 +259,17 @@ class InMemoryGameStore(GameStore): async with self._lock_for(game_id): yield - async def load(self, game_id: str) -> Optional[GameState]: - state = self._games.get(game_id) - return GameState.from_json(state.to_json()) if state else None + async def load(self, game_id: str) -> Optional[GameSession]: + raw = self._games.get(game_id) + if raw is None: + return None + return session_from_json(json.loads(raw), self._registry) - async def save(self, state: GameState) -> None: - self._games[state.id] = GameState.from_json(state.to_json()) - self._codes[state.join_code] = state.id + async def save(self, session: GameSession) -> None: + self._games[session.id] = json.dumps(session_to_json(session, self._registry)) + 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()) if game_id is None: return None diff --git a/server/src/tavolo/tortoise_mixin.py b/server/packages/tavolo-platform/src/tavolo/platform/tortoise_mixin.py similarity index 100% rename from server/src/tavolo/tortoise_mixin.py rename to server/packages/tavolo-platform/src/tavolo/platform/tortoise_mixin.py diff --git a/server/packages/tavolo-platform/src/tavolo/platform/ws.py b/server/packages/tavolo-platform/src/tavolo/platform/ws.py new file mode 100644 index 0000000..b4e77be --- /dev/null +++ b/server/packages/tavolo-platform/src/tavolo/platform/ws.py @@ -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": "", ...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) diff --git a/server/packages/tavolo-platform/tests/helpers.py b/server/packages/tavolo-platform/tests/helpers.py new file mode 100644 index 0000000..f04d9cb --- /dev/null +++ b/server/packages/tavolo-platform/tests/helpers.py @@ -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() diff --git a/server/packages/tavolo-platform/tests/test_deadlines.py b/server/packages/tavolo-platform/tests/test_deadlines.py new file mode 100644 index 0000000..fd2ecce --- /dev/null +++ b/server/packages/tavolo-platform/tests/test_deadlines.py @@ -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() diff --git a/server/tests/test_elo.py b/server/packages/tavolo-platform/tests/test_elo.py similarity index 95% rename from server/tests/test_elo.py rename to server/packages/tavolo-platform/tests/test_elo.py index 17a6c47..413111e 100644 --- a/server/tests/test_elo.py +++ b/server/packages/tavolo-platform/tests/test_elo.py @@ -1,9 +1,9 @@ -"""Unit tests for the chess-style Elo math in :mod:`tavolo.elo`.""" +"""Unit tests for the chess-style Elo math in :mod:`tavolo.platform.elo`.""" from __future__ import annotations import unittest -from tavolo.elo import ( +from tavolo.platform.elo import ( INITIAL_RATING, K_FACTOR, expected_score, diff --git a/server/packages/tavolo-platform/tests/test_routes.py b/server/packages/tavolo-platform/tests/test_routes.py new file mode 100644 index 0000000..6108d20 --- /dev/null +++ b/server/packages/tavolo-platform/tests/test_routes.py @@ -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() diff --git a/server/packages/tavolo-platform/tests/test_stats.py b/server/packages/tavolo-platform/tests/test_stats.py new file mode 100644 index 0000000..fa46372 --- /dev/null +++ b/server/packages/tavolo-platform/tests/test_stats.py @@ -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() diff --git a/server/tests/test_store.py b/server/packages/tavolo-platform/tests/test_store.py similarity index 58% rename from server/tests/test_store.py rename to server/packages/tavolo-platform/tests/test_store.py index 22d8e32..13c0803 100644 --- a/server/tests/test_store.py +++ b/server/packages/tavolo-platform/tests/test_store.py @@ -4,82 +4,89 @@ from __future__ import annotations import asyncio import unittest -from tavolo.game import engine -from tavolo.store import InMemoryGameStore -from tests.helpers 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 + + +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): @async_test async def test_save_load_roundtrip(self) -> None: - store = InMemoryGameStore() - state = engine.create_game("g1", "CODE01", "alice", "alice", target_score=16) - engine.join_game(state, "bob", "bob") - await store.save(state) + store = InMemoryGameStore(_registry()) + session = _session() + await store.save(session) loaded = await store.load("g1") self.assertIsNotNone(loaded) assert loaded is not None self.assertEqual("CODE01", loaded.join_code) - self.assertEqual(16, loaded.target_score) - self.assertEqual(["alice", "bob"], [p.sub for p in loaded.players]) + self.assertEqual("dummy", loaded.game_type) + 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 def test_game_type_roundtrip_and_default(self) -> None: - store = InMemoryGameStore() - state = engine.create_game( - "g1b", "CODE1B", "alice", "alice", game_type="scopone_scientifico" - ) - await store.save(state) - 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 def test_unknown_game_type_rejected(self) -> None: + store = InMemoryGameStore(_registry()) + session = _session() + session.game_type = "nope" + with self.assertRaises(Exception): + await store.save(session) @async_test async def test_load_missing_returns_none(self) -> None: - store = InMemoryGameStore() + store = InMemoryGameStore(_registry()) self.assertIsNone(await store.load("nope")) self.assertIsNone(await store.find_by_code("NOPE01")) @async_test async def test_find_by_code(self) -> None: - store = InMemoryGameStore() - state = engine.create_game("g2", "CODE02", "alice", "alice") - await store.save(state) - found = await store.find_by_code("code02") # case-insensitive + store = InMemoryGameStore(_registry()) + await store.save(_session()) + found = await store.find_by_code("code01") # case-insensitive self.assertIsNotNone(found) assert found is not None - self.assertEqual("g2", found.id) + self.assertEqual("g1", found.id) @async_test async def test_load_returns_a_copy(self) -> None: - store = InMemoryGameStore() - state = engine.create_game("g3", "CODE03", "alice", "alice") - await store.save(state) - first = await store.load("g3") + store = InMemoryGameStore(_registry()) + await store.save(_session()) + first = await store.load("g1") assert first is not None - first.phase = "tampered" - second = await store.load("g3") + first.state["target"] = 999 + second = await store.load("g1") assert second is not None - self.assertEqual("lobby", second.phase) + self.assertEqual(5, second.state["target"]) @async_test async def test_publish_reaches_subscriber(self) -> None: - store = InMemoryGameStore() - state = engine.create_game("g4", "CODE04", "alice", "alice") - await store.save(state) + store = InMemoryGameStore(_registry()) + await store.save(_session()) received = [] - async with store.subscribe("g4") as events: - await store.publish("g4") + async with store.subscribe("g1") as events: + await store.publish("g1") async for _ in events: received.append(True) break @@ -88,7 +95,7 @@ class InMemoryGameStoreTest(unittest.TestCase): @async_test async def test_lock_serializes_concurrent_mutations(self) -> None: - store = InMemoryGameStore() + store = InMemoryGameStore(_registry()) order = [] async def holder() -> None: @@ -109,7 +116,7 @@ class InMemoryGameStoreTest(unittest.TestCase): @async_test async def test_deadline_queue(self) -> None: - store = InMemoryGameStore() + store = InMemoryGameStore(_registry()) self.assertIsNone(await store.next_deadline()) self.assertEqual([], await store.due_deadlines(now=100.0)) diff --git a/server/packages/tavolo-platform/tests/test_ws.py b/server/packages/tavolo-platform/tests/test_ws.py new file mode 100644 index 0000000..a972b3a --- /dev/null +++ b/server/packages/tavolo-platform/tests/test_ws.py @@ -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() diff --git a/server/packages/tavolo-scopone/README.md b/server/packages/tavolo-scopone/README.md new file mode 100644 index 0000000..a9aadc0 --- /dev/null +++ b/server/packages/tavolo-scopone/README.md @@ -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). diff --git a/server/packages/tavolo-scopone/pyproject.toml b/server/packages/tavolo-scopone/pyproject.toml new file mode 100644 index 0000000..3798a7a --- /dev/null +++ b/server/packages/tavolo-scopone/pyproject.toml @@ -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 = [] diff --git a/server/packages/tavolo-scopone/src/tavolo/scopone/__init__.py b/server/packages/tavolo-scopone/src/tavolo/scopone/__init__.py new file mode 100644 index 0000000..6c49ee0 --- /dev/null +++ b/server/packages/tavolo-scopone/src/tavolo/scopone/__init__.py @@ -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"] diff --git a/server/src/tavolo/game/engine.py b/server/packages/tavolo-scopone/src/tavolo/scopone/engine.py similarity index 88% rename from server/src/tavolo/game/engine.py rename to server/packages/tavolo-scopone/src/tavolo/scopone/engine.py index a47c6c1..9d7f9af 100644 --- a/server/src/tavolo/game/engine.py +++ b/server/packages/tavolo-scopone/src/tavolo/scopone/engine.py @@ -2,9 +2,11 @@ Every function here is deterministic and I/O-free (the only side effect is debug logging): it mutates (or reads) -:class:`~tavolo.game.state.GameState` and raises -:class:`~tavolo.game.errors.GameError` subclasses on rule violations. This -makes the whole rule set unit-testable without Redis, Postgres or HTTP. +:class:`~tavolo.scopone.state.ScoponeState` and raises +:class:`~tavolo.platform.errors.GameError` subclasses on rule violations. +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 ----------------- @@ -37,15 +39,17 @@ from itertools import combinations from logging import getLogger from typing import Dict, List, Optional, Sequence, Tuple -from .errors import ( +from tavolo.platform.errors import ( AlreadyJoined, - CardNotInHand, GameFinished, GameNotStarted, IllegalMove, LobbyFull, NotYourTurn, ) + +from .errors import CardNotInHand + from .state import ( DEFAULT_TARGET_SCORE, PHASE_FINISHED, @@ -55,9 +59,9 @@ from .state import ( SUITS, TEAM_NAMES, Card, - GameState, Move, PlayerState, + ScoponeState, parse_card, ) @@ -68,13 +72,12 @@ HAND_SIZE = 10 PLAYERS = 4 # Default seconds the hand-end summary waits before dealing anyway. Games -# carry their own copy in ``GameState.hand_ack_timeout`` (configurable via -# the HAND_ACK_TIMEOUT_SECONDS environment variable). +# carry their own copy in ``ScoponeState.hand_ack_timeout``. DEFAULT_HAND_ACK_TIMEOUT_SECONDS = 30 # Default seconds a player has to play before the server plays a random # 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 # 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( - game_id: str, - join_code: str, creator_sub: str, creator_name: str, target_score: int = DEFAULT_TARGET_SCORE, hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS, turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS, - game_type: str = "scopone_scientifico", napola: bool = True, -) -> GameState: +) -> ScoponeState: """Create a lobby game with the creator seated first.""" if target_score < 1 or target_score > 100: raise IllegalMove("target_score must be between 1 and 100") - return GameState( - id=game_id, - join_code=join_code, - creator_sub=creator_sub, - game_type=game_type, + return ScoponeState( target_score=target_score, napola=napola, phase=PHASE_LOBBY, players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)], hand_ack_timeout=hand_ack_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.""" if state.phase != PHASE_LOBBY: raise GameNotStarted("game has already started") @@ -173,7 +168,7 @@ def join_game(state: GameState, sub: str, name: str) -> None: 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.""" if len(state.players) != PLAYERS: raise GameNotStarted("need exactly four players to start") @@ -181,13 +176,13 @@ def start_game(state: GameState) -> None: _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.""" deadline = datetime.now(timezone.utc) + timedelta(seconds=state.turn_timeout) state.turn_deadline = deadline.isoformat() -def _deal_hand(state: GameState) -> None: +def _deal_hand(state: ScoponeState) -> None: deck = shuffled_deck() for player in state.players: player.hand = [] @@ -203,10 +198,10 @@ def _deal_hand(state: GameState) -> None: for seat in range(PLAYERS): player = _player_at(state, (state.dealer + 1 + seat) % PLAYERS) 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: if player.seat == seat: return player @@ -214,7 +209,7 @@ def _player_at(state: GameState, seat: int) -> PlayerState: def play( - state: GameState, + state: ScoponeState, sub: str, card_code: str, capture_codes: Optional[Sequence[str]] = None, @@ -223,8 +218,8 @@ def play( ``capture_codes`` selects which table cards to capture; it must be a legal capture (see :func:`legal_captures`) when one exists and empty - otherwise. Raises a :class:`~tavolo.game.errors.GameError` subclass on - any violation. + otherwise. Raises a :class:`~tavolo.platform.errors.GameError` subclass + on any violation. """ if state.phase == PHASE_FINISHED: raise GameFinished("the match is over") @@ -297,7 +292,7 @@ def _match_option( 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. 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. 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 - :func:`acknowledge_hand` once all four players have acknowledged (or by - the hand-end timeout in the websocket layer). If the match is over the - game goes to ``finished`` immediately. + :func:`acknowledge_hand` once all four players have acknowledged (or + by the hand-end timeout fired by the platform's deadline scheduler). + If the match is over the game goes to ``finished`` immediately. """ state.turn_deadline = 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 log.debug( - "game %s: hand %d scored A+%d B+%d (totals %d-%d)", - state.id, + "hand %d scored A+%d B+%d (totals %d-%d)", state.hand_number, points[0], points[1], @@ -364,15 +358,13 @@ def _end_hand(state: GameState) -> None: if napola.get(name) == 10: state.phase = PHASE_FINISHED state.winner = team - state.finished_at = datetime.now(timezone.utc).isoformat() - log.info("game %s: team %s swept the denari (napola) and wins", state.id, name) + log.info("team %s swept the denari (napola) and wins", name) return reached = max(a, b) >= state.target_score if reached and a != b: state.phase = PHASE_FINISHED state.winner = 0 if a > b else 1 - state.finished_at = datetime.now(timezone.utc).isoformat() - log.debug("game %s: match ended, team %s wins", state.id, "A" if state.winner == 0 else "B") + log.debug("match ended, team %s wins", "A" if state.winner == 0 else "B") return # 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() -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. 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 -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).""" piles: List[List[Card]] = [[], []] scope: List[int] = [0, 0] @@ -506,13 +498,13 @@ def hand_points(state: GameState) -> Tuple[List[int], Dict[str, object]]: 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. Hands are reduced to a count, except for the requesting player's own - hand. Raises :class:`~tavolo.game.errors.GameNotFound`-style access via - the caller; this function assumes ``sub`` may or may not be seated and - simply omits the hand for non-seated viewers. + hand. Non-seated viewers simply see no hand at all. The platform + merges the session envelope (``id``, ``join_code``, ``game_type``) + into the view itself. """ viewer = state.player_for(sub) players: List[Dict[str, object]] = [] @@ -531,9 +523,6 @@ def state_for_player(state: GameState, sub: str) -> Dict[str, object]: players.append(view) payload: Dict[str, object] = { - "id": state.id, - "join_code": state.join_code, - "game_type": state.game_type, "phase": state.phase, "target_score": state.target_score, "napola": state.napola, diff --git a/server/packages/tavolo-scopone/src/tavolo/scopone/errors.py b/server/packages/tavolo-scopone/src/tavolo/scopone/errors.py new file mode 100644 index 0000000..e32f1be --- /dev/null +++ b/server/packages/tavolo-scopone/src/tavolo/scopone/errors.py @@ -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.""" diff --git a/server/packages/tavolo-scopone/src/tavolo/scopone/plugin.py b/server/packages/tavolo-scopone/src/tavolo/scopone/plugin.py new file mode 100644 index 0000000..3693c38 --- /dev/null +++ b/server/packages/tavolo-scopone/src/tavolo/scopone/plugin.py @@ -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, + }, + ) diff --git a/server/packages/tavolo-scopone/src/tavolo/scopone/py.typed b/server/packages/tavolo-scopone/src/tavolo/scopone/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/server/src/tavolo/game/state.py b/server/packages/tavolo-scopone/src/tavolo/scopone/state.py similarity index 82% rename from server/src/tavolo/game/state.py rename to server/packages/tavolo-scopone/src/tavolo/scopone/state.py index 7656550..7e50472 100644 --- a/server/src/tavolo/game/state.py +++ b/server/packages/tavolo-scopone/src/tavolo/scopone/state.py @@ -1,9 +1,14 @@ """In-memory representation of a scopone scientifico game. -The whole mutable game lives in :class:`GameState`, which is serialized to -and from plain JSON for storage in Redis (see :mod:`tavolo.store`). Keeping -the representation JSON-native means the store needs no custom codecs and -the state is inspectable with ``redis-cli``. +The whole mutable game lives in :class:`ScoponeState`, which is serialized +to and from plain JSON for storage inside the platform's session envelope +(see :mod:`tavolo.platform.store`). Keeping the representation JSON-native +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), ``C`` (coppe), ``S`` (spade) and ``B`` (bastoni); ranks are ``1``..``10``. @@ -144,17 +149,10 @@ class PlayerState: @dataclass -class GameState: - 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" +class ScoponeState: target_score: int = DEFAULT_TARGET_SCORE # Whether the napola rule is scored (denari run from the ace; a full - # suit wins the match instantly). Default on, also for states - # serialized before the option existed. + # suit wins the match instantly). Default on. napola: bool = True phase: str = PHASE_LOBBY players: List[PlayerState] = field(default_factory=list) @@ -167,10 +165,6 @@ class GameState: last_taker: Optional[int] = None # Per-hand points awarded, for a compact audit trail in the API. 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. last_move: Optional[Move] = None # 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. hand_ack_timeout: int = 30 # 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_timeout: int = 30 @@ -188,10 +182,6 @@ class GameState: def to_json(self) -> Dict[str, Any]: return { - "id": self.id, - "join_code": self.join_code, - "creator_sub": self.creator_sub, - "game_type": self.game_type, "target_score": self.target_score, "napola": self.napola, "phase": self.phase, @@ -204,9 +194,6 @@ class GameState: "winner": self.winner, "last_taker": self.last_taker, "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, "acked": list(self.acked), "hand_end_deadline": self.hand_end_deadline, @@ -216,12 +203,8 @@ class GameState: } @staticmethod - def from_json(data: Dict[str, Any]) -> "GameState": - return GameState( - 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")), + def from_json(data: Dict[str, Any]) -> "ScoponeState": + return ScoponeState( target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)), napola=bool(data.get("napola", True)), phase=str(data.get("phase", PHASE_LOBBY)), @@ -234,9 +217,6 @@ class GameState: winner=data.get("winner"), last_taker=data.get("last_taker"), 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, acked=[int(s) for s in data.get("acked", [])], hand_end_deadline=data.get("hand_end_deadline"), diff --git a/server/tests/test_engine.py b/server/packages/tavolo-scopone/tests/test_engine.py similarity index 96% rename from server/tests/test_engine.py rename to server/packages/tavolo-scopone/tests/test_engine.py index b7be55a..d47b224 100644 --- a/server/tests/test_engine.py +++ b/server/packages/tavolo-scopone/tests/test_engine.py @@ -4,19 +4,19 @@ from __future__ import annotations import random import unittest -from tavolo.game import engine -from tavolo.game.errors import ( +from tavolo.scopone import engine +from tavolo.scopone.errors import ( CardNotInHand, GameFinished, GameNotStarted, IllegalMove, NotYourTurn, ) -from tavolo.game.state import ( +from tavolo.scopone.state import ( PHASE_FINISHED, PHASE_PLAYING, Card, - GameState, + ScoponeState, PlayerState, ) @@ -34,12 +34,9 @@ def make_state( scope=None, target: int = 11, last_taker=None, -) -> GameState: +) -> ScoponeState: """Build a controlled game state directly (bypassing the deal).""" - state = GameState( - id="game-1", - join_code="ABC123", - creator_sub="p0", + state = ScoponeState( target_score=target, phase=PHASE_PLAYING, turn=turn, @@ -315,22 +312,22 @@ class NapolaTest(unittest.TestCase): state = make_state([["02D"], [], [], []], table=[]) self.assertTrue(state.napola) 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. data = state.to_json() 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: - self.assertTrue(engine.create_game("g", "CODE42", "p0", "p0").napola) + self.assertTrue(engine.create_game("p0", "p0").napola) self.assertFalse( - engine.create_game("g", "CODE42", "p0", "p0", napola=False).napola + engine.create_game("p0", "p0", napola=False).napola ) class MatchFlowTest(unittest.TestCase): 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)) engine.join_game(state, "p1", "p1") engine.join_game(state, "p2", "p2") @@ -382,7 +379,7 @@ class MatchFlowTest(unittest.TestCase): self.assertNotIn("your_turn", other) 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): engine.join_game(state, f"p{i}", f"p{i}") @@ -405,11 +402,10 @@ class MatchFlowTest(unittest.TestCase): self.assertEqual([], state.table) self.assertTrue(all(not p.hand 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): - 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.""" state = make_state([["02D"], [], [], []], table=["02C"], target=11) @@ -525,7 +521,7 @@ class AutoPlayTest(unittest.TestCase): engine.auto_play(state) 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) for i in range(1, 4): engine.join_game(state, f"p{i}", f"p{i}") diff --git a/server/packages/tavolo-scopone/tests/test_plugin.py b/server/packages/tavolo-scopone/tests/test_plugin.py new file mode 100644 index 0000000..8588223 --- /dev/null +++ b/server/packages/tavolo-scopone/tests/test_plugin.py @@ -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() diff --git a/server/pyproject.toml b/server/pyproject.toml index 4ab18a8..a762017 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -3,12 +3,14 @@ requires = ["setuptools>=68"] build-backend = "setuptools.build_meta" [project] -name = "tavolo" +name = "tavolo-app" 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" requires-python = ">=3.10" dependencies = [ + "tavolo-platform", + "tavolo-scopone", "kaya-core", "kaya-cors", "kaya-session", @@ -17,9 +19,8 @@ dependencies = [ "kaya-openapi", "kaya-rsgi", "granian>=2.0", - "tortoise-orm", - "aerich", "asyncpg", + "aerich", "httpx", "PyJWT[crypto]", "pwo", @@ -38,7 +39,7 @@ otel = [ [tool.setuptools.packages.find] where = ["src"] -namespaces = false +namespaces = true # Database migrations (aerich). See the Migrations section in README.md. [tool.aerich] @@ -49,18 +50,3 @@ location = "./migrations" python_version = "3.12" ignore_missing_imports = true plugins = [] - -# TortoiseORM auto-generates `_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"] diff --git a/server/requirements.txt b/server/requirements.txt index b08533a..7fd6756 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -2,13 +2,19 @@ # This file is autogenerated by pip-compile with Python 3.14 # 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 --extra-index-url https://pypi.org/simple aerich==0.10.1 - # via tavolo (pyproject.toml) + # via tavolo-app (pyproject.toml) aiosqlite==0.22.1 # via tortoise-orm anyio==4.15.1 @@ -19,7 +25,7 @@ anyio==4.15.1 asyncclick==8.4.2.1 # via aerich asyncpg==0.31.0 - # via tavolo (pyproject.toml) + # via tavolo-app (pyproject.toml) certifi==2026.7.22 # via # httpcore @@ -35,7 +41,7 @@ dictdiffer==0.10.0 granian==2.8.3 # via # kaya-rsgi - # tavolo (pyproject.toml) + # tavolo-app (pyproject.toml) h11==0.16.0 # via httpcore httpcore==1.0.9 @@ -43,8 +49,8 @@ httpcore==1.0.9 httpx==0.28.1 # via # kaya-oidc - # tavolo (pyproject.toml) -idna==3.19 + # tavolo-app (pyproject.toml) +idna==3.20 # via # anyio # httpx @@ -57,46 +63,44 @@ kaya-core==0.0.4 # kaya-openapi # kaya-rsgi # kaya-session - # tavolo (pyproject.toml) + # tavolo-app (pyproject.toml) kaya-cors==0.0.4 - # via tavolo (pyproject.toml) + # via tavolo-app (pyproject.toml) kaya-oidc==0.0.4 - # via tavolo (pyproject.toml) + # via tavolo-app (pyproject.toml) kaya-openapi==0.0.4 - # via tavolo (pyproject.toml) + # via tavolo-app (pyproject.toml) kaya-rsgi==0.0.4 - # via tavolo (pyproject.toml) + # via tavolo-app (pyproject.toml) kaya-session==0.0.4 # via # kaya-oidc # kaya-session-redis - # tavolo (pyproject.toml) + # tavolo-app (pyproject.toml) kaya-session-redis==0.0.4 - # via tavolo (pyproject.toml) + # via tavolo-app (pyproject.toml) pwo==0.1.2 # via # kaya-core # kaya-rsgi # kaya-session - # tavolo (pyproject.toml) + # tavolo-app (pyproject.toml) pycparser==3.0 # via cffi pyjwt[crypto]==2.14.0 # via # kaya-oidc - # tavolo (pyproject.toml) + # tavolo-app (pyproject.toml) pypika-tortoise==0.6.5 # via tortoise-orm pyyaml==6.0.3 - # via tavolo (pyproject.toml) + # via tavolo-app (pyproject.toml) redis==8.1.0 # via # kaya-session-redis - # tavolo (pyproject.toml) + # tavolo-app (pyproject.toml) tortoise-orm==1.1.8 - # via - # aerich - # tavolo (pyproject.toml) + # via aerich typing-extensions==4.16.0 # via # anyio diff --git a/server/src/tavolo/__init__.py b/server/src/tavolo/__init__.py deleted file mode 100644 index e29c713..0000000 --- a/server/src/tavolo/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Scopone scientifico backend built on the kaya framework.""" diff --git a/server/src/tavolo/aerich_config.py b/server/src/tavolo/aerich_config.py index 3845776..d4416f1 100644 --- a/server/src/tavolo/aerich_config.py +++ b/server/src/tavolo/aerich_config.py @@ -16,7 +16,7 @@ TORTOISE_ORM = { "connections": {"default": settings.database_url}, "apps": { "models": { - "models": ["tavolo.models", "aerich.models"], + "models": ["tavolo.platform.models", "aerich.models"], "default_connection": "default", } }, diff --git a/server/src/tavolo/app.py b/server/src/tavolo/app.py index 2de354d..457533e 100644 --- a/server/src/tavolo/app.py +++ b/server/src/tavolo/app.py @@ -1,24 +1,31 @@ """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.redis.RedisSessionStore` when ``REDIS_URL`` is set, otherwise an in-memory store — e.g. for tests) - :class:`~kaya.oidc.OIDCMixin` (OIDC login) -- :class:`~tavolo.tortoise_mixin.TortoiseMixin` (Postgres match statistics; - skipped for ``/api/health`` and the OpenAPI documentation endpoints) +- :class:`~tavolo.platform.tortoise_mixin.TortoiseMixin` (Postgres match + statistics; skipped for ``/api/health`` and the OpenAPI documentation + endpoints) - :class:`~kaya.openapi.OpenAPIMixin` (serves the OpenAPI document at ``/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) A :class:`~kaya.cors.CorsMixin` is prepended when CORS is configured via the ``CORS_*`` environment variables (see :mod:`tavolo.config`). A :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). Routes and the websocket handlers are registered by importing -their modules at the bottom; imports must happen after ``app`` is built. +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 @@ -33,12 +40,14 @@ from kaya.openapi import OpenAPIMixin from kaya.session import InMemorySessionStore, SessionMixin, SessionStore from kaya.session.redis import RedisSessionStore 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, settings -from .deadlines import DeadlineSchedulerMixin from .logging_config import configure_logging -from .store import GameStore, InMemoryGameStore, RedisGameStore -from .tortoise_mixin import TortoiseMixin configure_logging(settings.logging_config) log = getLogger(__name__) @@ -79,7 +88,7 @@ def otel_mixin_from_settings(settings: Settings) -> Optional[KayaMixin]: except ImportError as exc: raise RuntimeError( "OTEL_ENABLED is set but kaya-otel is not installed; " - "install tavolo with the 'otel' extra" + "install tavolo-app with the 'otel' extra" ) from exc headers = dict( pair.split("=", 1) @@ -94,19 +103,35 @@ def otel_mixin_from_settings(settings: Settings) -> Optional[KayaMixin]: ) +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 +game_store: GameStore if settings.redis_url is not None: # Lazy client: no connection is opened until a session is actually # loaded/saved, so importing this module never requires a live Redis. 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), + registry, ttl_seconds=settings.game_ttl_seconds, ) log.info("using Redis stores (sessions + live games, game TTL %ds)", settings.game_ttl_seconds) else: session_store = InMemorySessionStore() - game_store = InMemoryGameStore() + game_store = InMemoryGameStore(registry) log.info("REDIS_URL unset: using in-memory stores (sessions + live games)") session_mixin = SessionMixin(session_store) @@ -124,19 +149,30 @@ oidc_mixin = OIDCMixin( ) openapi_mixin = OpenAPIMixin( title="tavolo", - version=_pkg_version("tavolo"), - description="Scopone scientifico multiplayer API", + version=_pkg_version("tavolo-app"), + description="Multiplayer card-game platform API", spec_path="/api/openapi.json", docs_path="/api/docs", ) tortoise_mixin = TortoiseMixin( database_url=settings.database_url, - models_modules=["tavolo.models"], + models_modules=["tavolo.platform.models"], skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}), ) +scheduler = DeadlineScheduler( + game_store, + registry, + heartbeat_ms=settings.deadline_heartbeat_ms, +) +platform = Platform( + registry=registry, + game_store=game_store, + scheduler=scheduler, + oidc=oidc_mixin, +) mixins: list[KayaMixin] = [session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin, - DeadlineSchedulerMixin(game_store)] + PlatformMixin(platform), DeadlineSchedulerMixin(scheduler)] otel_mixin = otel_mixin_from_settings(settings) if otel_mixin is not None: # First in the list: before hooks run in registration order (after hooks @@ -163,17 +199,8 @@ if cors_mixin is not None: ) app = KayaApp(mixins=mixins) -log.debug( - "timeouts: hand_ack=%ds turn=%ds", - settings.hand_ack_timeout_seconds, - settings.turn_timeout_seconds, -) -# Register routes by importing modules. Order does not matter; each module -# pulls ``app`` from here and decorates its handlers at import time. The -# static SPA-shell catch-all is registered last and only matches paths no -# other route claimed (asset files under /static are served by Granian -# itself and never reach the app). -from .routes import games, health, me, stats # noqa: E402,F401 -from . import ws # noqa: E402,F401 -from .routes import static # noqa: E402,F401 +# 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 diff --git a/server/src/tavolo/config.py b/server/src/tavolo/config.py index 0338245..bbf4dd7 100644 --- a/server/src/tavolo/config.py +++ b/server/src/tavolo/config.py @@ -90,10 +90,12 @@ class Settings: # are served by Granian under /static (GRANIAN_STATIC_PATH_* env vars). static_dir: str # 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 # 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 # Upper bound on how long the deadline consumer sleeps between polls. # Locally enqueued deadlines wake the consumer immediately; the diff --git a/server/src/tavolo/deadlines.py b/server/src/tavolo/deadlines.py deleted file mode 100644 index 356e236..0000000 --- a/server/src/tavolo/deadlines.py +++ /dev/null @@ -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) diff --git a/server/src/tavolo/game/__init__.py b/server/src/tavolo/game/__init__.py deleted file mode 100644 index cd972e1..0000000 --- a/server/src/tavolo/game/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Scopone scientifico domain package.""" diff --git a/server/src/tavolo/game/errors.py b/server/src/tavolo/game/errors.py deleted file mode 100644 index b937274..0000000 --- a/server/src/tavolo/game/errors.py +++ /dev/null @@ -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.""" diff --git a/server/src/tavolo/games.py b/server/src/tavolo/games.py deleted file mode 100644 index c1962ab..0000000 --- a/server/src/tavolo/games.py +++ /dev/null @@ -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) diff --git a/server/src/tavolo/routes/__init__.py b/server/src/tavolo/routes/__init__.py deleted file mode 100644 index cd2194d..0000000 --- a/server/src/tavolo/routes/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""HTTP route modules.""" diff --git a/server/src/tavolo/routes/games.py b/server/src/tavolo/routes/games.py deleted file mode 100644 index 40037f3..0000000 --- a/server/src/tavolo/routes/games.py +++ /dev/null @@ -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)) diff --git a/server/src/tavolo/routes/health.py b/server/src/tavolo/routes/health.py deleted file mode 100644 index c042844..0000000 --- a/server/src/tavolo/routes/health.py +++ /dev/null @@ -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",)}, - ) diff --git a/server/src/tavolo/routes/me.py b/server/src/tavolo/routes/me.py deleted file mode 100644 index 7ac5c21..0000000 --- a/server/src/tavolo/routes/me.py +++ /dev/null @@ -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)}) diff --git a/server/src/tavolo/routes/stats.py b/server/src/tavolo/routes/stats.py deleted file mode 100644 index 03ffe7d..0000000 --- a/server/src/tavolo/routes/stats.py +++ /dev/null @@ -1,189 +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 ..elo import INITIAL_RATING -from ..games import DEFAULT_GAME_TYPE, get_game_type -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 - -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), - "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, - "elo_delta": p.elo_delta, - } - 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="Elo rating, aggregated wins, matches played and team " - "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(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") - # Ratings are per game type; without a filter show the default game's. - rating_rows = await PlayerRating.filter(game_type=game_type or DEFAULT_GAME_TYPE) - 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 - 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["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 -async def my_ratings(ctx: HttpContext) -> None: - user = oidc_mixin.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 - ] - }) diff --git a/server/src/tavolo/routes/static.py b/server/src/tavolo/static.py similarity index 96% rename from server/src/tavolo/routes/static.py rename to server/src/tavolo/static.py index 60a84e8..067525a 100644 --- a/server/src/tavolo/routes/static.py +++ b/server/src/tavolo/static.py @@ -17,8 +17,8 @@ from pathlib import Path from kaya.core import HttpContext -from ..app import app -from ..config import settings +from .app import app +from .config import settings async def _send_shell(ctx: HttpContext) -> None: diff --git a/server/src/tavolo/stats.py b/server/src/tavolo/stats.py deleted file mode 100644 index 3839cb1..0000000 --- a/server/src/tavolo/stats.py +++ /dev/null @@ -1,118 +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. The same transaction also -updates the participants' Elo ratings (see :mod:`tavolo.elo`). -""" -from __future__ import annotations - -import uuid -from datetime import datetime, timezone -from logging import getLogger -from typing import Dict, List, Optional - -from tortoise.transactions import in_transaction - -from .elo import match_delta -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 apply_elo( - game_type: str, winner_team: str, team_members: Dict[str, List[str]] -) -> Dict[str, int]: - """Update the Elo ratings of ``team_members`` for ``game_type``. - - ``team_members`` maps a team name ("A"/"B") to its players' subs. - 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. - """ - from .models import PlayerRating - - ratings: Dict[str, PlayerRating] = {} - for subs in team_members.values(): - 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 team_members["A"]], - [ratings[sub].rating for sub in team_members["B"]], - 0 if winner_team == "A" else 1, - ) - deltas: Dict[str, int] = { - **{sub: delta_a for sub in team_members["A"]}, - **{sub: -delta_a for sub in team_members["B"]}, - } - 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(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) - winner_team = TEAM_NAMES[state.winner] - team_members: Dict[str, List[str]] = {"A": [], "B": []} - for player in state.players: - team_members[TEAM_NAMES[player.team]].append(player.sub) - 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=winner_team, - target_score=state.target_score, - hands_played=state.hand_number, - started_at=started_at, - finished_at=finished_at, - ) - deltas = await apply_elo(state.game_type, winner_team, team_members) - 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, - elo_delta=deltas[player.sub], - ) - 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, - ) diff --git a/server/src/tavolo/ws.py b/server/src/tavolo/ws.py deleted file mode 100644 index 38df213..0000000 --- a/server/src/tavolo/ws.py +++ /dev/null @@ -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) diff --git a/server/tests/helpers/__init__.py b/server/tests/helpers/__init__.py index dbf734d..818336c 100644 --- a/server/tests/helpers/__init__.py +++ b/server/tests/helpers/__init__.py @@ -1,4 +1,4 @@ -"""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 diff --git a/server/tests/helpers/asynctest.py b/server/tests/helpers/asynctest.py index 2b16422..3948ea7 100644 --- a/server/tests/helpers/asynctest.py +++ b/server/tests/helpers/asynctest.py @@ -1,7 +1,7 @@ """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.tortoise_mixin.TortoiseMixin` builds one +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". @@ -12,18 +12,21 @@ import asyncio from functools import wraps from typing import Any, Callable, Coroutine -from tavolo.app import tortoise_mixin +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 afterwards.""" + """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: diff --git a/server/tests/helpers/oidc.py b/server/tests/helpers/oidc.py index 0bcb031..8a5c5c6 100644 --- a/server/tests/helpers/oidc.py +++ b/server/tests/helpers/oidc.py @@ -1,8 +1,8 @@ """Helpers for faking the OIDC authenticated user during tests. HTTP handlers funnel through ``oidc_mixin.get_user``; websocket handlers -through :func:`tavolo.auth.get_ws_user`. Patching those two entry points -lets route and websocket tests run entirely in-process with no IdP. +through :func:`tavolo.platform.auth.get_ws_user`. Patching those two entry +points lets route and websocket tests run entirely in-process with no IdP. """ from __future__ import annotations @@ -13,10 +13,9 @@ from typing import Iterator, Optional, Sequence from kaya.oidc import OIDCUser # Import the app first: it pulls in the route modules, which import -# ``tavolo.auth`` themselves. Importing ``auth`` before ``app`` would hit a -# partially initialized module (same constraint as reimpasto). +# ``tavolo.platform.auth`` themselves. 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: diff --git a/server/tests/test_deadlines.py b/server/tests/test_deadlines.py index 82cd337..52c67af 100644 --- a/server/tests/test_deadlines.py +++ b/server/tests/test_deadlines.py @@ -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, -not by connected sockets: these tests seed games, queue their deadlines -and let the background consumer fire them without a single websocket. +not by connected sockets: these tests seed sessions, queue their +deadlines and let the background consumer fire them without a single +websocket. """ from __future__ import annotations @@ -11,47 +12,73 @@ import unittest from datetime import datetime, timedelta, timezone from typing import Optional -from tavolo import deadlines -from tavolo.app import game_store -from tavolo.game import engine -from tavolo.game.state import GameState, PlayerState +from tavolo.app import game_store, platform, scheduler +from tavolo.platform import GameSession, Seat +from tavolo.platform.deadlines import encode +from tavolo.scopone.state import PlayerState, ScoponeState from tests.helpers import async_test PLAYERS = ("alice", "bob", "carol", "dave") -def _ms(iso: str) -> int: - """Epoch milliseconds for an ISO-8601 timestamp (the queue-entry form).""" - return int(datetime.fromisoformat(iso).timestamp() * 1000) - - -def _hand_end_state(game_id: str, deadline: str) -> GameState: - """A game paused on the hand-end summary, waiting for acks.""" - state = GameState( +def _started_session( + game_id: str, + code: str, + turn_timeout: int = 3600, + hand_ack_timeout: int = 3600, +) -> GameSession: + engine = platform.registry.require("scopone_scientifico") + 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, {}) + 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", 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, 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 # with later tests sharing this store. turn_timeout=3600, + hand_end_deadline=deadline, ) - state.players = [ - PlayerState(sub=name, name=name.capitalize(), seat=i) - for i, name in enumerate(PLAYERS) - ] - state.hand_end_deadline = deadline - return state + return session -async def _wait_for(predicate, timeout: float = 5.0) -> Optional[GameState]: - """Poll the store until ``predicate`` holds for the loaded state.""" +async def _wait_for(predicate, timeout: float = 5.0) -> Optional[GameSession]: + """Poll the store until ``predicate`` holds for the loaded session.""" deadline = asyncio.get_running_loop().time() + timeout while asyncio.get_running_loop().time() < deadline: - state = await predicate() - if state is not None: - return state + session = await predicate() + if session is not None: + return session await asyncio.sleep(0.05) return None @@ -59,36 +86,33 @@ async def _wait_for(predicate, timeout: float = 5.0) -> Optional[GameState]: class ConnectionIndependenceTest(unittest.TestCase): @async_test async def test_turn_timeout_fires_with_no_connections(self) -> None: - state = engine.create_game( - "dl-turn-1", "DLT001", "alice", "Alice", - target_score=11, turn_timeout=1, - ) - 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) + session = _started_session("dl-turn-1", "DLT001", turn_timeout=1) + assert session.state.turn_deadline is not None + await game_store.save(session) + await scheduler.sync_deadline(session) # Nobody ever connects: the consumer must still auto-play for Bob # (seat 1, first to act). result = await _wait_for( - lambda: _turn_is(state.id, 2), + lambda: _turn_is(session.id, 2), ) self.assertIsNotNone(result, "turn deadline never fired") 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 # auto-playing while later tests run. - result.turn_timeout = 3600 + result.state.turn_timeout = 3600 await game_store.save(result) @async_test async def test_hand_end_timeout_fires_with_no_connections(self) -> None: deadline = (datetime.now(timezone.utc) + timedelta(seconds=1)).isoformat() - state = _hand_end_state("dl-handend-1", deadline) - await game_store.save(state) - await deadlines.sync_deadline(game_store, state) + session = _hand_end_session("dl-handend-1", deadline) + await game_store.save(session) + await scheduler.sync_deadline(session) # Nobody acks (nobody is even connected): the deadline must deal # the next hand. @@ -97,92 +121,84 @@ class ConnectionIndependenceTest(unittest.TestCase): ) self.assertIsNotNone(result, "hand-end deadline never fired") assert result is not None - self.assertEqual(2, result.hand_number) - self.assertEqual([], result.acked) + self.assertEqual(2, result.state.hand_number) + self.assertEqual([], result.state.acked) -async def _turn_is(game_id: str, turn: int) -> Optional[GameState]: - state = await game_store.load(game_id) - return state if state is not None and state.turn == turn else None +async def _turn_is(game_id: str, turn: int) -> Optional[GameSession]: + session = await game_store.load(game_id) + 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]: - state = await game_store.load(game_id) - return state if state is not None and state.phase == phase else None +async def _phase_is(game_id: str, phase: str) -> Optional[GameSession]: + session = await game_store.load(game_id) + return session if session is not None and session.state.phase == phase else None class ProcessDueTest(unittest.TestCase): - """Direct ``process_due`` behaviour: revalidation and idempotency.""" + """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. + engine = platform.registry.require("scopone_scientifico") deadline = (datetime.now(timezone.utc) - timedelta(seconds=1)).isoformat() - state = _hand_end_state("dl-idem-1", deadline) - await game_store.save(state) - member = deadlines.encode({ - "game_id": state.id, - "kind": deadlines.KIND_HAND_END, - "hand": state.hand_number, - "deadline": _ms(deadline), + session = _hand_end_session("dl-idem-1", deadline) + await game_store.save(session) + current = engine.next_deadline(session) + assert current is not None + member = encode({ + "game_id": session.id, + "kind": current.kind, + "token": current.token, }) - await deadlines.process_due(game_store, member) - await deadlines.process_due(game_store, member) + await scheduler.process_due(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 # Advanced exactly once: hand 2, not hand 3. - self.assertEqual("playing", result.phase) - self.assertEqual(2, result.hand_number) + self.assertEqual("playing", result.state.phase) + self.assertEqual(2, result.state.hand_number) @async_test async def test_stale_entry_is_discarded(self) -> None: - # A turn entry enqueued before a play landed in time: the state's - # deadline has moved, so the entry must not fire. - state = engine.create_game( - "dl-stale-1", "DLS001", "alice", "Alice", - target_score=11, turn_timeout=3600, - ) - for name in PLAYERS[1:]: - engine.join_game(state, name, name.capitalize()) - 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, + # A turn entry enqueued with a forged token: the live state carries + # a different deadline, so the entry must not fire. + session = _started_session("dl-stale-1", "DLS001") + await game_store.save(session) + member = encode({ + "game_id": session.id, + "kind": "turn", + "token": "turn:1:1:0", # not the live token }) 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 - self.assertEqual(state.turn, result.turn) + self.assertEqual(session.state.turn, result.state.turn) # The entry was removed after processing. self.assertNotIn(member, await game_store.due_deadlines(float("inf"))) @async_test async def test_entry_for_expired_game_is_dropped(self) -> None: - member = deadlines.encode({ + member = encode({ "game_id": "dl-gone", - "kind": deadlines.KIND_TURN, - "hand": 1, - "turn": 0, - "deadline": 946684800000, + "kind": "turn", + "token": "turn:1: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"))) @async_test async def test_malformed_entry_is_dropped(self) -> None: 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"))) diff --git a/server/tests/test_routes_games.py b/server/tests/test_routes_games.py index 995d65d..e3388ac 100644 --- a/server/tests/test_routes_games.py +++ b/server/tests/test_routes_games.py @@ -1,4 +1,4 @@ -"""Game lobby route tests via kaya's ASGI transport.""" +"""Game lobby route tests via kaya's ASGI transport (scopone game).""" from __future__ import annotations import unittest @@ -23,7 +23,9 @@ class GamesRouteTest(unittest.TestCase): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: 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) body = created.json() self.assertEqual("lobby", body["phase"]) @@ -80,12 +82,16 @@ class GamesRouteTest(unittest.TestCase): self.assertTrue(default.json()["napola"]) 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.assertFalse(disabled.json()["napola"]) 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) @async_test @@ -120,9 +126,15 @@ class GamesRouteTest(unittest.TestCase): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: with oidc_user("alice"): - zero = await client.post("/api/games", json={"target_score": 0}) - text = await client.post("/api/games", json={"target_score": "eleven"}) - huge = await client.post("/api/games", json={"target_score": 1000}) + zero = await client.post( + "/api/games", json={"options": {"target_score": 0}} + ) + 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, text.status_code) self.assertEqual(400, huge.status_code) @@ -147,6 +159,10 @@ class GameTypesRouteTest(unittest.TestCase): self.assertEqual(["scopone_scientifico"], [g["id"] for g in results]) self.assertEqual("Scopone scientifico", results[0]["name"]) 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 def test_create_defaults_game_type(self) -> None: diff --git a/server/tests/test_routes_me.py b/server/tests/test_routes_me.py index 0dd808b..0c84ef1 100644 --- a/server/tests/test_routes_me.py +++ b/server/tests/test_routes_me.py @@ -42,7 +42,7 @@ class StaticRouteTest(unittest.TestCase): (Path(dist) / "index.html").write_text("spa") 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) async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: index = await client.get("/") @@ -58,7 +58,7 @@ class StaticRouteTest(unittest.TestCase): @async_test async def test_missing_dist_returns_404(self) -> None: 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) async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: response = await client.get("/") diff --git a/server/tests/test_stats.py b/server/tests/test_stats.py index 45a8de4..7a0e544 100644 --- a/server/tests/test_stats.py +++ b/server/tests/test_stats.py @@ -1,395 +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 import unittest -import uuid -from datetime import datetime, timezone from httpx import ASGITransport, AsyncClient -from tavolo.app import app, tortoise_mixin -from tavolo.elo import INITIAL_RATING -from tavolo.game import engine -from tavolo.game.state import GameState -from tavolo.models import Match, MatchPlayer, PlayerRating -from tavolo.stats import save_match_result +from tavolo.app import app, platform, tortoise_mixin +from tavolo.platform import GameSession, Seat +from tavolo.platform.elo import INITIAL_RATING +from tavolo.platform.models import Match, MatchPlayer, PlayerRating +from tavolo.platform.stats import save_match_result +from tavolo.scopone import engine as rules from tests.helpers import async_test, oidc_user - -async def _use_app_db(): - """Bind the same Tortoise context the app uses for this event loop and - return it, so tests can seed rows the route handlers will see.""" - await tortoise_mixin._bind() - ctx = tortoise_mixin._ctx - assert ctx is not None - return ctx +PLAYERS = ("alice", "bob", "carol", "dave") -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", +async def _finished_session(target_score: int = 1) -> GameSession: + engine = platform.registry.require("scopone_scientifico") + session = GameSession( + id="stats-scope-1", + game_type=engine.id, + join_code="SS0001", creator_sub="alice", - target_score=2, - phase=engine.PHASE_PLAYING, - turn=0, - table=[engine.parse_card("02C")], + players=[Seat(user_sub="alice", display_name="Alice")], ) - 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 + 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 -def _finished_state_reversed() -> GameState: - """Same one-capture ending as ``_finished_state``, but team B scores it.""" - state = GameState( - id="stats-game-2", - join_code="STATS2", - creator_sub="alice", - target_score=2, - phase=engine.PHASE_PLAYING, - turn=1, - table=[engine.parse_card("02C")], - ) - from tavolo.game.state import PlayerState, Card - - state.players = [ - PlayerState(sub="alice", name="alice", seat=0), - PlayerState(sub="bob", name="bob", seat=1, hand=[Card.parse("02D")]), - PlayerState(sub="carol", name="carol", seat=2), - PlayerState(sub="dave", name="dave", seat=3), - ] - return state - - -class SaveMatchResultTest(unittest.TestCase): +class ScoponeStatsTest(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) - + async def test_finished_match_persisted_with_scopone_summary(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(state) - await save_match_result(state) # idempotent + await save_match_result(session, engine) self.assertEqual(1, await Match.all().count()) self.assertEqual(4, await MatchPlayer.all().count()) match = await Match.all().first() 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) - winners = await MatchPlayer.filter(won=True) - self.assertEqual({"alice", "carol"}, {p.user_sub for p in winners}) + summary = match.result + 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) - @async_test - async def test_finished_match_updates_elo_ratings(self) -> None: - ctx = await _use_app_db() - state = _finished_state() - engine.play(state, "alice", "02D", ["02C"]) - - with ctx: - await save_match_result(state) - - ratings = { - row.user_sub: row for row in await PlayerRating.all() - } - self.assertEqual(4, len(ratings)) - # Four players at 1500: winners gain K/2, losers lose it. - for winner in ("alice", "carol"): - self.assertEqual(INITIAL_RATING + 16, ratings[winner].rating) - self.assertEqual(1, ratings[winner].matches_played) - for loser in ("bob", "dave"): - self.assertEqual(INITIAL_RATING - 16, ratings[loser].rating) - self.assertEqual(1, ratings[loser].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, "carol": 16, "bob": -16, "dave": -16}, deltas - ) - - @async_test - async def test_elo_ratings_accumulate_across_matches(self) -> None: - ctx = await _use_app_db() - state = _finished_state() - engine.play(state, "alice", "02D", ["02C"]) - reversed_state = _finished_state_reversed() - engine.play(reversed_state, "bob", "02D", ["02C"]) - - with ctx: - await save_match_result(state) - # A second match between the same players, won by team B. - await save_match_result(reversed_state) - - ratings = { - row.user_sub: row.rating for row in await PlayerRating.all() - } - # Match 1: even teams, team A wins (+16/-16). Match 2: team A - # 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 def _seed_two_matches(game_types: tuple = ("scopone_scientifico", "scopone_scientifico")) -> None: - ctx = await _use_app_db() - with ctx: - for index, (a_score, b_score, winner, finished) in enumerate( - [ - (11, 5, "A", datetime(2026, 1, 1, 10, tzinfo=timezone.utc)), - (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), + players = {p.user_sub: p for p in await MatchPlayer.all()} + winner_team = summary["winner_team"] + for sub, row in players.items(): + if row.won: + self.assertEqual(winner_team, row.team) + else: + self.assertNotEqual(winner_team, row.team) + self.assertEqual( + summary["team_a_score"] if row.team == "A" + else summary["team_b_score"], + row.score, ) - - -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()) + # Winners share a team, losers the other. + winners = {sub for sub, row in players.items() if row.won} + self.assertEqual(2, len(winners)) @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"]) - - @async_test - async def test_leaderboard_includes_elo_and_sorts_by_it(self) -> None: - await _seed_two_matches() - ctx = await _use_app_db() + async def test_finished_match_updates_elo(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: - # Bob outranks everyone despite Alice leading on points. - await PlayerRating.create( - id=uuid.uuid4(), - user_sub="bob", - game_type="scopone_scientifico", - 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["bob"]["elo"]) - # Players without a rating row report the initial rating. - self.assertEqual(INITIAL_RATING, by_sub["alice"]["elo"]) - # Elo outranks wins/points. - self.assertEqual("bob", results[0]["user_sub"]) - - @async_test - async def test_leaderboard_elo_scoped_by_game_type(self) -> None: - await _seed_two_matches() - ctx = await _use_app_db() - with ctx: - await PlayerRating.create( - id=uuid.uuid4(), - user_sub="alice", - game_type="scopone_scientifico", - rating=1516, - matches_played=1, - ) - # Alice's rating in another game must not leak into the - # scopone leaderboard. - await PlayerRating.create( - id=uuid.uuid4(), - user_sub="alice", - game_type="other_game", - 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=scopone_scientifico") - 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["alice"]["elo"]) - self.assertEqual(INITIAL_RATING, by_sub["bob"]["elo"]) - - @async_test - async def test_my_matches_include_elo_delta(self) -> None: - ctx = await _use_app_db() - state = _finished_state() - engine.play(state, "alice", "02D", ["02C"]) - with ctx: - await save_match_result(state) - 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) - 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: - 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: - ctx = await _use_app_db() - with ctx: - await PlayerRating.create( - id=uuid.uuid4(), - user_sub="alice", - game_type="scopone_scientifico", - rating=1516, - matches_played=1, - ) - await PlayerRating.create( - id=uuid.uuid4(), - user_sub="bob", - game_type="scopone_scientifico", - 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("alice"): - response = await client.get("/api/me/ratings") - self.assertEqual(200, response.status_code) - self.assertEqual( - [{"game_type": "scopone_scientifico", "rating": 1516, "matches_played": 1}], - response.json()["results"], - ) - - -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"])) + 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( - {"scopone_scientifico", "other_game"}, - {m["game_type"] for m in all_matches.json()["results"]}, + {INITIAL_RATING + 16, INITIAL_RATING - 16}, set(ratings.values()) ) - scoped_results = scoped.json()["results"] - self.assertEqual(1, len(scoped_results)) - self.assertEqual("scopone_scientifico", scoped_results[0]["game_type"]) - self.assertEqual(400, unknown.status_code) @async_test - async def test_leaderboard_filter_by_game_type(self) -> None: - await _seed_two_matches(game_types=("scopone_scientifico", "other_game")) + 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) async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - scoped = await client.get("/api/leaderboard?game_type=scopone_scientifico") - 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, team A 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) + with oidc_user("alice"): + history = await client.get("/api/me/matches") + self.assertEqual(200, history.status_code) + results = history.json()["results"] + self.assertEqual(1, len(results)) + self.assertEqual("scopone_scientifico", results[0]["game_type"]) + self.assertIn("team_a_score", results[0]["result"]) + self.assertIn("your_elo_delta", results[0]) + 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__": diff --git a/server/tests/test_websocket.py b/server/tests/test_websocket.py index cb9e758..dc74ced 100644 --- a/server/tests/test_websocket.py +++ b/server/tests/test_websocket.py @@ -8,14 +8,44 @@ from httpx import ASGITransport, AsyncClient from httpx_ws import WebSocketDisconnect, aconnect_ws from httpx_ws.transport import ASGIWebSocketTransport -from tavolo.app import app, game_store -from tavolo.game import engine -from tavolo.game.state import Card, GameState, PlayerState +from tavolo.app import app, game_store, platform +from tavolo.platform import GameSession, Seat +from tavolo.scopone.state import Card, PlayerState, ScoponeState from tests.helpers import async_test, make_user, oidc_user, ws_users 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): async def _started_game(self, client: AsyncClient) -> dict: """Create a game and seat four players; return the playing state.""" @@ -132,24 +162,34 @@ class WebSocketTest(unittest.TestCase): 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 card left and can capture the only table card.""" - state = GameState( + engine = platform.registry.require("scopone_scientifico") + session = GameSession( id="hand-end-1", + game_type=engine.id, join_code="HEND01", 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, phase="playing", turn=0, table=[Card.parse("02C")], + 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), + ], + hand_ack_timeout=hand_ack_timeout, ) - 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), - ] - state.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): @@ -240,18 +280,16 @@ class HandEndWebSocketTest(unittest.TestCase): class TurnTimeoutWebSocketTest(unittest.TestCase): @async_test async def test_turn_timeout_auto_plays_a_card(self) -> None: - state = engine.create_game( - "turn-timeout-1", "TT0001", "alice", "Alice", - target_score=11, turn_timeout=1, + engine = platform.registry.require("scopone_scientifico") + session = _started_session( + engine, "turn-timeout-1", "TT0001", turn_timeout=1 ) - for name in PLAYERS[1:]: - engine.join_game(state, name, name.capitalize()) - await game_store.save(state) + await game_store.save(session) ws_transport = ASGIWebSocketTransport(app=app) async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: 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() # Bob (seat 1) is first to act and never connects. self.assertEqual(1, first["game"]["turn"])