Compare commits
15
Commits
294a93912d
...
opencode
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b06f23311 | ||
|
|
5a73601ddf | ||
|
|
b2b514ab91 | ||
|
|
031d933204 | ||
|
|
af480bb9b7
|
||
|
|
93d041b004
|
||
|
|
ebeccc937d
|
||
|
|
e0896b4a95
|
||
|
|
e2091ee4df
|
||
|
|
61f3539c4e
|
||
|
|
8fea4fac74
|
||
|
|
c5b6c84408
|
||
|
|
2d1a13f663
|
||
|
|
c1e7f70a3b
|
||
|
|
bf04a9b38d
|
@@ -32,6 +32,7 @@ jobs:
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
builder: multiplatform-builder
|
||||
file: server/Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
|
||||
@@ -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
|
||||
@@ -32,7 +35,8 @@ echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
|
||||
|
||||
When a hand ends but the match is not decided, the game pauses on a
|
||||
**scoring summary screen**: every player sees how each category was won
|
||||
(carte, denara, settebello, primiera, scope) with the running totals and
|
||||
(carte, denara, settebello, primiera, scope — plus napola when enabled)
|
||||
with the running totals and
|
||||
must click "Understood" before the next hand is dealt. If someone is away
|
||||
the next hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS`
|
||||
(default 30s). The match-ending hand is explained on the final screen.
|
||||
@@ -52,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:
|
||||
|
||||
@@ -61,6 +61,23 @@ data:
|
||||
GAME_TTL_SECONDS: "86400"
|
||||
HAND_ACK_TIMEOUT_SECONDS: "30"
|
||||
TURN_TIMEOUT_SECONDS: "30"
|
||||
# CORS (kaya-cors' CorsMixin). Disabled unless CORS_ALLOW_ORIGINS or
|
||||
# CORS_ALLOW_ORIGIN_REGEX is set — unneeded when the SPA and the API are
|
||||
# served from the same origin. See server/.env.example for details.
|
||||
# CORS_ALLOW_ORIGINS: "https://example.com,https://app.example.com" # or "*"
|
||||
# CORS_ALLOW_ORIGIN_REGEX: 'https://tavolo-[a-z0-9-]+\.vercel\.app'
|
||||
# CORS_ALLOW_METHODS: "GET,POST" # default: GET; "*" = all
|
||||
# CORS_ALLOW_HEADERS: "Authorization,Content-Type" # "*" mirrors the request
|
||||
# CORS_ALLOW_CREDENTIALS: "false"
|
||||
# CORS_EXPOSE_HEADERS: ""
|
||||
# CORS_MAX_AGE: "600"
|
||||
# OpenTelemetry (kaya-otel): traces + metrics via OTLP/HTTP, disabled
|
||||
# unless OTEL_ENABLED is set. Requires the otel extra in the image.
|
||||
# OTEL_ENABLED: "true"
|
||||
# OTEL_SERVICE_NAME: "tavolo"
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector.observability:4318"
|
||||
# OTEL_EXPORTER_OTLP_HEADERS: "Authorization=Bearer ..."
|
||||
# OTEL_EXCLUDED_PATHS: "/api/health" # default; paths skipped by tracing
|
||||
# OIDC (provider lives in another namespace).
|
||||
OIDC_CLIENT_ID: tavolo
|
||||
OIDC_POST_LOGIN_REDIRECT: /
|
||||
|
||||
@@ -113,6 +113,22 @@ services:
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30}
|
||||
TURN_TIMEOUT_SECONDS: ${TURN_TIMEOUT_SECONDS:-30}
|
||||
# CORS is disabled unless CORS_ALLOW_ORIGINS or CORS_ALLOW_ORIGIN_REGEX
|
||||
# is set (see server/.env.example for the full list of options).
|
||||
CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-}
|
||||
CORS_ALLOW_ORIGIN_REGEX: ${CORS_ALLOW_ORIGIN_REGEX:-}
|
||||
CORS_ALLOW_METHODS: ${CORS_ALLOW_METHODS:-}
|
||||
CORS_ALLOW_HEADERS: ${CORS_ALLOW_HEADERS:-}
|
||||
CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-}
|
||||
CORS_EXPOSE_HEADERS: ${CORS_EXPOSE_HEADERS:-}
|
||||
CORS_MAX_AGE: ${CORS_MAX_AGE:-}
|
||||
# OpenTelemetry (kaya-otel): disabled unless OTEL_ENABLED is set.
|
||||
# Requires the otel extra in the image (see server/pyproject.toml).
|
||||
OTEL_ENABLED: ${OTEL_ENABLED:-}
|
||||
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-}
|
||||
OTEL_EXCLUDED_PATHS: ${OTEL_EXCLUDED_PATHS:-}
|
||||
ports:
|
||||
- "127.0.0.1:${APP_PORT:-8080}:8080"
|
||||
|
||||
|
||||
@@ -39,6 +39,28 @@ TURN_TIMEOUT_SECONDS=30
|
||||
# schema). Unset logs DEBUG to the console.
|
||||
#LOGGING_CONFIG=/path/to/logging.yaml
|
||||
|
||||
# CORS (via kaya-cors' CorsMixin; same semantics as Starlette's
|
||||
# CORSMiddleware). Disabled unless CORS_ALLOW_ORIGINS or
|
||||
# CORS_ALLOW_ORIGIN_REGEX is set — the app serves the SPA and the API from
|
||||
# the same origin, so no CORS headers are needed by default.
|
||||
# Comma-separated list of origins allowed to make cross-origin requests,
|
||||
# or "*" for any origin:
|
||||
#CORS_ALLOW_ORIGINS=https://example.com,https://app.example.com
|
||||
# Optional regex (fullmatch) allowed origins are additionally checked
|
||||
# against — handy for dynamic preview URLs:
|
||||
#CORS_ALLOW_ORIGIN_REGEX=https://tavolo-[a-z0-9-]+\.vercel\.app
|
||||
# Comma-separated allowed methods, or "*" for all (default GET):
|
||||
#CORS_ALLOW_METHODS=GET,POST
|
||||
# Comma-separated allowed request headers, or "*" to mirror back whatever
|
||||
# the browser requests (default: only the CORS-safelisted headers):
|
||||
#CORS_ALLOW_HEADERS=Authorization,Content-Type
|
||||
# Allow cookies/credentials on cross-origin requests (1/true/yes/on):
|
||||
#CORS_ALLOW_CREDENTIALS=false
|
||||
# Comma-separated response headers exposed to the browser:
|
||||
#CORS_EXPOSE_HEADERS=
|
||||
# Seconds browsers may cache the preflight response (default 600):
|
||||
#CORS_MAX_AGE=600
|
||||
|
||||
# App server
|
||||
APP_HOST=0.0.0.0
|
||||
APP_PORT=8080
|
||||
|
||||
+14
-6
@@ -48,16 +48,24 @@ RUN --mount=type=cache,target=/var/cache/apk \
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY server/pyproject.toml server/README.md server/requirements.txt ./
|
||||
COPY server/src/ ./src/
|
||||
# aerich migration files are a release artifact: the db-migrate compose
|
||||
# service runs `aerich upgrade` from this image before the app starts.
|
||||
COPY server/migrations/ ./migrations/
|
||||
|
||||
COPY server/requirements.txt ./
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
python3 -m venv /opt/venv \
|
||||
&& /opt/venv/bin/pip install --upgrade pip \
|
||||
&& /opt/venv/bin/pip install -r requirements.txt .
|
||||
&& /opt/venv/bin/pip install -r requirements.txt
|
||||
|
||||
COPY server/pyproject.toml server/README.md ./
|
||||
COPY server/src/ ./src/
|
||||
# Sibling distributions, installed from the local tree (PEP 420 namespace
|
||||
# packages sharing the `tavolo` namespace with the app above). --no-deps:
|
||||
# every third-party dependency is already pinned in requirements.txt.
|
||||
COPY server/packages/ ./packages/
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
/opt/venv/bin/pip install --no-deps ./packages/tavolo-platform ./packages/tavolo-scopone .
|
||||
# aerich migration files are a release artifact: the db-migrate compose
|
||||
# service runs `aerich upgrade` from this image before the app starts.
|
||||
COPY server/migrations/ ./migrations/
|
||||
|
||||
# --- Runtime ---------------------------------------------------------------
|
||||
FROM alpine:3.24
|
||||
|
||||
+151
-57
@@ -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,9 +70,22 @@ 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 |
|
||||
| `CORS_ALLOW_ORIGIN_REGEX` | unset | Regex (fullmatch) additionally matched against request origins, e.g. `https://tavolo-[a-z0-9-]+\.vercel\.app` |
|
||||
| `CORS_ALLOW_METHODS` | `GET` | Comma-separated methods allowed for cross-origin requests, or `*` for all |
|
||||
| `CORS_ALLOW_HEADERS` | unset | Comma-separated request headers allowed in cross-origin requests, or `*` to mirror back the requested ones. The CORS-safelisted headers are always allowed |
|
||||
| `CORS_ALLOW_CREDENTIALS` | `false` | `1`/`true`/`yes`/`on` allow cookies/credentials on cross-origin requests |
|
||||
| `CORS_EXPOSE_HEADERS` | unset | Comma-separated response headers exposed to the browser |
|
||||
| `CORS_MAX_AGE` | `600` | Seconds browsers may cache the preflight response |
|
||||
| `OTEL_ENABLED` | `false` | `1`/`true`/`yes`/`on` enable OpenTelemetry traces and metrics (requires the `otel` extra, i.e. `pip install tavolo-app[otel]`) |
|
||||
| `OTEL_SERVICE_NAME` | `tavolo` | `service.name` resource attribute of the exported telemetry |
|
||||
| `OTEL_EXPORTER_OTLP_ENDPOINT` | unset | Base URL of an OTLP/HTTP collector (e.g. `http://localhost:4318`); unset uses the exporter default |
|
||||
| `OTEL_EXPORTER_OTLP_HEADERS` | unset | Comma-separated `key=value` headers sent to the collector (e.g. authentication) |
|
||||
| `OTEL_EXCLUDED_PATHS` | `/api/health` | Comma-separated paths excluded from tracing and metrics (exact matches) |
|
||||
| `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address |
|
||||
|
||||
## Logging
|
||||
@@ -102,28 +127,59 @@ loggers:
|
||||
|
||||
### Redis (live games)
|
||||
|
||||
- `tavolo:game:<uuid>` — the whole match as JSON: players (seat 0/2 = team A,
|
||||
1/3 = team B), hands, table, captured piles, scope, current turn, dealer,
|
||||
scores, phase (`lobby` → `playing` → `finished`). Sliding TTL
|
||||
(`GAME_TTL_SECONDS`).
|
||||
- `tavolo:game:<uuid>` — 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:<JOINCODE>` — the 6-character join code → game id index.
|
||||
- `tavolo:game:<uuid>:lock` — a short-lived lock serializing every mutation.
|
||||
- `tavolo:game:<uuid>:events` — a pub/sub channel carrying "state changed"
|
||||
signals; every open WebSocket reloads the state and pushes the
|
||||
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 (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 and whether they won. Unique per `(match, user_sub)`.
|
||||
seat, game-defined team label (nullable), whether they won, the points
|
||||
they scored, the Elo change the match produced (`elo_delta`) and
|
||||
game-specific extras (`details` JSON). Unique per `(match, user_sub)`.
|
||||
- `player_rating` — current Elo rating per `(user_sub, game_type)`, with
|
||||
the number of rated matches played.
|
||||
|
||||
When a match ends, the result is written transactionally to Postgres
|
||||
(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.platform.elo`): everyone starts at 1500, a team's rating is the
|
||||
mean of its members, and the standard formula
|
||||
`E = 1 / (1 + 10 ** ((R_opp - R_team) / 400))` with `K = 32` decides how
|
||||
many points the match result moves — the same delta for every member of
|
||||
a team, zero-sum between the two teams. Ratings update in the same
|
||||
transaction as the match result. To recompute every rating from the
|
||||
recorded match history (e.g. to backfill matches recorded before ratings
|
||||
existed):
|
||||
|
||||
```sh
|
||||
.venv/bin/python -m tavolo.platform.backfill_elo \
|
||||
--database-url postgres://tavolo:tavolo@localhost:5432/tavolo
|
||||
```
|
||||
|
||||
## REST API
|
||||
|
||||
@@ -132,12 +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}`. 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 (`?limit=&cursor=&game_type=`) |
|
||||
| `GET` | `/api/leaderboard` | Aggregated wins / matches / team points per player (`?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 / points per player, sorted by Elo (`?game_type=`) |
|
||||
|
||||
## WebSocket protocol
|
||||
|
||||
@@ -153,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"]}
|
||||
@@ -181,7 +241,9 @@ player on turn does not move before it, the server plays a random legal
|
||||
card for them (picking one of the legal captures at random when a capture
|
||||
is required), so a disconnected or idle player cannot stall the match. The
|
||||
timeout is `TURN_TIMEOUT_SECONDS` (default 30); the auto-played move is
|
||||
broadcast like any other.
|
||||
broadcast like any other. Deadlines fire from the shared `tavolo:deadlines`
|
||||
queue (see above), not from timers tied to client connections, so the
|
||||
match keeps progressing even with every player disconnected.
|
||||
|
||||
### Hand-end summary
|
||||
|
||||
@@ -203,29 +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` 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
|
||||
@@ -238,28 +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 (Postgres)
|
||||
├── stats.py # finished match -> Postgres persistence
|
||||
├── store.py # Redis / in-memory live-game store
|
||||
├── ws.py # WebSocket live-play endpoint
|
||||
├── game/
|
||||
│ ├── state.py # GameState / PlayerState / Card, JSON (de)serialization
|
||||
│ ├── engine.py # pure scopone scientifico rules
|
||||
│ └── errors.py # typed rule violations
|
||||
└── routes/
|
||||
├── health.py # GET /api/health
|
||||
├── games.py # lobby: create / join / snapshot
|
||||
└── stats.py # match history + leaderboard
|
||||
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)
|
||||
```
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
RUN_IN_TRANSACTION = True
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
CREATE TABLE IF NOT EXISTS "player_rating" (
|
||||
"id" UUID NOT NULL PRIMARY KEY,
|
||||
"user_sub" VARCHAR(255) NOT NULL,
|
||||
"game_type" VARCHAR(32) NOT NULL,
|
||||
"rating" INT NOT NULL,
|
||||
"matches_played" INT NOT NULL,
|
||||
"updated_at" TIMESTAMPTZ NOT NULL,
|
||||
CONSTRAINT "uid_player_rati_user_su_655b53" UNIQUE ("user_sub", "game_type")
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_player_rati_game_ty_3326d1" ON "player_rating" ("game_type", "rating");
|
||||
COMMENT ON TABLE "player_rating" IS 'Current Elo rating of one player for one game type.';
|
||||
ALTER TABLE "match_player" ADD "elo_delta" SMALLINT;"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
ALTER TABLE "match_player" DROP COLUMN "elo_delta";
|
||||
DROP TABLE IF EXISTS "player_rating";"""
|
||||
|
||||
|
||||
MODELS_STATE = (
|
||||
"eJztmltv4jgUgP9KlKeuNFsBvY1Wq5WAUg07pVSF7q6mqiyTGLAa7IztbAd1+9/Xdm7EuR"
|
||||
"RoYUrFyww59nHsz87xufTJnlEXefywB4UztX+znmwCZ0j+yDZ8smzo+6lYCQQceWHPpMuI"
|
||||
"CwYdIYVj6HEkRS7iDsO+wJSork3LoTPfQwK5llaz6NiiBKn/xBRZDE0wF4jJ5omchyXmPu"
|
||||
"KHamyXOnJwTCavGyYg+HuAgKATJDsyOdjdvRRj4qIfiMeP/gMYY+S5GSDYVQNoOVADKtnt"
|
||||
"bff8QvdUUxwBh3rBjKS9/bmYUpJ0DwLsHiod1TZBBDEol7CAiwSeF2GNReGMpUCwACVTdV"
|
||||
"OBi8Yw8BR0+/dxQBzF2tJvUv8c/xFNbaEbAFf9IRh0hgDYuT1SUzB4RyKHErW/mAgF6uk5"
|
||||
"HDcFoqW2ekH7S/Pm4Oj0F42AcjFhulHjsp+1IhQwVNXQU8pqv0JeOdjtKWTFsDNKBnM55X"
|
||||
"Vox4Iq3Nyhvjx1gDsYEYHH2KGbgi0/sh/AQ2Qi1Fd61KiA/1fzJuTf0Pyp/CLD7/Qqamno"
|
||||
"JrUNKXaB4AxAuRLKCsgPZtDzukQU0zd1jQ2QS3ibDUhtSgw3xrcJ4BM1iV+PGmenn2Wrnq"
|
||||
"N6OKsgP+g1Ly+7V8MitqNXsB3t2ZaxfcRELh0oTKsYDENtUyZju1QzJqK+hIWolxqIes4+"
|
||||
"QCZvzDXPsKG7P8NZtlNIXA58D85RgYtRzdbU3bPNsuXy8Ml1AyjyZM8lEYFnqJhsVtPg6k"
|
||||
"aqh/GPHaRcAXPY7XUGw2bvWg0/4/y7p3k1hx3V0tDSuSE9ODWsSTKI9Xd3+MVSj9a3/lXH"
|
||||
"dAqTfsNvtpoTDAQFhD4C6C4yicWxKLPNY0wwn661z4bqfqPf3UariGz8sBAtKMEIOg+PkL"
|
||||
"kg05KeCG0SGc+fhlakePH1BnlQs8xv+2Lwe61H2t19T6V2GMRooLRBy4jmm2aNmSmBBE70"
|
||||
"ktS71ZsKkJWlE1KiLyQVwouNLZdbuJb2GjvY13saJwQCjpiFif6th8wnE1bQK8ge3KXpD6"
|
||||
"UDeDCy7/cphfeUUkj2ZYUAYVFnawmFrQUHjZOTJcID2as0QNBtWVfLxVx9rkA/r4Da1PuA"
|
||||
"wVijVluGd61Wzlu1Ga4tKnJ2qsOFWGcfJuRTNKuc2X3ioOSc5hIHj6F/ZfhglHoIkpLcTK"
|
||||
"FHNpIqO8i2Amar37/MuN2t7tDAettrdWLashMWWpw/vsijcuqegKvag4ziWkYhusTeC+83"
|
||||
"tAmhB7qai7ao85aO2vs9xS/4ZbnYzeCbh3tBGcIT8hXNNeKunAgkTpFnYFYodxNqLjyTYg"
|
||||
"Yfk2ghc6bk6uWaUWgH2s1Bu3nesZ/L4+FNRnphFHcjwya9yFyol2n/VBXrhVEeYGnXF4O9"
|
||||
"dsAYIsLqeNQK9eLILRzMGlOmH5P6bz7wW3OMwiBwMV5Iq5FmIHiXrVRGC77fx4f7+PBnW6"
|
||||
"MtBIjvt7T/E0m/fSk/NaNZxqVOYKqwvbCwflKrbdYFbNSPz44/H50eJ35gIqlyBkv8QFRe"
|
||||
"oCvlmlfcHt9dgRv4qrCyTt0mq/kByzY2Q9DtE28e3bu7W8ZJig5LVnE26bU2EcPFf+kYtV"
|
||||
"R6qjDt85KLWr7Pb/w3iKX2p9AfLLA5kVP3Okdw8zH9K21Ouf/3L2IcF6Wpyr2SBZUP6JNs"
|
||||
"xPtTH9UKhKPuH5BufaliQL2iGFDPFwPkGwUiBZfon4P+VTHhBRXz9sSOsP6zPMx3sSxQAV"
|
||||
"fByFyRMdODXvMfE3f7st8y7z41QKsorbXNy+z5fy/uTSo="
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
RUN_IN_TRANSACTION = True
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
ALTER TABLE "match" ADD "result" JSONB NOT NULL DEFAULT '{}';
|
||||
UPDATE "match" SET "result" = jsonb_build_object(
|
||||
'team_a_score', team_a_score,
|
||||
'team_b_score', team_b_score,
|
||||
'winner_team', winner_team,
|
||||
'target_score', target_score,
|
||||
'hands_played', hands_played);
|
||||
ALTER TABLE "match" DROP COLUMN "winner_team";
|
||||
ALTER TABLE "match" DROP COLUMN "team_b_score";
|
||||
ALTER TABLE "match" DROP COLUMN "team_a_score";
|
||||
ALTER TABLE "match" DROP COLUMN "target_score";
|
||||
ALTER TABLE "match" DROP COLUMN "hands_played";
|
||||
ALTER TABLE "match" ALTER COLUMN "game_type" DROP DEFAULT;
|
||||
ALTER TABLE "match_player" ADD "details" JSONB NOT NULL DEFAULT '{}';
|
||||
ALTER TABLE "match_player" ADD "score" DOUBLE PRECISION NOT NULL DEFAULT 0;
|
||||
UPDATE "match_player" mp SET "score" = CASE WHEN mp.team = 'A'
|
||||
THEN (mp_match.result->>'team_a_score')::float
|
||||
ELSE (mp_match.result->>'team_b_score')::float END
|
||||
FROM "match" mp_match WHERE mp_match.id = mp.match_id;
|
||||
ALTER TABLE "match_player" ALTER COLUMN "team" DROP NOT NULL;
|
||||
ALTER TABLE "match_player" ALTER COLUMN "team" TYPE VARCHAR(32) USING "team"::VARCHAR(32);"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
ALTER TABLE "match" ADD "winner_team" VARCHAR(1) NOT NULL;
|
||||
ALTER TABLE "match" ADD "team_b_score" SMALLINT NOT NULL;
|
||||
ALTER TABLE "match" ADD "team_a_score" SMALLINT NOT NULL;
|
||||
ALTER TABLE "match" ADD "target_score" SMALLINT NOT NULL;
|
||||
ALTER TABLE "match" ADD "hands_played" SMALLINT NOT NULL;
|
||||
ALTER TABLE "match" DROP COLUMN "result";
|
||||
ALTER TABLE "match" ALTER COLUMN "game_type" SET DEFAULT 'scopone_scientifico';
|
||||
ALTER TABLE "match_player" DROP COLUMN "details";
|
||||
ALTER TABLE "match_player" DROP COLUMN "score";
|
||||
ALTER TABLE "match_player" ALTER COLUMN "team" SET NOT NULL;
|
||||
ALTER TABLE "match_player" ALTER COLUMN "team" TYPE VARCHAR(1) USING "team"::VARCHAR(1);"""
|
||||
|
||||
|
||||
MODELS_STATE = (
|
||||
"eJztmm1v4jgQx79KlFc9aa8C+rQ6rU4KlGq5hVIB3TttVUUmMdSqsbOOc13U63c/23l2El"
|
||||
"ooUKj6piVjj+P8hoz/HvNozqgLsX/YA9y5M/8wHk0CZlB8yDd8MkzgealZGjgY47Bn0mXs"
|
||||
"cwYcLowTgH0oTC70HYY8jiiRXS3DoTMPQw5dQ7kZdGJQAuU/fgcNBqfI55CJ5qmYh8HnHv"
|
||||
"QP5dgudcTgiExfN0xA0M8A2pxOoejIxGA3t8KMiAt/QT++9O7tCYLYzQFBrhxA2W05oLRd"
|
||||
"X3fOL1RPOcWx7VAczEja25vzO0qS7kGA3EPpI9umkEAGxCNkcJEA4whrbApnLAycBTCZqp"
|
||||
"saXDgBAZbQzS+TgDiStaHuJP8c/xlNLdPNti/7I3vYHtm2WYiRnILGOzI5lMj4IsIlqMen"
|
||||
"cNwUiLKa8gatr9bg4Oj0N4WA+nzKVKPCZT4pR8BB6Kqgp5RlvEJeBditO8DKYeecNOZiyq"
|
||||
"vQjg0LcMekNsBWvFO/bAzJlMuX8qixgPV3axDibijcVLyA4Wt5GbU0VJOknlL2OWCChQ14"
|
||||
"EfO5oMTRDJajzntqrN3I9TD+sB7yaTLZAvoFqEedXns4snpXcviZ7//Eipc1asuWhrLONe"
|
||||
"vBqRaWZBDj787oqyEvjR/9y7b+riT9Rj9MOScQcGoT+mADN8skNsemXJgniCD/bqU4a64f"
|
||||
"gd7pQIuhJJtCjP8a9i/L45t66KFFDjf+M7BYQDcV1Mw6NQ4Q5oj4h/K2b7FUSUK5KMdp86"
|
||||
"Bn/aNn1Fa339TDJwdoivQqdcPkPrOmScMYOPcPgLl2riWNm4fBHDK/GLhm5HjxbQAxUM9b"
|
||||
"DFVWol2pkfb3NUyt0VKrgNIGrSJabJo1ZroFEDBVjyTvLe9UgqxK9KZEn5G+tpf2fFYBX4"
|
||||
"nlEznIUzGNZWvgQ2Ygoj6rIYuSdwm/Eo17k4p06WP7wdi8/RC+uyR8k7gsoXuzPu9P9jZO"
|
||||
"Tl6ge0WvSuGr2vLK10W+fF1tdb0Eat1vU7i3mns13rXaS3jXatW8ZZu204Bl2nM4Axh3CK"
|
||||
"/YY8BS0SnXhj1kPJWT+P2ocXb6WbSqOcqLswWshz2r2+1cjjSWHILZMt/ZuP9K39Xovd8Z"
|
||||
"jBveET+ECktTYZRiCEg53YdSTTYWLnv4LV2As9nvd3MKudkZaVyve8324KCucItOiCtz8Q"
|
||||
"vsO5SVZN0LTEFVKog9NMwT6bIpzrXtQz7vXze7beNq0G51hp1oS5JsMVVjHu6gbXU1uhBT"
|
||||
"MWfMwbL5Nue4UtLdtWSxvpzrQg4QLtmhVW+tMy4fe+u17K2zAQm3XMvtSbI+69yZ7G7Sfm"
|
||||
"YjUihWaHxLcrTIw2hKvsG5QtwREwHEKUvN+sHRfkIt1COEmYGHZHuc+06JpxfPDMPM3LKG"
|
||||
"Leu8bT5VF4A2WdoIyxYDwMOHLNQ2cu2fFhU3wrKGzdKuz1Y3WgFjkHCjjakR+sWlinAwY0"
|
||||
"KZukyO5YqVjhXHKK16ZDfI6SGRXvm4yR8gRQ98+1EQ+SiIvHU22kJFZHdPXN/VBjNNo3nG"
|
||||
"lao8ddheHaR+UtvU5ifS5I368dnx56PT40SYJ5ZF6rwozNX6C/2w+F6SkCu5Fh23x3df4A"
|
||||
"aePNhd5dw47/kOj41NBoHbJ3gerbv7e4ycnLK98Nhyk6rVggyV/wAtalmoVEHa5zmJWh3n"
|
||||
"Nf80rDL/lOrBkpwTibrXCcHNF1lemXOq9d+/kPmorCpbrUoyLu9Qk2xE/cmXagnCUfd3SL"
|
||||
"f+otOv+oLTr3rx9EvckUOy1A9zMi7brx6+TbHqLX6Ds/7F7Ol/ruuC3w=="
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
# tavolo-platform
|
||||
|
||||
The game-independent half of tavolo: lobby, live play, match history and
|
||||
leaderboards. Everything here works for any game that implements the
|
||||
[`GameEngine`](src/tavolo/platform/engine.py) contract; the package
|
||||
itself ships no game.
|
||||
|
||||
## Contents
|
||||
|
||||
- `engine.py` — the platform↔game contract: `GameEngine` (ABC),
|
||||
`GameSession` (the platform-owned envelope with an opaque `state` blob),
|
||||
`Seat`, `Deadline` (kind + due time + revalidation token) and the
|
||||
`MatchResult`/`PlayerResult` outcome types.
|
||||
- `registry.py` — `GameRegistry`: game id → engine lookup, single source
|
||||
of truth for which games exist.
|
||||
- `mixin.py` — `Platform` (registry + store + scheduler + OIDC, the
|
||||
collaborators every endpoint needs) and `PlatformMixin`, the kaya mixin
|
||||
that registers all routes and the websocket endpoint on an app.
|
||||
- `routes/` — `health` (`GET /api/health`), `me` (`GET /api/me`), `games`
|
||||
(lobby: `GET /api/game-types`, `POST /api/games`, `POST /api/games/join`,
|
||||
`GET /api/games/{id}`) and `stats` (`GET /api/me/matches`,
|
||||
`GET /api/leaderboard`, `GET /api/me/ratings`).
|
||||
- `ws.py` — the live-play websocket (`/ws/games/{id}`): connection
|
||||
lifecycle, the message envelope and the publish/subscribe fan-out.
|
||||
Game-specific actions are dispatched to the session's engine.
|
||||
- `store.py` — live-session persistence: `RedisGameStore` (production)
|
||||
and `InMemoryGameStore` (tests/dev) over a JSON envelope plus the
|
||||
engine's opaque state blob, with per-game locks, change signals and the
|
||||
shared deadline queue.
|
||||
- `deadlines.py` — `DeadlineScheduler`: enqueue the deadline an engine
|
||||
declares after each mutation; a per-loop consumer fires due entries
|
||||
through `engine.fire_deadline` under the per-game lock. Engines
|
||||
revalidate the token, so stale or duplicate deliveries are harmless.
|
||||
- `models.py` — `Match` (`game_type`, timestamps, game-specific JSON
|
||||
`result`), `MatchPlayer` (seat, team, won, points, Elo delta, JSON
|
||||
`details`) and `PlayerRating` (Elo per `(user_sub, game_type)`).
|
||||
- `stats.py` — `save_match_result` (engine `MatchResult` → Postgres,
|
||||
transactionally, with Elo) and `apply_elo`.
|
||||
- `elo.py` — chess-style Elo math generalized to two-team matches.
|
||||
- `backfill_elo.py` — rebuild every rating from the match history:
|
||||
`python -m tavolo.platform.backfill_elo --database-url postgres://…`.
|
||||
- `auth.py`, `http.py`, `pagination.py`, `openapi.py`,
|
||||
`tortoise_mixin.py` — OIDC helpers, JSON helpers, keyset pagination,
|
||||
shared OpenAPI fragments, the TortoiseORM lifecycle mixin.
|
||||
|
||||
## Adding a game
|
||||
|
||||
Implement `GameEngine` (see `engine.py` for the full contract), register
|
||||
it in a `GameRegistry`, and mount `PlatformMixin(Platform(...))` on a
|
||||
`KayaApp` — see the composition root in `tavolo.app`. The canonical
|
||||
example is
|
||||
[`tavolo-scopone`](../tavolo-scopone/README.md).
|
||||
|
||||
## Development (from `server/`)
|
||||
|
||||
```sh
|
||||
.venv/bin/python -m unittest discover -s packages/tavolo-platform/tests
|
||||
.venv/bin/python -m mypy -p tavolo.platform
|
||||
```
|
||||
|
||||
Tests run fully in-process against a `DummyEngine` (a two-player toy game
|
||||
in `tests/helpers.py`): in-memory sqlite, in-memory stores, a patched
|
||||
OIDC user and `httpx` / `httpx-ws` ASGI transports. No test in this
|
||||
package may import a real game.
|
||||
@@ -0,0 +1,38 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tavolo-platform"
|
||||
version = "0.1.0"
|
||||
description = "Game-independent multiplayer game platform (lobby, live play, match history, leaderboards) built on the kaya framework"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"kaya-core",
|
||||
"kaya-session",
|
||||
"kaya-oidc",
|
||||
"kaya-openapi",
|
||||
"tortoise-orm",
|
||||
"redis",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
namespaces = true
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
ignore_missing_imports = true
|
||||
plugins = []
|
||||
|
||||
# TortoiseORM auto-generates `<fk>_id` attributes on ForeignKeyField at
|
||||
# runtime; without the (unavailable here) tortoise mypy plugin the stubs
|
||||
# only declare the relation field. These are real attributes, not bugs.
|
||||
[[tool.mypy.overrides]]
|
||||
module = "tavolo.platform.models"
|
||||
disable_error_code = ["attr-defined"]
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "tavolo.platform.routes.*"
|
||||
disable_error_code = ["attr-defined"]
|
||||
@@ -0,0 +1,53 @@
|
||||
"""The game-independent half of tavolo: lobby, live play, history, ratings.
|
||||
|
||||
This package hosts every concern that does not depend on the rules of a
|
||||
specific game: the lobby HTTP API, the live-game store, the websocket
|
||||
endpoint, the deadline scheduler, match persistence and the Elo
|
||||
leaderboards. A game plugs in by implementing
|
||||
:class:`~tavolo.platform.engine.GameEngine` and registering it in a
|
||||
:class:`~tavolo.platform.registry.GameRegistry`; the application then
|
||||
mounts everything with :class:`~tavolo.platform.mixin.PlatformMixin`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .engine import (
|
||||
Deadline,
|
||||
GameEngine,
|
||||
GameSession,
|
||||
MatchResult,
|
||||
PlayerResult,
|
||||
Seat,
|
||||
)
|
||||
from .errors import (
|
||||
AlreadyJoined,
|
||||
GameError,
|
||||
GameFinished,
|
||||
GameNotFound,
|
||||
GameNotStarted,
|
||||
IllegalMove,
|
||||
LobbyFull,
|
||||
NotYourTurn,
|
||||
)
|
||||
from .mixin import Platform, PlatformMixin
|
||||
from .registry import GameRegistry, UnknownGameType
|
||||
|
||||
__all__ = [
|
||||
"AlreadyJoined",
|
||||
"Deadline",
|
||||
"GameEngine",
|
||||
"GameError",
|
||||
"GameFinished",
|
||||
"GameNotFound",
|
||||
"GameNotStarted",
|
||||
"GameRegistry",
|
||||
"GameSession",
|
||||
"IllegalMove",
|
||||
"LobbyFull",
|
||||
"MatchResult",
|
||||
"NotYourTurn",
|
||||
"Platform",
|
||||
"PlatformMixin",
|
||||
"PlayerResult",
|
||||
"Seat",
|
||||
"UnknownGameType",
|
||||
]
|
||||
+19
-18
@@ -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
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Recompute every Elo rating from the recorded match history.
|
||||
|
||||
Ratings are deterministic given the finished matches, so this replays all
|
||||
matches in chronological order and rewrites the ``player_rating`` table
|
||||
and each ``match_player.elo_delta`` from scratch. Run any time ratings
|
||||
need to be rebuilt::
|
||||
|
||||
python -m tavolo.platform.backfill_elo --database-url postgres://...
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from logging import getLogger
|
||||
from typing import Dict, List
|
||||
|
||||
from tortoise.transactions import in_transaction
|
||||
|
||||
from .stats import apply_elo
|
||||
from .tortoise_mixin import TortoiseMixin
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
|
||||
async def backfill_elo() -> int:
|
||||
"""Rebuild all ratings; returns the number of matches replayed."""
|
||||
from .models import Match, MatchPlayer, PlayerRating
|
||||
|
||||
replayed = 0
|
||||
async with in_transaction():
|
||||
await PlayerRating.all().delete()
|
||||
matches = await Match.all().order_by("finished_at", "id")
|
||||
for match in matches:
|
||||
players = await MatchPlayer.filter(match_id=match.id)
|
||||
by_team: Dict[str, List[str]] = defaultdict(list)
|
||||
for player in players:
|
||||
if player.team is not None:
|
||||
by_team[player.team].append(player.user_sub)
|
||||
# Deterministic team order; the winner is the team of any
|
||||
# player flagged as having won.
|
||||
teams = [by_team[team] for team in sorted(by_team)]
|
||||
winner_index = next(
|
||||
(
|
||||
i
|
||||
for i, members in enumerate(teams)
|
||||
if any(p.won and p.user_sub in members for p in players)
|
||||
),
|
||||
0,
|
||||
)
|
||||
deltas = await apply_elo(match.game_type, teams, winner_index)
|
||||
for player in players:
|
||||
player.elo_delta = deltas[player.user_sub]
|
||||
await player.save()
|
||||
replayed += 1
|
||||
return replayed
|
||||
|
||||
|
||||
async def _main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--database-url",
|
||||
required=True,
|
||||
help="Tortoise database URL, e.g. postgres://user:pass@host/db",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
mixin = TortoiseMixin(
|
||||
database_url=args.database_url,
|
||||
models_modules=["tavolo.platform.models"],
|
||||
)
|
||||
await mixin._bind()
|
||||
try:
|
||||
replayed = await backfill_elo()
|
||||
log.info("elo backfill complete: %d matches replayed", replayed)
|
||||
print(f"Recomputed ratings from {replayed} matches.")
|
||||
finally:
|
||||
await mixin.aclose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(_main())
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Deadline-driven timeouts, independent of player connections.
|
||||
|
||||
In-match timeouts (e.g. scopone's per-turn auto-play and hand-end
|
||||
auto-continue) are driven by absolute deadlines, never by which players
|
||||
(or whether any players) are connected. The platform owns the scheduling
|
||||
machinery; the *meaning* of each deadline belongs to the game engine:
|
||||
|
||||
* after every mutation the engine's
|
||||
:meth:`~tavolo.platform.engine.GameEngine.next_deadline` computes the
|
||||
deadline the new state implies (kind + due time + a revalidation
|
||||
token), and the scheduler enqueues it in the store's shared deadline
|
||||
queue (a Redis sorted set in production, see
|
||||
:mod:`tavolo.platform.store`); enqueueing is idempotent;
|
||||
* a background consumer running on **every** worker polls the queue for
|
||||
due entries and hands them to the engine's
|
||||
:meth:`~tavolo.platform.engine.GameEngine.fire_deadline` under the
|
||||
per-game lock; the engine revalidates the token against the live state
|
||||
and raises :class:`~tavolo.platform.errors.GameError` when the entry
|
||||
was overtaken by events, so duplicate or stale deliveries are harmless.
|
||||
|
||||
Delivery is at-least-once: an entry is removed from the queue only after
|
||||
it has been processed. If a worker dies mid-processing, the entry stays
|
||||
in Redis and another worker's consumer picks it up. Entries whose game
|
||||
has expired are dropped the first time they fire, so the queue is
|
||||
self-cleaning.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from logging import getLogger
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from kaya.core import KayaApp, KayaMixin
|
||||
|
||||
from .engine import GameSession
|
||||
from .errors import GameError
|
||||
from .registry import GameRegistry
|
||||
from .stats import save_match_result
|
||||
from .store import GameStore
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
|
||||
def encode(entry: Dict[str, Any]) -> str:
|
||||
"""Canonical queue-member encoding for a deadline entry."""
|
||||
return json.dumps(entry, sort_keys=True)
|
||||
|
||||
|
||||
def _decode(member: Any) -> Optional[Dict[str, Any]]:
|
||||
if isinstance(member, bytes):
|
||||
member = member.decode("utf-8")
|
||||
if not isinstance(member, str):
|
||||
return None
|
||||
try:
|
||||
entry = json.loads(member)
|
||||
except ValueError:
|
||||
return None
|
||||
return entry if isinstance(entry, dict) else None
|
||||
|
||||
|
||||
class DeadlineScheduler:
|
||||
"""Enqueue and fire the deadlines game engines ask for.
|
||||
|
||||
Holds the store and the registry so a single instance serves every
|
||||
game type. One consumer task (and its wake-up event) is kept per
|
||||
event loop — tests run each test on a fresh loop.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: GameStore,
|
||||
registry: GameRegistry,
|
||||
heartbeat_ms: int = 1000,
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._registry = registry
|
||||
self._heartbeat = heartbeat_ms / 1000
|
||||
self._consumers: Dict[asyncio.AbstractEventLoop, asyncio.Task] = {}
|
||||
self._wake_events: Dict[asyncio.AbstractEventLoop, asyncio.Event] = {}
|
||||
|
||||
async def sync_deadline(self, session: GameSession) -> None:
|
||||
"""Enqueue the deadline the current state implies, if any.
|
||||
|
||||
Called after every mutation that can set a deadline and as a
|
||||
backstop when a client connects. Enqueueing is idempotent: an
|
||||
identical entry is already queued with the same due time, so
|
||||
re-adding it changes nothing.
|
||||
"""
|
||||
engine = self._registry.require(session.game_type)
|
||||
deadline = engine.next_deadline(session)
|
||||
if deadline is None:
|
||||
return
|
||||
entry = {
|
||||
"game_id": session.id,
|
||||
"kind": deadline.kind,
|
||||
"token": deadline.token,
|
||||
}
|
||||
self.ensure_consumer()
|
||||
due_at = deadline.due_at.timestamp()
|
||||
await self._store.add_deadline(encode(entry), due_at)
|
||||
wake = self._wake_events.get(asyncio.get_running_loop())
|
||||
if wake is not None:
|
||||
wake.set()
|
||||
|
||||
async def finalize_mutation(self, session: GameSession) -> None:
|
||||
"""Persist a successful mutation, notify subscribers and enqueue
|
||||
the next deadline.
|
||||
|
||||
Callers must hold the per-game lock. Handles the terminal
|
||||
transition: the match result is written to Postgres once
|
||||
(guarded by ``stats_saved``).
|
||||
"""
|
||||
engine = self._registry.require(session.game_type)
|
||||
if engine.is_finished(session):
|
||||
if session.finished_at is None:
|
||||
session.finished_at = datetime.now(timezone.utc)
|
||||
await save_match_result(session, engine)
|
||||
log.info("game %s (%s) finished", session.id, session.game_type)
|
||||
await self._store.save(session)
|
||||
await self._store.publish(session.id)
|
||||
await self.sync_deadline(session)
|
||||
|
||||
async def process_due(self, member: Any) -> None:
|
||||
"""Fire a single due deadline entry.
|
||||
|
||||
The engine revalidates the entry against the live state under
|
||||
the per-game lock; stale or foreign entries are discarded
|
||||
without effect. The entry is removed from the queue once handled
|
||||
(including "nothing to do"); if handling fails unexpectedly
|
||||
(e.g. the lock cannot be acquired), the entry is left in the
|
||||
queue so another consumer retries it.
|
||||
"""
|
||||
entry = _decode(member)
|
||||
if entry is None:
|
||||
log.warning("deadline consumer: dropping malformed entry %r", member)
|
||||
await self._store.remove_deadline(member)
|
||||
return
|
||||
game_id = entry.get("game_id")
|
||||
kind = entry.get("kind")
|
||||
token = entry.get("token")
|
||||
if not isinstance(game_id, str) or not isinstance(kind, str):
|
||||
await self._store.remove_deadline(member)
|
||||
return
|
||||
async with self._store.lock(game_id):
|
||||
session = await self._store.load(game_id)
|
||||
if session is not None:
|
||||
engine = self._registry.require(session.game_type)
|
||||
try:
|
||||
engine.fire_deadline(session, kind, str(token))
|
||||
except GameError:
|
||||
# Stale or inapplicable entry: nothing to do.
|
||||
pass
|
||||
else:
|
||||
await self.finalize_mutation(session)
|
||||
await self._store.remove_deadline(member)
|
||||
|
||||
# --- consumer lifecycle -------------------------------------------------
|
||||
|
||||
def ensure_consumer(
|
||||
self, loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
) -> None:
|
||||
"""Start the deadline consumer on the given (or running) loop if
|
||||
not yet running.
|
||||
|
||||
Called lazily whenever a deadline is enqueued (the ASGI test
|
||||
transport never fires the lifespan hooks, so the mixin's
|
||||
``setup`` alone is not enough) and on application startup. The
|
||||
explicit ``loop`` matters at startup: under RSGI granian calls
|
||||
``setup`` before the loop runs, so ``asyncio.get_running_loop()``
|
||||
would fail there.
|
||||
"""
|
||||
if loop is None:
|
||||
loop = asyncio.get_running_loop()
|
||||
for old in list(self._consumers):
|
||||
if old.is_closed():
|
||||
self._consumers.pop(old, None)
|
||||
self._wake_events.pop(old, None)
|
||||
task = self._consumers.get(loop)
|
||||
if task is None or task.done():
|
||||
self._wake_events[loop] = asyncio.Event()
|
||||
self._consumers[loop] = loop.create_task(self._run(loop))
|
||||
log.debug("deadline consumer started")
|
||||
|
||||
def stop_consumer(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||
task = self._consumers.pop(loop, None)
|
||||
self._wake_events.pop(loop, None)
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
|
||||
async def _run(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||
wake = self._wake_events[loop]
|
||||
while True:
|
||||
# Clear before polling so an enqueue racing the poll re-wakes us.
|
||||
wake.clear()
|
||||
delay = self._heartbeat
|
||||
try:
|
||||
for member in await self._store.due_deadlines(time.time()):
|
||||
try:
|
||||
await self.process_due(member)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
# Left in the queue; retried on the next pass.
|
||||
log.exception("deadline consumer: failed to process %r", member)
|
||||
next_due = await self._store.next_deadline()
|
||||
if next_due is not None:
|
||||
delay = max(0.0, min(self._heartbeat, next_due - time.time()))
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
log.exception("deadline consumer: poll failed; retrying")
|
||||
try:
|
||||
await asyncio.wait_for(wake.wait(), timeout=delay)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
|
||||
class DeadlineSchedulerMixin(KayaMixin):
|
||||
"""Run the deadline consumer for the whole app lifetime.
|
||||
|
||||
Every worker (and every pod) runs the same consumer; coordination
|
||||
happens exclusively through the shared deadline queue and the
|
||||
per-game locks, so any worker may fire any game's deadline.
|
||||
"""
|
||||
|
||||
def __init__(self, scheduler: DeadlineScheduler) -> None:
|
||||
self._scheduler = scheduler
|
||||
|
||||
def apply(self, app: KayaApp) -> None:
|
||||
pass
|
||||
|
||||
def setup(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||
self._scheduler.ensure_consumer(loop)
|
||||
|
||||
def shutdown(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||
self._scheduler.stop_consumer(loop)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Chess-style Elo ratings, generalized to two-team matches.
|
||||
|
||||
Every player starts at :data:`INITIAL_RATING`. A team's rating is the mean
|
||||
of its members' current ratings, so the usual chess formula applies
|
||||
unchanged between the two teams:
|
||||
|
||||
* expected score ``E = 1 / (1 + 10 ** ((R_opponent - R_team) / 400))``
|
||||
* actual score ``S`` is 1 for a win and 0 for a loss (matches never draw)
|
||||
* every member of a team gains/loses the same ``round(K * (S - E))``
|
||||
|
||||
Deltas are rounded to integers and ratings are stored as integers, so the
|
||||
system is exactly zero-sum: what the winners gain the losers lose.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence
|
||||
|
||||
INITIAL_RATING = 1500
|
||||
K_FACTOR = 32
|
||||
|
||||
|
||||
def expected_score(rating: float, opponent_rating: float) -> float:
|
||||
"""Expected score (0..1) of a side rated ``rating`` against
|
||||
``opponent_rating``."""
|
||||
return 1.0 / (1.0 + 10.0 ** ((opponent_rating - rating) / 400.0))
|
||||
|
||||
|
||||
def team_rating(ratings: Sequence[float]) -> float:
|
||||
"""A team's rating is the mean of its members' ratings."""
|
||||
if not ratings:
|
||||
raise ValueError("a team needs at least one rating")
|
||||
return sum(ratings) / len(ratings)
|
||||
|
||||
|
||||
def match_delta(
|
||||
team_a_ratings: Sequence[float],
|
||||
team_b_ratings: Sequence[float],
|
||||
winner_team: int,
|
||||
) -> int:
|
||||
"""Rating change applied to each member of team A.
|
||||
|
||||
``winner_team`` is 0 when team A won, 1 when team B won. Team B
|
||||
members change by the negation of the returned value (zero-sum).
|
||||
"""
|
||||
rating_a = team_rating(team_a_ratings)
|
||||
rating_b = team_rating(team_b_ratings)
|
||||
expected = expected_score(rating_a, rating_b)
|
||||
score = 1.0 if winner_team == 0 else 0.0
|
||||
return round(K_FACTOR * (score - expected))
|
||||
@@ -0,0 +1,247 @@
|
||||
"""The contract between the platform and a game implementation.
|
||||
|
||||
The platform owns everything that is game-independent: the lobby, live
|
||||
game storage, websockets, timeouts, match history and leaderboards. A
|
||||
game (e.g. scopone scientifico) implements :class:`GameEngine` and plugs
|
||||
into the platform through :class:`~tavolo.platform.registry.GameRegistry`;
|
||||
the platform never imports a concrete game module.
|
||||
|
||||
The central type is :class:`GameSession`: the platform-owned envelope
|
||||
carrying the lifecycle metadata (id, join code, seats, timestamps) plus
|
||||
an opaque ``state`` blob that only the engine interprets. The engine
|
||||
serializes/deserializes that blob (:meth:`GameEngine.state_to_json` /
|
||||
:meth:`GameEngine.state_from_json`) so the store stays game-agnostic.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Mapping, Optional
|
||||
|
||||
from .errors import GameError
|
||||
|
||||
__all__ = [
|
||||
"Deadline",
|
||||
"GameEngine",
|
||||
"GameSession",
|
||||
"MatchResult",
|
||||
"PlayerResult",
|
||||
"Seat",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Seat:
|
||||
"""One occupied chair in a game, owned by the platform.
|
||||
|
||||
Games may keep their own per-player state internally; the seat is
|
||||
what the platform needs for the lobby, for access control and for
|
||||
persisting participations.
|
||||
"""
|
||||
|
||||
user_sub: str
|
||||
display_name: str
|
||||
# Game-defined team label ("A"/"B", ...); ``None`` for games without
|
||||
# fixed teams.
|
||||
team: Optional[str] = None
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"user_sub": self.user_sub,
|
||||
"display_name": self.display_name,
|
||||
"team": self.team,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_json(data: Mapping[str, Any]) -> "Seat":
|
||||
return Seat(
|
||||
user_sub=str(data["user_sub"]),
|
||||
display_name=str(data["display_name"]),
|
||||
team=data.get("team"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameSession:
|
||||
"""The platform-owned envelope of a live game.
|
||||
|
||||
``state`` is opaque to the platform: only the engine registered for
|
||||
``game_type`` reads or writes it.
|
||||
"""
|
||||
|
||||
id: str
|
||||
game_type: str
|
||||
join_code: str
|
||||
creator_sub: str
|
||||
players: List[Seat] = field(default_factory=list)
|
||||
created_at: Optional[datetime] = None
|
||||
finished_at: Optional[datetime] = None
|
||||
# Set once the finished match has been copied to Postgres.
|
||||
stats_saved: bool = False
|
||||
state: Any = None
|
||||
|
||||
def seated(self, sub: str) -> bool:
|
||||
"""Whether ``sub`` occupies a seat in this game."""
|
||||
return self.seat_of(sub) is not None
|
||||
|
||||
def seat_of(self, sub: str) -> Optional[Seat]:
|
||||
for seat in self.players:
|
||||
if seat.user_sub == sub:
|
||||
return seat
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Deadline:
|
||||
"""A timeout the engine wants the platform to fire.
|
||||
|
||||
``kind`` is an engine-defined string (e.g. ``"turn"``); ``token`` is
|
||||
an opaque revalidation token: when the deadline fires, the engine must
|
||||
recompute it from the live session and refuse to act (raising
|
||||
:class:`~tavolo.platform.errors.GameError`) if it no longer matches,
|
||||
which makes duplicate or overtaken deliveries harmless.
|
||||
"""
|
||||
|
||||
kind: str
|
||||
due_at: datetime
|
||||
token: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlayerResult:
|
||||
"""The outcome of a finished match for one participant."""
|
||||
|
||||
user_sub: str
|
||||
seat: int
|
||||
won: bool
|
||||
team: Optional[str] = None
|
||||
# Points scored, aggregated by the leaderboard.
|
||||
score: float = 0.0
|
||||
# Game-specific extras persisted alongside the participation.
|
||||
details: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchResult:
|
||||
"""The game-independent outcome of a finished match.
|
||||
|
||||
``teams`` groups the participants' subs into the sides the Elo rating
|
||||
treats as opponents (exactly two for now); ``winner_team`` is the
|
||||
index of the winning side. ``summary`` is persisted verbatim as the
|
||||
match's JSON ``result`` column.
|
||||
"""
|
||||
|
||||
teams: List[List[str]]
|
||||
winner_team: int
|
||||
players: List[PlayerResult]
|
||||
summary: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class GameEngine(ABC):
|
||||
"""The interface every playable game implements.
|
||||
|
||||
Implementations must be deterministic and I/O-free apart from
|
||||
logging: all persistence, locking and transport concerns belong to
|
||||
the platform. Rule violations are reported by raising
|
||||
:class:`~tavolo.platform.errors.GameError` subclasses.
|
||||
"""
|
||||
|
||||
#: Unique id carried by sessions and persisted matches.
|
||||
id: str
|
||||
#: Human-readable name and description, for the lobby UI.
|
||||
name: str
|
||||
description: str
|
||||
min_players: int
|
||||
max_players: int
|
||||
#: JSON-schema fragment describing the game-specific creation options
|
||||
#: accepted by :meth:`create` (exposed via ``GET /api/game-types``).
|
||||
options_schema: Mapping[str, Any] = {}
|
||||
|
||||
@abstractmethod
|
||||
def create(self, session: GameSession, options: Mapping[str, Any]) -> None:
|
||||
"""Initialize ``session.state`` for a fresh lobby game.
|
||||
|
||||
The platform has already filled the session envelope and seated
|
||||
the creator (``session.players[0]``, without a team label yet).
|
||||
Assign the creator's team here if the game has fixed teams, then
|
||||
validate ``options`` (the opaque object from the create request;
|
||||
raise :class:`~tavolo.platform.errors.GameError` on invalid
|
||||
values) and initialize ``session.state``.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def join(self, session: GameSession, user_sub: str, display_name: str) -> None:
|
||||
"""Seat a player, starting the match when the lobby fills up.
|
||||
|
||||
Must append a :class:`Seat` to ``session.players`` and mirror
|
||||
whatever per-player state the game keeps in ``session.state``.
|
||||
Raises ``AlreadyJoined`` / ``LobbyFull`` / ``GameNotStarted``.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def handle_action(
|
||||
self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any]
|
||||
) -> None:
|
||||
"""Apply one player action received over the websocket.
|
||||
|
||||
``payload`` is the client message minus its ``action`` field.
|
||||
Unknown actions and rule violations raise
|
||||
:class:`~tavolo.platform.errors.GameError`.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def view_for(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
|
||||
"""The personalized game view for one player.
|
||||
|
||||
Must hide information the player is not entitled to (e.g. other
|
||||
players' hands). The platform merges in the envelope fields
|
||||
(``id``, ``join_code``, ``game_type``) itself.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
|
||||
"""Game-specific fields of the lobby payload (options echo, phase)."""
|
||||
|
||||
@abstractmethod
|
||||
def in_lobby(self, session: GameSession) -> bool:
|
||||
"""Whether the game is still waiting for players."""
|
||||
|
||||
@abstractmethod
|
||||
def is_finished(self, session: GameSession) -> bool:
|
||||
"""Whether the match is over and a result can be extracted."""
|
||||
|
||||
@abstractmethod
|
||||
def result(self, session: GameSession) -> MatchResult:
|
||||
"""The outcome of the match. Only called when finished."""
|
||||
|
||||
@abstractmethod
|
||||
def state_to_json(self, state: Any) -> Dict[str, Any]:
|
||||
"""Serialize the opaque game state to plain JSON."""
|
||||
|
||||
@abstractmethod
|
||||
def state_from_json(self, data: Mapping[str, Any]) -> Any:
|
||||
"""Rebuild the opaque game state from its JSON form."""
|
||||
|
||||
@abstractmethod
|
||||
def next_deadline(self, session: GameSession) -> Optional[Deadline]:
|
||||
"""The deadline the current state implies, if any.
|
||||
|
||||
Called after every mutation; the platform enqueues the returned
|
||||
deadline (enqueueing is idempotent) and never removes previously
|
||||
enqueued ones — stale entries are discarded at fire time by
|
||||
:meth:`fire_deadline` revalidation.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def fire_deadline(self, session: GameSession, kind: str, token: str) -> None:
|
||||
"""Apply the timeout action for a due deadline.
|
||||
|
||||
Must recompute the current deadline and raise
|
||||
:class:`~tavolo.platform.errors.GameError` without acting when
|
||||
``kind``/``token`` no longer match the live state.
|
||||
"""
|
||||
|
||||
def game_over_view(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
|
||||
"""Extra fields merged into the ``game_over`` websocket message."""
|
||||
return {}
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Typed errors shared between the platform and game implementations.
|
||||
|
||||
Game engines raise :class:`GameError` subclasses on rule/validation
|
||||
failures; the platform translates them into 4xx HTTP responses or
|
||||
``error`` WebSocket messages, using :attr:`GameError.code` as the
|
||||
machine-readable error code. Engines themselves stay transport-agnostic.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class GameError(Exception):
|
||||
"""Base class for every rule/validation failure a game can raise."""
|
||||
|
||||
#: Machine-readable code carried by WebSocket ``error`` messages.
|
||||
code = "illegal_move"
|
||||
|
||||
|
||||
class IllegalMove(GameError):
|
||||
"""The requested action violates the rules of the game."""
|
||||
|
||||
|
||||
class NotYourTurn(GameError):
|
||||
"""A player attempted to act out of turn."""
|
||||
|
||||
|
||||
class GameNotStarted(GameError):
|
||||
"""An action was attempted before the game left the lobby."""
|
||||
|
||||
|
||||
class GameFinished(GameError):
|
||||
"""An action was attempted after the match ended."""
|
||||
|
||||
|
||||
class LobbyFull(GameError):
|
||||
"""A game already has its full complement of players."""
|
||||
|
||||
|
||||
class AlreadyJoined(GameError):
|
||||
"""A player tried to join a game they are already seated in."""
|
||||
|
||||
|
||||
class GameNotFound(GameError):
|
||||
"""No live game exists for the given id or join code."""
|
||||
@@ -0,0 +1,50 @@
|
||||
"""The :class:`PlatformMixin`: mounts the whole platform on a kaya app.
|
||||
|
||||
``Platform`` bundles the collaborators every platform endpoint needs —
|
||||
the game registry, the live-game store, the deadline scheduler and the
|
||||
OIDC mixin used for authentication — so route and websocket modules
|
||||
never reach for module-global state. :class:`PlatformMixin` is a plain
|
||||
:class:`~kaya.core.KayaMixin`: applying it registers the lobby, stats
|
||||
and health HTTP routes plus the live-play websocket endpoint, and the
|
||||
app stays a :class:`~kaya.core.KayaApp` (both ASGI and RSGI keep
|
||||
working).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from kaya.core import KayaApp, KayaMixin
|
||||
from kaya.oidc import OIDCMixin
|
||||
|
||||
from .deadlines import DeadlineScheduler
|
||||
from .registry import GameRegistry
|
||||
from .store import GameStore
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Platform:
|
||||
"""The collaborators shared by all platform endpoints."""
|
||||
|
||||
registry: GameRegistry
|
||||
game_store: GameStore
|
||||
scheduler: DeadlineScheduler
|
||||
oidc: OIDCMixin
|
||||
|
||||
|
||||
class PlatformMixin(KayaMixin):
|
||||
"""Register the game-independent routes and the websocket endpoint."""
|
||||
|
||||
def __init__(self, platform: Platform) -> None:
|
||||
self.platform = platform
|
||||
|
||||
def apply(self, app: KayaApp) -> None:
|
||||
# Imported here so module import order stays acyclic: the route
|
||||
# modules reference Platform only for typing.
|
||||
from . import ws
|
||||
from .routes import games, health, me, stats
|
||||
|
||||
health.register(app, self.platform)
|
||||
me.register(app, self.platform)
|
||||
games.register(app, self.platform)
|
||||
stats.register(app, self.platform)
|
||||
ws.register(app, self.platform)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Tortoise ORM models: match statistics persisted in Postgres.
|
||||
|
||||
Live game state lives in Redis (see :mod:`tavolo.platform.store`); only
|
||||
completed matches are written here. The schema is game-independent:
|
||||
|
||||
* :class:`Match` — one row per finished match; everything game-specific
|
||||
(scores, hands played, options, per-hand audit trail) lives in the
|
||||
JSON ``result`` column produced by the game's engine.
|
||||
* :class:`MatchPlayer` — one row per participant, linking an OIDC
|
||||
``sub`` to a seat, an optional team label, whether they won, the
|
||||
points they scored and their Elo delta.
|
||||
* :class:`PlayerRating` — current chess-style Elo rating of a player for
|
||||
one game type, updated transactionally with every finished match (see
|
||||
:mod:`tavolo.platform.elo`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from tortoise import fields
|
||||
from tortoise.models import Model
|
||||
|
||||
from .elo import INITIAL_RATING
|
||||
|
||||
|
||||
class Match(Model):
|
||||
"""A completed match of one of the registered game types."""
|
||||
|
||||
id = fields.UUIDField(pk=True)
|
||||
# Which game was played (an id from the game registry).
|
||||
game_type = fields.CharField(max_length=32, db_index=True)
|
||||
started_at = fields.DatetimeField()
|
||||
finished_at = fields.DatetimeField()
|
||||
# Game-specific outcome summary as reported by the engine's
|
||||
# ``MatchResult.summary`` (scores, hands played, options, ...).
|
||||
result: dict = fields.JSONField(default=dict)
|
||||
|
||||
players: fields.ReverseRelation["MatchPlayer"]
|
||||
|
||||
class Meta:
|
||||
table = "match"
|
||||
ordering = ["-finished_at"]
|
||||
|
||||
|
||||
class MatchPlayer(Model):
|
||||
"""Participation of one user in one match."""
|
||||
|
||||
id = fields.UUIDField(pk=True)
|
||||
match: fields.ForeignKeyRelation[Match] = fields.ForeignKeyField(
|
||||
"models.Match", related_name="players", on_delete=fields.CASCADE
|
||||
)
|
||||
# OIDC subject of the player; no local users table.
|
||||
user_sub = fields.CharField(max_length=255, db_index=True)
|
||||
display_name = fields.CharField(max_length=200)
|
||||
seat = fields.SmallIntField()
|
||||
# Game-defined team label; null for games without fixed teams.
|
||||
team = fields.CharField(max_length=32, null=True)
|
||||
won = fields.BooleanField()
|
||||
# Points the player scored, aggregated by the leaderboard.
|
||||
score = fields.FloatField(default=0)
|
||||
# Elo change this match produced for the player (see
|
||||
# tavolo.platform.elo); null for matches recorded before ratings
|
||||
# existed.
|
||||
elo_delta = fields.SmallIntField(null=True)
|
||||
# Game-specific extras reported by the engine's PlayerResult.
|
||||
details: dict = fields.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
table = "match_player"
|
||||
unique_together = (("match", "user_sub"),)
|
||||
|
||||
|
||||
class PlayerRating(Model):
|
||||
"""Current Elo rating of one player for one game type."""
|
||||
|
||||
id = fields.UUIDField(pk=True)
|
||||
# OIDC subject of the player; no local users table.
|
||||
user_sub = fields.CharField(max_length=255)
|
||||
# Which game the rating applies to (an id from the game registry).
|
||||
game_type = fields.CharField(max_length=32)
|
||||
rating = fields.IntField(default=INITIAL_RATING)
|
||||
matches_played = fields.IntField(default=0)
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
table = "player_rating"
|
||||
unique_together = (("user_sub", "game_type"),)
|
||||
indexes = (("game_type", "rating"),)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Registry of the games the platform can host.
|
||||
|
||||
The registry maps a ``game_type`` id to its
|
||||
:class:`~tavolo.platform.engine.GameEngine` implementation and is the
|
||||
single source of truth for which games exist: the lobby lists it, the
|
||||
store uses it to deserialize opaque game state, and the stats endpoints
|
||||
use it to validate ``game_type`` filters. Games register themselves at
|
||||
composition time (see the application entry point); the platform itself
|
||||
ships no game.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Iterable, List, Optional
|
||||
|
||||
from .engine import GameEngine
|
||||
from .errors import GameError
|
||||
|
||||
|
||||
class UnknownGameType(GameError):
|
||||
"""A request named a ``game_type`` no registered engine provides."""
|
||||
|
||||
code = "unknown_game_type"
|
||||
|
||||
|
||||
class GameRegistry:
|
||||
"""An ordered collection of game engines, keyed by their id."""
|
||||
|
||||
def __init__(self, engines: Iterable[GameEngine] = ()) -> None:
|
||||
self._engines: Dict[str, GameEngine] = {}
|
||||
for engine in engines:
|
||||
self.register(engine)
|
||||
|
||||
def register(self, engine: GameEngine) -> GameEngine:
|
||||
"""Add ``engine``; the first registered becomes the default."""
|
||||
if engine.id in self._engines:
|
||||
raise ValueError(f"duplicate game engine id: {engine.id!r}")
|
||||
self._engines[engine.id] = engine
|
||||
return engine
|
||||
|
||||
def get(self, game_type: str) -> Optional[GameEngine]:
|
||||
"""Return the engine for ``game_type``, or ``None``."""
|
||||
return self._engines.get(game_type)
|
||||
|
||||
def require(self, game_type: str) -> GameEngine:
|
||||
"""Return the engine for ``game_type`` or raise."""
|
||||
engine = self.get(game_type)
|
||||
if engine is None:
|
||||
raise UnknownGameType(f"unknown game_type: {game_type!r}")
|
||||
return engine
|
||||
|
||||
@property
|
||||
def default(self) -> GameEngine:
|
||||
"""The first registered engine, used when no type is requested."""
|
||||
try:
|
||||
return next(iter(self._engines.values()))
|
||||
except StopIteration:
|
||||
raise RuntimeError("no game engines registered") from None
|
||||
|
||||
def all(self) -> List[GameEngine]:
|
||||
return list(self._engines.values())
|
||||
@@ -0,0 +1 @@
|
||||
"""HTTP endpoints of the platform: health, identity, lobby, statistics."""
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Game lobby endpoints.
|
||||
|
||||
A game starts as a lobby: the creator is seated first and shares the
|
||||
six-character ``join_code``. When the lobby fills up, the engine starts
|
||||
the match. Live play then happens over the ``/ws/games/{id}`` websocket
|
||||
(see :mod:`tavolo.platform.ws`); these endpoints cover creation, joining
|
||||
and snapshotting state.
|
||||
|
||||
Everything game-specific — which options a game accepts, when the lobby
|
||||
is full, what the personalized view looks like — is delegated to the
|
||||
engine registered for the session's ``game_type``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from logging import getLogger
|
||||
from typing import TYPE_CHECKING, Any, Dict
|
||||
|
||||
from kaya.core import HttpContext, KayaApp
|
||||
from kaya.openapi import operation
|
||||
|
||||
from ..auth import display_name, require_auth
|
||||
from ..engine import GameSession, Seat
|
||||
from ..errors import GameError
|
||||
from ..http import JsonRequestError, read_json, read_json_optional, send_error, send_json
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..mixin import Platform
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
# Join codes avoid characters that are easy to confuse when read aloud.
|
||||
_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
_CODE_LENGTH = 6
|
||||
_MAX_CODE_ATTEMPTS = 20
|
||||
|
||||
|
||||
def _now_code() -> str:
|
||||
return "".join(secrets.choice(_CODE_ALPHABET) for _ in range(_CODE_LENGTH))
|
||||
|
||||
|
||||
async def _unique_code(platform: "Platform") -> str:
|
||||
for _ in range(_MAX_CODE_ATTEMPTS):
|
||||
code = _now_code()
|
||||
if await platform.game_store.find_by_code(code) is None:
|
||||
return code
|
||||
raise RuntimeError("could not allocate a unique join code")
|
||||
|
||||
|
||||
def _lobby_payload(platform: "Platform", session: GameSession) -> Dict[str, Any]:
|
||||
engine = platform.registry.require(session.game_type)
|
||||
return {
|
||||
"id": session.id,
|
||||
"join_code": session.join_code,
|
||||
"game_type": session.game_type,
|
||||
"players": [
|
||||
{
|
||||
"sub": seat.user_sub,
|
||||
"name": seat.display_name,
|
||||
"seat": index,
|
||||
"team": seat.team,
|
||||
}
|
||||
for index, seat in enumerate(session.players)
|
||||
],
|
||||
"seats_open": engine.max_players - len(session.players),
|
||||
**engine.lobby_view(session),
|
||||
}
|
||||
|
||||
|
||||
def _view_payload(platform: "Platform", session: GameSession, sub: str) -> Dict[str, Any]:
|
||||
engine = platform.registry.require(session.game_type)
|
||||
return {
|
||||
"id": session.id,
|
||||
"join_code": session.join_code,
|
||||
"game_type": session.game_type,
|
||||
**engine.view_for(session, sub),
|
||||
}
|
||||
|
||||
|
||||
def register(app: KayaApp, platform: "Platform") -> None:
|
||||
@app.GET("/api/game-types")
|
||||
@operation(summary="List available games",
|
||||
description="Every game the platform can host, for the "
|
||||
"match-creation dropdown. ``options_schema`` "
|
||||
"describes the per-game creation options accepted "
|
||||
"by POST /api/games.",
|
||||
tags=["games"],
|
||||
responses={200: {"description": "The available game types"}})
|
||||
async def list_game_types(ctx: HttpContext) -> None:
|
||||
await send_json(ctx, 200, {
|
||||
"results": [
|
||||
{
|
||||
"id": engine.id,
|
||||
"name": engine.name,
|
||||
"description": engine.description,
|
||||
"min_players": engine.min_players,
|
||||
"max_players": engine.max_players,
|
||||
"options_schema": engine.options_schema,
|
||||
}
|
||||
for engine in platform.registry.all()
|
||||
]
|
||||
})
|
||||
|
||||
@app.POST("/api/games")
|
||||
@operation(summary="Create a game",
|
||||
description="Creates a lobby game and seats the caller in "
|
||||
"seat 0. Share the returned join_code with the "
|
||||
"other players. ``options`` is an opaque object "
|
||||
"validated by the chosen game (see "
|
||||
"GET /api/game-types).",
|
||||
tags=["games"],
|
||||
request_body={
|
||||
"required": False,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"game_type": {
|
||||
"type": "string",
|
||||
"description": "One of the ids from "
|
||||
"GET /api/game-types; "
|
||||
"defaults to the "
|
||||
"platform's first "
|
||||
"registered game",
|
||||
},
|
||||
"options": {
|
||||
"type": "object",
|
||||
"description": "Game-specific creation "
|
||||
"options",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
responses={
|
||||
201: {"description": "The created lobby"},
|
||||
400: {"description": "Invalid game_type, options or body"},
|
||||
401: {"description": "Authentication required"},
|
||||
})
|
||||
@require_auth(platform.oidc)
|
||||
async def create_game(ctx: HttpContext) -> None:
|
||||
body: dict = {}
|
||||
try:
|
||||
body = await read_json_optional(ctx)
|
||||
except JsonRequestError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
|
||||
game_type: Any = body.get("game_type")
|
||||
if game_type is None:
|
||||
engine = platform.registry.default
|
||||
elif isinstance(game_type, str) and platform.registry.get(game_type) is not None:
|
||||
engine = platform.registry.require(game_type)
|
||||
else:
|
||||
await send_error(ctx, 400, f"unknown game_type: {game_type!r}")
|
||||
return
|
||||
|
||||
options: Any = body.get("options", {})
|
||||
if not isinstance(options, dict):
|
||||
await send_error(ctx, 400, "options must be an object")
|
||||
return
|
||||
|
||||
user = platform.oidc.get_user(ctx)
|
||||
assert user is not None # enforced by @require_auth
|
||||
session = GameSession(
|
||||
id=str(uuid.uuid4()),
|
||||
game_type=engine.id,
|
||||
join_code=await _unique_code(platform),
|
||||
creator_sub=user.sub,
|
||||
players=[Seat(user_sub=user.sub, display_name=display_name(user))],
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
try:
|
||||
engine.create(session, options)
|
||||
except GameError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
await platform.game_store.save(session)
|
||||
log.info(
|
||||
"game %s created by %s (%s, options %r)",
|
||||
session.id,
|
||||
user.sub,
|
||||
engine.id,
|
||||
options,
|
||||
)
|
||||
await send_json(ctx, 201, _lobby_payload(platform, session))
|
||||
|
||||
@app.POST("/api/games/join")
|
||||
@operation(summary="Join a game by code",
|
||||
description="Seats the caller in the next free chair. Joining "
|
||||
"as the last player starts the match.",
|
||||
tags=["games"],
|
||||
request_body={
|
||||
"required": True,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"code": {"type": "string"}},
|
||||
"required": ["code"],
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
responses={
|
||||
200: {"description": "Seated; game state (may be playing)"},
|
||||
400: {"description": "Missing code"},
|
||||
401: {"description": "Authentication required"},
|
||||
404: {"description": "Unknown join code"},
|
||||
409: {"description": "Already joined or lobby full"},
|
||||
})
|
||||
@require_auth(platform.oidc)
|
||||
async def join_game(ctx: HttpContext) -> None:
|
||||
try:
|
||||
body = await read_json(ctx)
|
||||
except JsonRequestError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
code = body.get("code")
|
||||
if not isinstance(code, str) or not code:
|
||||
await send_error(ctx, 400, "code is required")
|
||||
return
|
||||
|
||||
user = platform.oidc.get_user(ctx)
|
||||
assert user is not None
|
||||
existing = await platform.game_store.find_by_code(code)
|
||||
if existing is None:
|
||||
log.debug("join rejected for %s: unknown code %r", user.sub, code)
|
||||
await send_error(ctx, 404, "unknown join code")
|
||||
return
|
||||
|
||||
async with platform.game_store.lock(existing.id):
|
||||
session = await platform.game_store.load(existing.id)
|
||||
if session is None:
|
||||
await send_error(ctx, 404, "unknown join code")
|
||||
return
|
||||
engine = platform.registry.require(session.game_type)
|
||||
try:
|
||||
engine.join(session, user.sub, display_name(user))
|
||||
except GameError as exc:
|
||||
log.debug("join rejected for %s in game %s: %s", user.sub, session.id, exc)
|
||||
await send_error(ctx, 409, str(exc))
|
||||
return
|
||||
await platform.game_store.save(session)
|
||||
await platform.game_store.publish(session.id)
|
||||
# When the last join started the match, the engine may have
|
||||
# armed a deadline; queue it so it fires even if nobody ever
|
||||
# connects.
|
||||
await platform.scheduler.sync_deadline(session)
|
||||
seat = next(i for i, s in enumerate(session.players) if s.user_sub == user.sub)
|
||||
if engine.in_lobby(session):
|
||||
log.info(
|
||||
"%s joined game %s (seat %d, %d/%d players)",
|
||||
user.sub, session.id, seat, len(session.players), engine.max_players,
|
||||
)
|
||||
await send_json(ctx, 200, _lobby_payload(platform, session))
|
||||
return
|
||||
log.info("%s joined game %s (seat %d); match started", user.sub, session.id, seat)
|
||||
await send_json(ctx, 200, _view_payload(platform, session, user.sub))
|
||||
|
||||
@app.GET("/api/games/${game_id}")
|
||||
@operation(summary="Get a game snapshot",
|
||||
description="Only seated players may read a game; the view is "
|
||||
"personalized by the engine (e.g. hidden hands).",
|
||||
tags=["games"],
|
||||
responses={
|
||||
200: {"description": "The personalized game state"},
|
||||
401: {"description": "Authentication required"},
|
||||
403: {"description": "Not a player in this game"},
|
||||
404: {"description": "Game not found"},
|
||||
})
|
||||
@require_auth(platform.oidc)
|
||||
async def get_game(ctx: HttpContext, game_id: str) -> None:
|
||||
session = await platform.game_store.load(game_id)
|
||||
if session is None:
|
||||
await send_error(ctx, 404, "game not found")
|
||||
return
|
||||
user = platform.oidc.get_user(ctx)
|
||||
assert user is not None
|
||||
if not session.seated(user.sub):
|
||||
await send_error(ctx, 403, "forbidden")
|
||||
return
|
||||
await send_json(ctx, 200, _view_payload(platform, session, user.sub))
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Liveness probe."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from kaya.core import HttpContext, KayaApp
|
||||
from kaya.openapi import operation
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..mixin import Platform
|
||||
|
||||
|
||||
def register(app: KayaApp, platform: "Platform") -> None:
|
||||
@app.GET("/api/health")
|
||||
@operation(summary="Health check",
|
||||
tags=["health"],
|
||||
responses={200: {"description": "The service is up"}})
|
||||
async def health(ctx: HttpContext) -> None:
|
||||
await ctx.send_bytes(
|
||||
200,
|
||||
b'{"status":"ok"}',
|
||||
{"content-type": ("application/json",)},
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Whoami endpoint: lets the single-page app detect the login state."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from kaya.core import HttpContext, KayaApp
|
||||
from kaya.openapi import operation
|
||||
|
||||
from ..auth import display_name, require_auth
|
||||
from ..http import send_json
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..mixin import Platform
|
||||
|
||||
|
||||
def register(app: KayaApp, platform: "Platform") -> None:
|
||||
@app.GET("/api/me")
|
||||
@operation(summary="Current user",
|
||||
description="Returns the authenticated user's identity from the "
|
||||
"session; 401 when not logged in.",
|
||||
tags=["auth"],
|
||||
responses={
|
||||
200: {"description": "The current user"},
|
||||
401: {"description": "Not logged in"},
|
||||
})
|
||||
@require_auth(platform.oidc)
|
||||
async def me(ctx: HttpContext) -> None:
|
||||
user = platform.oidc.get_user(ctx)
|
||||
assert user is not None # enforced by @require_auth
|
||||
await send_json(ctx, 200, {"sub": user.sub, "name": display_name(user)})
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Player statistics endpoints, served from Postgres.
|
||||
|
||||
Every finished match is persisted by
|
||||
:func:`tavolo.platform.stats.save_match_result`. These endpoints expose a
|
||||
player's own match history and a global leaderboard aggregated from the
|
||||
same two tables. The game-specific outcome of each match is exposed
|
||||
verbatim through the ``result`` JSON column.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
|
||||
|
||||
from kaya.core import HttpContext, KayaApp
|
||||
from kaya.openapi import operation
|
||||
|
||||
from ..auth import require_auth
|
||||
from ..elo import INITIAL_RATING
|
||||
from ..http import extract_query_params, send_error, send_json
|
||||
from ..models import Match, MatchPlayer, PlayerRating
|
||||
from ..openapi import PAGINATION_PARAMETERS
|
||||
from ..pagination import CursorDecodeError, paginate, parse_cursor_params
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..mixin import Platform
|
||||
|
||||
GAME_TYPE_PARAMETER: Dict[str, Any] = {
|
||||
"name": "game_type",
|
||||
"in": "query",
|
||||
"required": False,
|
||||
"schema": {"type": "string"},
|
||||
"description": "Only count matches of this game (id from GET /api/game-types).",
|
||||
}
|
||||
|
||||
|
||||
def _parse_game_type(
|
||||
platform: "Platform", query_string: str
|
||||
) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Parse the ``game_type`` query parameter.
|
||||
|
||||
Returns ``(value, error)``: ``(None, None)`` when absent, ``(id, None)``
|
||||
when valid, ``(None, message)`` when it names no registered game."""
|
||||
values = extract_query_params(query_string).get("game_type")
|
||||
if not values:
|
||||
return None, None
|
||||
game_type = values[0]
|
||||
if platform.registry.get(game_type) is None:
|
||||
return None, f"unknown game_type: {game_type!r}"
|
||||
return game_type, None
|
||||
|
||||
|
||||
async def _serialize_match(match: Match, viewer: str) -> Dict[str, Any]:
|
||||
participants = await MatchPlayer.filter(match_id=match.id).order_by("seat")
|
||||
return {
|
||||
"id": str(match.id),
|
||||
"game_type": match.game_type,
|
||||
# The engine's MatchResult.summary: for scopone, the teams' final
|
||||
# scores, the winner, the target score and the per-hand audit.
|
||||
"result": match.result,
|
||||
"started_at": match.started_at.isoformat(),
|
||||
"finished_at": match.finished_at.isoformat(),
|
||||
"you_won": any(p.user_sub == viewer and p.won for p in participants),
|
||||
"your_elo_delta": next(
|
||||
(p.elo_delta for p in participants if p.user_sub == viewer), None
|
||||
),
|
||||
"players": [
|
||||
{
|
||||
"user_sub": p.user_sub,
|
||||
"display_name": p.display_name,
|
||||
"seat": p.seat,
|
||||
"team": p.team,
|
||||
"won": p.won,
|
||||
"score": p.score,
|
||||
"elo_delta": p.elo_delta,
|
||||
"details": p.details,
|
||||
}
|
||||
for p in participants
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def register(app: KayaApp, platform: "Platform") -> None:
|
||||
@app.GET("/api/me/matches")
|
||||
@operation(summary="List my matches",
|
||||
description="Cursor-paginated history of finished matches the "
|
||||
"caller played, newest first, with the result.",
|
||||
tags=["stats"],
|
||||
parameters=[*PAGINATION_PARAMETERS, GAME_TYPE_PARAMETER],
|
||||
responses={
|
||||
200: {"description": "A page of matches"},
|
||||
400: {"description": "Invalid pagination cursor or game_type"},
|
||||
401: {"description": "Authentication required"},
|
||||
})
|
||||
@require_auth(platform.oidc)
|
||||
async def my_matches(ctx: HttpContext) -> None:
|
||||
try:
|
||||
cursor = parse_cursor_params(ctx.query_string)
|
||||
except CursorDecodeError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
game_type, error = _parse_game_type(platform, ctx.query_string)
|
||||
if error is not None:
|
||||
await send_error(ctx, 400, error)
|
||||
return
|
||||
user = platform.oidc.get_user(ctx)
|
||||
assert user is not None
|
||||
queryset = Match.filter(players__user_sub=user.sub).distinct()
|
||||
if game_type is not None:
|
||||
queryset = queryset.filter(game_type=game_type)
|
||||
matches, next_cursor = await paginate(
|
||||
queryset, [("finished_at", "DESC"), ("id", "ASC")], cursor
|
||||
)
|
||||
results = [await _serialize_match(m, user.sub) for m in matches]
|
||||
await send_json(ctx, 200, {"results": results, "next_cursor": next_cursor})
|
||||
|
||||
@app.GET("/api/leaderboard")
|
||||
@operation(summary="Global leaderboard",
|
||||
description="Elo rating, aggregated wins, matches played and "
|
||||
"points for every player with at least one "
|
||||
"finished match. Sorted by Elo rating (the rating "
|
||||
"for the requested game_type, or the default game "
|
||||
"when the filter is absent).",
|
||||
tags=["stats"],
|
||||
parameters=[GAME_TYPE_PARAMETER],
|
||||
responses={
|
||||
200: {"description": "The leaderboard"},
|
||||
400: {"description": "Unknown game_type"},
|
||||
})
|
||||
async def leaderboard(ctx: HttpContext) -> None:
|
||||
game_type, error = _parse_game_type(platform, ctx.query_string)
|
||||
if error is not None:
|
||||
await send_error(ctx, 400, error)
|
||||
return
|
||||
queryset = MatchPlayer.all()
|
||||
if game_type is not None:
|
||||
queryset = queryset.filter(match__game_type=game_type)
|
||||
rows = await queryset
|
||||
# Ratings are per game type; without a filter show the default
|
||||
# game's.
|
||||
rating_game = game_type or platform.registry.default.id
|
||||
rating_rows = await PlayerRating.filter(game_type=rating_game)
|
||||
ratings = {row.user_sub: row.rating for row in rating_rows}
|
||||
aggregate: Dict[str, Dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
entry = aggregate.setdefault(
|
||||
row.user_sub,
|
||||
{
|
||||
"user_sub": row.user_sub,
|
||||
"display_name": row.display_name,
|
||||
"matches": 0,
|
||||
"wins": 0,
|
||||
"points": 0,
|
||||
"elo": ratings.get(row.user_sub, INITIAL_RATING),
|
||||
},
|
||||
)
|
||||
entry["matches"] += 1
|
||||
entry["wins"] += 1 if row.won else 0
|
||||
entry["points"] += row.score
|
||||
# Keep the most recent display name seen.
|
||||
entry["display_name"] = row.display_name
|
||||
|
||||
ranking: List[Dict[str, Any]] = sorted(
|
||||
aggregate.values(),
|
||||
key=lambda e: (e["elo"], e["wins"], e["points"], -e["matches"]),
|
||||
reverse=True,
|
||||
)
|
||||
await send_json(ctx, 200, {"results": ranking})
|
||||
|
||||
@app.GET("/api/me/ratings")
|
||||
@operation(summary="My Elo ratings",
|
||||
description="The caller's current Elo rating for every game "
|
||||
"type they have played.",
|
||||
tags=["stats"],
|
||||
responses={
|
||||
200: {"description": "The caller's ratings"},
|
||||
401: {"description": "Authentication required"},
|
||||
})
|
||||
@require_auth(platform.oidc)
|
||||
async def my_ratings(ctx: HttpContext) -> None:
|
||||
user = platform.oidc.get_user(ctx)
|
||||
assert user is not None
|
||||
rows = await PlayerRating.filter(user_sub=user.sub).order_by("game_type")
|
||||
await send_json(ctx, 200, {
|
||||
"results": [
|
||||
{
|
||||
"game_type": row.game_type,
|
||||
"rating": row.rating,
|
||||
"matches_played": row.matches_played,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Copy finished match results from the live store into Postgres.
|
||||
|
||||
Called once when a session reaches the finished state (guarded by the
|
||||
``stats_saved`` flag on the session). The write is transactional so a
|
||||
match never appears with only some of its players. The same transaction
|
||||
also updates the participants' Elo ratings (see
|
||||
:mod:`tavolo.platform.elo`).
|
||||
|
||||
This module is game-independent: everything it persists comes from the
|
||||
engine's :class:`~tavolo.platform.engine.MatchResult`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from logging import getLogger
|
||||
from typing import Dict, List
|
||||
|
||||
from tortoise.transactions import in_transaction
|
||||
|
||||
from .elo import match_delta
|
||||
from .engine import GameEngine, GameSession
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
|
||||
async def apply_elo(
|
||||
game_type: str, teams: List[List[str]], winner_team: int
|
||||
) -> Dict[str, int]:
|
||||
"""Update the Elo ratings of ``teams`` for ``game_type``.
|
||||
|
||||
``teams`` groups the participants' subs into the two opposing sides;
|
||||
``winner_team`` is the index of the winning side. Ratings are read
|
||||
from (and written back to) the ``player_rating`` table; unrated
|
||||
players start at the initial rating. Returns the per-player delta.
|
||||
Must be called inside a transaction.
|
||||
"""
|
||||
if len(teams) != 2:
|
||||
raise ValueError("Elo rating requires exactly two teams")
|
||||
from .models import PlayerRating
|
||||
|
||||
ratings: Dict[str, "PlayerRating"] = {}
|
||||
for subs in teams:
|
||||
for sub in subs:
|
||||
rating = await PlayerRating.get_or_none(
|
||||
user_sub=sub, game_type=game_type
|
||||
)
|
||||
if rating is None:
|
||||
rating = await PlayerRating.create(
|
||||
id=uuid.uuid4(), user_sub=sub, game_type=game_type
|
||||
)
|
||||
ratings[sub] = rating
|
||||
delta_a = match_delta(
|
||||
[ratings[sub].rating for sub in teams[0]],
|
||||
[ratings[sub].rating for sub in teams[1]],
|
||||
winner_team,
|
||||
)
|
||||
deltas: Dict[str, int] = {
|
||||
**{sub: delta_a for sub in teams[0]},
|
||||
**{sub: -delta_a for sub in teams[1]},
|
||||
}
|
||||
for sub, delta in deltas.items():
|
||||
rating = ratings[sub]
|
||||
rating.rating += delta
|
||||
rating.matches_played += 1
|
||||
await rating.save()
|
||||
return deltas
|
||||
|
||||
|
||||
async def save_match_result(session: GameSession, engine: GameEngine) -> None:
|
||||
"""Persist ``session`` to Postgres if it is finished and not yet saved."""
|
||||
if session.stats_saved or not engine.is_finished(session):
|
||||
return
|
||||
result = engine.result(session)
|
||||
|
||||
from .models import Match, MatchPlayer
|
||||
|
||||
started_at = session.created_at or datetime.now(timezone.utc)
|
||||
finished_at = session.finished_at or datetime.now(timezone.utc)
|
||||
display_names = {seat.user_sub: seat.display_name for seat in session.players}
|
||||
async with in_transaction():
|
||||
match = await Match.create(
|
||||
id=uuid.uuid4(),
|
||||
game_type=session.game_type,
|
||||
started_at=started_at,
|
||||
finished_at=finished_at,
|
||||
result=result.summary,
|
||||
)
|
||||
deltas = await apply_elo(session.game_type, result.teams, result.winner_team)
|
||||
for player in result.players:
|
||||
await MatchPlayer.create(
|
||||
id=uuid.uuid4(),
|
||||
match=match,
|
||||
user_sub=player.user_sub,
|
||||
display_name=display_names.get(player.user_sub, player.user_sub),
|
||||
seat=player.seat,
|
||||
team=player.team,
|
||||
won=player.won,
|
||||
score=player.score,
|
||||
elo_delta=deltas[player.user_sub],
|
||||
details=player.details,
|
||||
)
|
||||
session.stats_saved = True
|
||||
log.info(
|
||||
"match result persisted: game %s (%s), team %d of %d won",
|
||||
session.id,
|
||||
session.game_type,
|
||||
result.winner_team,
|
||||
len(result.teams),
|
||||
)
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Persistence for live games.
|
||||
|
||||
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:<id>`` 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.
|
||||
|
||||
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 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 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
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from logging import getLogger
|
||||
from typing import Any, AsyncContextManager, AsyncIterator, Dict, List, Mapping, Optional, Set, cast
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from .engine import GameSession, Seat
|
||||
from .registry import GameRegistry
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
GAME_KEY_PREFIX = "tavolo:game:"
|
||||
CODE_KEY_PREFIX = "tavolo:code:"
|
||||
CHANNEL_PREFIX = "tavolo:game:"
|
||||
DEADLINES_KEY = "tavolo:deadlines"
|
||||
|
||||
# Sentinel pushed into in-memory subscriber queues to signal a change.
|
||||
_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[GameSession]:
|
||||
"""Return the live session for ``game_id`` or ``None``."""
|
||||
|
||||
@abstractmethod
|
||||
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[GameSession]:
|
||||
"""Return the live session for a join ``code`` or ``None``."""
|
||||
|
||||
@abstractmethod
|
||||
def lock(self, game_id: str) -> AsyncContextManager[None]:
|
||||
"""Async context manager serializing mutations of one game."""
|
||||
|
||||
@abstractmethod
|
||||
def subscribe(self, game_id: str) -> AsyncContextManager[AsyncIterator[None]]:
|
||||
"""Async context manager yielding an async iterator of change signals."""
|
||||
|
||||
@abstractmethod
|
||||
async def publish(self, game_id: str) -> None:
|
||||
"""Signal that the session of ``game_id`` changed."""
|
||||
|
||||
@abstractmethod
|
||||
async def add_deadline(self, member: str, due_at: float) -> None:
|
||||
"""Enqueue ``member`` to fire at ``due_at`` (epoch seconds).
|
||||
|
||||
Idempotent for identical members: re-adding an existing member only
|
||||
updates its due time.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def due_deadlines(self, now: float, limit: int = 32) -> List[str]:
|
||||
"""Return up to ``limit`` enqueued members due at or before ``now``."""
|
||||
|
||||
@abstractmethod
|
||||
async def next_deadline(self) -> Optional[float]:
|
||||
"""Return the earliest pending due time (epoch seconds), if any."""
|
||||
|
||||
@abstractmethod
|
||||
async def remove_deadline(self, member: str) -> None:
|
||||
"""Remove ``member`` from the queue; a no-op when absent."""
|
||||
|
||||
|
||||
def _channel(game_id: str) -> str:
|
||||
return f"{CHANNEL_PREFIX}{game_id}:events"
|
||||
|
||||
|
||||
class RedisGameStore(GameStore):
|
||||
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):
|
||||
# Lock and state use distinct key names; the lock expires on its own
|
||||
# if a worker dies mid-mutation.
|
||||
return self._redis.lock(f"{GAME_KEY_PREFIX}{game_id}:lock",
|
||||
timeout=10, blocking_timeout=10)
|
||||
|
||||
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)
|
||||
return None
|
||||
if isinstance(raw, bytes):
|
||||
raw = raw.decode("utf-8")
|
||||
log.debug("redis load %s: hit", game_id)
|
||||
return session_from_json(json.loads(raw), self._registry)
|
||||
|
||||
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}{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 (game %s, ttl %ds)", session.id, session.game_type, self._ttl)
|
||||
|
||||
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
|
||||
if isinstance(game_id, bytes):
|
||||
game_id = game_id.decode("utf-8")
|
||||
return await self.load(str(game_id))
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]:
|
||||
pubsub = self._redis.pubsub()
|
||||
await pubsub.subscribe(_channel(game_id))
|
||||
try:
|
||||
yield _redis_events(pubsub)
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
await pubsub.unsubscribe(_channel(game_id))
|
||||
await pubsub.aclose()
|
||||
|
||||
async def publish(self, game_id: str) -> None:
|
||||
await self._redis.publish(_channel(game_id), "update")
|
||||
log.debug("redis publish %s", game_id)
|
||||
|
||||
async def add_deadline(self, member: str, due_at: float) -> None:
|
||||
await self._redis.zadd(DEADLINES_KEY, {member: due_at})
|
||||
|
||||
async def due_deadlines(self, now: float, limit: int = 32) -> List[str]:
|
||||
members = cast(
|
||||
list,
|
||||
await self._redis.zrangebyscore(
|
||||
DEADLINES_KEY, "-inf", now, start=0, num=limit
|
||||
),
|
||||
)
|
||||
return [m.decode("utf-8") if isinstance(m, bytes) else m for m in members]
|
||||
|
||||
async def next_deadline(self) -> Optional[float]:
|
||||
earliest = await self._redis.zrange(DEADLINES_KEY, 0, 0, withscores=True)
|
||||
return float(earliest[0][1]) if earliest else None
|
||||
|
||||
async def remove_deadline(self, member: str) -> None:
|
||||
await self._redis.zrem(DEADLINES_KEY, member)
|
||||
|
||||
|
||||
async def _redis_events(pubsub) -> AsyncIterator[None]:
|
||||
async for message in pubsub.listen():
|
||||
if message.get("type") == "message":
|
||||
yield None
|
||||
|
||||
|
||||
class InMemoryGameStore(GameStore):
|
||||
"""Process-local store used by tests and when Redis is not configured."""
|
||||
|
||||
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]] = {}
|
||||
self._deadlines: Dict[str, float] = {}
|
||||
|
||||
def _lock_for(self, game_id: str) -> asyncio.Lock:
|
||||
lock = self._locks.get(game_id)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
self._locks[game_id] = lock
|
||||
return lock
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def lock(self, game_id: str) -> AsyncIterator[None]:
|
||||
async with self._lock_for(game_id):
|
||||
yield
|
||||
|
||||
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, 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[GameSession]:
|
||||
game_id = self._codes.get(code.upper())
|
||||
if game_id is None:
|
||||
return None
|
||||
return await self.load(game_id)
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]:
|
||||
queue: asyncio.Queue = asyncio.Queue()
|
||||
self._subscribers.setdefault(game_id, set()).add(queue)
|
||||
try:
|
||||
yield _queue_events(queue)
|
||||
finally:
|
||||
subscribers = self._subscribers.get(game_id)
|
||||
if subscribers is not None:
|
||||
subscribers.discard(queue)
|
||||
if not subscribers:
|
||||
self._subscribers.pop(game_id, None)
|
||||
|
||||
async def publish(self, game_id: str) -> None:
|
||||
for queue in list(self._subscribers.get(game_id, ())):
|
||||
queue.put_nowait(_BUMP)
|
||||
|
||||
async def add_deadline(self, member: str, due_at: float) -> None:
|
||||
self._deadlines[member] = due_at
|
||||
|
||||
async def due_deadlines(self, now: float, limit: int = 32) -> List[str]:
|
||||
due = [m for m, due_at in self._deadlines.items() if due_at <= now]
|
||||
due.sort(key=self._deadlines.__getitem__)
|
||||
return due[:limit]
|
||||
|
||||
async def next_deadline(self) -> Optional[float]:
|
||||
return min(self._deadlines.values(), default=None)
|
||||
|
||||
async def remove_deadline(self, member: str) -> None:
|
||||
self._deadlines.pop(member, None)
|
||||
|
||||
|
||||
async def _queue_events(queue: asyncio.Queue) -> AsyncIterator[None]:
|
||||
while True:
|
||||
await queue.get()
|
||||
yield None
|
||||
+16
@@ -83,6 +83,22 @@ class TortoiseMixin(KayaMixin):
|
||||
self._ctx = None
|
||||
self._init_loop = None
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Close the current context's connections and forget it.
|
||||
|
||||
Must be called from the event loop that owns the context. When a
|
||||
loop goes away without this, its aiosqlite connections are orphaned;
|
||||
their non-daemon worker threads then block interpreter shutdown
|
||||
forever. The test suite calls this at the end of every test because
|
||||
each test runs in a fresh event loop.
|
||||
"""
|
||||
ctx = self._ctx
|
||||
self._ctx = None
|
||||
self._init_loop = None
|
||||
if ctx is not None:
|
||||
log.info("closing database connections")
|
||||
await ctx.close_connections()
|
||||
|
||||
async def _build_context(self) -> TortoiseContext:
|
||||
ctx = TortoiseContext()
|
||||
with ctx:
|
||||
@@ -0,0 +1,210 @@
|
||||
"""WebSocket endpoint for live play.
|
||||
|
||||
Clients connect to ``/ws/games/{game_id}`` using their session cookie
|
||||
(the OIDC login stores the user in the session, which the session mixin
|
||||
loads onto the websocket). Only seated players are accepted.
|
||||
|
||||
The platform owns the connection lifecycle — authentication, seat
|
||||
check, the subscription/publish fan-out and the message envelope — and
|
||||
delegates the game-specific actions to the engine registered for the
|
||||
session's ``game_type``.
|
||||
|
||||
Protocol
|
||||
--------
|
||||
Server -> client messages are JSON objects with a ``type``:
|
||||
|
||||
* ``state`` — the personalized game view (the engine's
|
||||
:meth:`~tavolo.platform.engine.GameEngine.view_for` output merged with
|
||||
the session envelope: ``id``, ``join_code``, ``game_type``).
|
||||
* ``game_over`` — sent once when the match ends, carrying the engine's
|
||||
:meth:`~tavolo.platform.engine.GameEngine.game_over_view` fields.
|
||||
* ``error`` — a rejected action or malformed message.
|
||||
|
||||
Client -> server messages are JSON objects::
|
||||
|
||||
{"action": "state"} # platform: resend the view
|
||||
{"action": "<game action>", ...fields} # dispatched to the engine
|
||||
|
||||
For scopone scientifico the game actions are ``play`` (with ``card``
|
||||
and optionally ``capture``) and ``ack`` — see
|
||||
:mod:`tavolo.scopone.plugin`. Because the whole message (minus
|
||||
``action``) is handed to the engine as the payload, games define their
|
||||
own fields freely.
|
||||
|
||||
Mutations run under the per-game lock; after a successful action the new
|
||||
session is saved and a change signal is published. Every connected
|
||||
websocket is subscribed to that signal and re-renders the state, so all
|
||||
players see the move immediately (and consistently across workers).
|
||||
|
||||
Timeouts do not depend on anyone being connected: they are driven by
|
||||
the absolute deadlines the engine declares, via the shared deadline
|
||||
queue drained by a consumer on every worker (see
|
||||
:mod:`tavolo.platform.deadlines`). A disconnected or idle player
|
||||
therefore cannot stall the match, and a worker dying cannot either.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from contextlib import suppress
|
||||
from logging import getLogger
|
||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict
|
||||
|
||||
from kaya.core import KayaApp, WebSocket
|
||||
|
||||
from . import auth
|
||||
from .engine import GameSession
|
||||
from .errors import GameError
|
||||
|
||||
if TYPE_CHECKING: # avoid the import cycle: mixin imports this module
|
||||
from .mixin import Platform
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
Send = Callable[[Dict[str, Any]], Awaitable[None]]
|
||||
|
||||
|
||||
def _error(message: str, code: str = "invalid") -> Dict[str, Any]:
|
||||
return {"type": "error", "code": code, "message": message}
|
||||
|
||||
|
||||
def _state_message(platform: "Platform", session: GameSession, sub: str) -> Dict[str, Any]:
|
||||
engine = platform.registry.require(session.game_type)
|
||||
return {
|
||||
"type": "state",
|
||||
"game": {
|
||||
"id": session.id,
|
||||
"join_code": session.join_code,
|
||||
"game_type": session.game_type,
|
||||
**engine.view_for(session, sub),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def register(app: KayaApp, platform: "Platform") -> None:
|
||||
"""Register the live-play websocket endpoint on ``app``."""
|
||||
|
||||
@app.websocket("/ws/games/${game_id}")
|
||||
async def game_socket(ws: WebSocket, game_id: str) -> None:
|
||||
user = auth.get_ws_user(ws)
|
||||
if user is None:
|
||||
log.debug("websocket %s rejected: no authenticated user", game_id)
|
||||
await ws.close(4401)
|
||||
return
|
||||
|
||||
session = await platform.game_store.load(game_id)
|
||||
if session is None:
|
||||
log.debug("websocket rejected: unknown game %s", game_id)
|
||||
await ws.close(4404)
|
||||
return
|
||||
if not session.seated(user.sub):
|
||||
log.debug("websocket %s rejected: %s is not seated", game_id, user.sub)
|
||||
await ws.close(4403)
|
||||
return
|
||||
|
||||
await ws.accept()
|
||||
log.info("%s connected to game %s", user.sub, game_id)
|
||||
|
||||
send_lock = asyncio.Lock()
|
||||
|
||||
async def send(payload: Dict[str, Any]) -> None:
|
||||
async with send_lock:
|
||||
await ws.send_text(json.dumps(payload))
|
||||
|
||||
await send(_state_message(platform, session, user.sub))
|
||||
# Backstop: make sure the current deadline is queued even if its
|
||||
# entry was lost (e.g. the queue was flushed while the game lived
|
||||
# on thanks to its sliding TTL).
|
||||
await platform.scheduler.sync_deadline(session)
|
||||
|
||||
async with platform.game_store.subscribe(game_id) as events:
|
||||
forward = asyncio.create_task(
|
||||
_forward(platform, events, game_id, user.sub, send)
|
||||
)
|
||||
try:
|
||||
async for message in ws:
|
||||
if message.kind == "close":
|
||||
break
|
||||
if message.kind != "text" or not isinstance(message.data, str):
|
||||
await send(_error("expected a text frame with a JSON object"))
|
||||
continue
|
||||
await _handle_message(platform, send, game_id, user.sub, message.data)
|
||||
finally:
|
||||
forward.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await forward
|
||||
log.debug("%s disconnected from game %s", user.sub, game_id)
|
||||
|
||||
|
||||
async def _forward(
|
||||
platform: "Platform",
|
||||
events,
|
||||
game_id: str,
|
||||
sub: str,
|
||||
send: Send,
|
||||
) -> None:
|
||||
async for _ in events:
|
||||
session = await platform.game_store.load(game_id)
|
||||
if session is None:
|
||||
return
|
||||
engine = platform.registry.require(session.game_type)
|
||||
await send(_state_message(platform, session, sub))
|
||||
if engine.is_finished(session):
|
||||
await send(
|
||||
{
|
||||
"type": "game_over",
|
||||
**engine.game_over_view(session, sub),
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
async def _handle_message(
|
||||
platform: "Platform", send: Send, game_id: str, sub: str, raw: str
|
||||
) -> None:
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (ValueError, TypeError):
|
||||
log.debug("game %s: malformed message from %s (not JSON)", game_id, sub)
|
||||
await send(_error("invalid JSON"))
|
||||
return
|
||||
if not isinstance(data, dict):
|
||||
log.debug("game %s: malformed message from %s (not an object)", game_id, sub)
|
||||
await send(_error("message must be a JSON object"))
|
||||
return
|
||||
|
||||
action = data.get("action")
|
||||
if action in ("state", "sync"):
|
||||
session = await platform.game_store.load(game_id)
|
||||
if session is not None:
|
||||
await send(_state_message(platform, session, sub))
|
||||
return
|
||||
if not isinstance(action, str):
|
||||
await send(_error(f"unknown action: {action!r}"))
|
||||
return
|
||||
await _handle_action(platform, send, game_id, sub, action, data)
|
||||
|
||||
|
||||
async def _handle_action(
|
||||
platform: "Platform",
|
||||
send: Send,
|
||||
game_id: str,
|
||||
sub: str,
|
||||
action: str,
|
||||
data: Dict[str, Any],
|
||||
) -> None:
|
||||
async with platform.game_store.lock(game_id):
|
||||
session = await platform.game_store.load(game_id)
|
||||
if session is None:
|
||||
await send(_error("game not found", code="not_found"))
|
||||
return
|
||||
engine = platform.registry.require(session.game_type)
|
||||
payload = {key: value for key, value in data.items() if key != "action"}
|
||||
try:
|
||||
engine.handle_action(session, sub, action, payload)
|
||||
except GameError as exc:
|
||||
log.debug("game %s: rejected %r by %s: %s", game_id, action, sub, exc)
|
||||
await send(_error(str(exc), code=exc.code))
|
||||
return
|
||||
log.debug("game %s: %s performed %r", game_id, sub, action)
|
||||
await platform.scheduler.finalize_mutation(session)
|
||||
@@ -0,0 +1,348 @@
|
||||
"""Shared fixtures for the tavolo-platform test suite.
|
||||
|
||||
The centerpiece is :class:`DummyEngine`: a tiny two-player game
|
||||
implementing the platform's
|
||||
:class:`~tavolo.platform.engine.GameEngine` contract, so every platform
|
||||
behaviour (lobby, store, websockets, deadlines, stats) is exercised
|
||||
without importing any real game. Its rules: the first player to reach
|
||||
``target`` plays wins the match.
|
||||
|
||||
:func:`make_platform` builds a throwaway :class:`~kaya.core.KayaApp`
|
||||
wired with in-memory stores, a dummy OIDC mixin (patched per test) and
|
||||
a sqlite :class:`~tavolo.platform.tortoise_mixin.TortoiseMixin`, so
|
||||
tests construct their own app instead of importing a global one.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import unittest.mock as _mock
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Coroutine, Dict, Iterator, List, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
from kaya.core import KayaApp
|
||||
from kaya.oidc import OIDCConfig, OIDCMixin, OIDCUser
|
||||
from kaya.openapi import OpenAPIMixin
|
||||
from kaya.session import InMemorySessionStore, SessionMixin
|
||||
|
||||
from tavolo.platform import (
|
||||
AlreadyJoined,
|
||||
Deadline,
|
||||
GameEngine,
|
||||
GameError,
|
||||
GameFinished,
|
||||
GameNotStarted,
|
||||
GameSession,
|
||||
IllegalMove,
|
||||
LobbyFull,
|
||||
MatchResult,
|
||||
NotYourTurn,
|
||||
Platform,
|
||||
PlatformMixin,
|
||||
PlayerResult,
|
||||
Seat,
|
||||
)
|
||||
from tavolo.platform.auth import get_ws_user # noqa: F401 (re-exported for patching)
|
||||
from tavolo.platform.deadlines import DeadlineScheduler
|
||||
from tavolo.platform.registry import GameRegistry
|
||||
from tavolo.platform.store import InMemoryGameStore
|
||||
from tavolo.platform.tortoise_mixin import TortoiseMixin
|
||||
|
||||
|
||||
class DummyEngine(GameEngine):
|
||||
"""A two-player toy game: first to ``target`` plays wins.
|
||||
|
||||
Team labels are "A"/"B" (one player per team) so Elo paths are
|
||||
exercised too. A ``deadline_in_seconds`` creation option arms a
|
||||
``tick`` deadline that plays for the first player when it fires.
|
||||
"""
|
||||
|
||||
id = "dummy"
|
||||
name = "Dummy game"
|
||||
description = "A two-player toy game for testing the platform."
|
||||
min_players = 2
|
||||
max_players = 2
|
||||
options_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"default": 3,
|
||||
"description": "Plays needed to win the match.",
|
||||
},
|
||||
"deadline_in_seconds": {
|
||||
"type": "number",
|
||||
"description": "Arm a tick deadline this far in the future.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def create(self, session: GameSession, options: Mapping[str, Any]) -> None:
|
||||
target = options.get("target", 3)
|
||||
if isinstance(target, bool) or not isinstance(target, int) or target < 1:
|
||||
raise IllegalMove("target must be a positive integer")
|
||||
# The creator takes team A.
|
||||
creator = session.players[0]
|
||||
session.players[0] = Seat(
|
||||
user_sub=creator.user_sub,
|
||||
display_name=creator.display_name,
|
||||
team="A",
|
||||
)
|
||||
session.state = {
|
||||
"target": target,
|
||||
"plays": [],
|
||||
"started": False,
|
||||
"finished": False,
|
||||
"winner": None,
|
||||
"deadline_in_seconds": options.get("deadline_in_seconds"),
|
||||
}
|
||||
|
||||
def join(self, session: GameSession, user_sub: str, display_name: str) -> None:
|
||||
state = session.state
|
||||
if state["started"]:
|
||||
raise GameNotStarted("game has already started")
|
||||
if session.seated(user_sub):
|
||||
raise AlreadyJoined("already joined this game")
|
||||
if len(session.players) >= 2:
|
||||
raise LobbyFull("game is full")
|
||||
session.players.append(
|
||||
Seat(
|
||||
user_sub=user_sub,
|
||||
display_name=display_name,
|
||||
team="A" if not session.players else "B",
|
||||
)
|
||||
)
|
||||
if len(session.players) == 2:
|
||||
state["started"] = True
|
||||
|
||||
def handle_action(
|
||||
self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any]
|
||||
) -> None:
|
||||
state = session.state
|
||||
if not state["started"]:
|
||||
raise GameNotStarted("the game has not started yet")
|
||||
if state["finished"]:
|
||||
raise GameFinished("the match is over")
|
||||
if not session.seated(user_sub):
|
||||
raise NotYourTurn("you are not seated in this game")
|
||||
if action != "play":
|
||||
raise IllegalMove(f"unknown action: {action!r}")
|
||||
self._play(session, user_sub)
|
||||
|
||||
def _play(self, session: GameSession, user_sub: str) -> None:
|
||||
state = session.state
|
||||
state["plays"].append(user_sub)
|
||||
if len(state["plays"]) >= state["target"]:
|
||||
state["finished"] = True
|
||||
state["winner"] = user_sub
|
||||
|
||||
def view_for(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
|
||||
state = session.state
|
||||
return {
|
||||
"started": state["started"],
|
||||
"finished": state["finished"],
|
||||
"winner": state["winner"],
|
||||
"plays": len(state["plays"]),
|
||||
"target": state["target"],
|
||||
"viewer_seated": session.seated(user_sub),
|
||||
}
|
||||
|
||||
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
|
||||
return {
|
||||
"phase": "playing" if session.state["started"] else "lobby",
|
||||
"target": session.state["target"],
|
||||
}
|
||||
|
||||
def in_lobby(self, session: GameSession) -> bool:
|
||||
return not session.state["started"]
|
||||
|
||||
def is_finished(self, session: GameSession) -> bool:
|
||||
return bool(session.state["finished"])
|
||||
|
||||
def result(self, session: GameSession) -> MatchResult:
|
||||
state = session.state
|
||||
winner = state["winner"]
|
||||
if winner is None:
|
||||
raise GameError("no result: the match is not finished")
|
||||
subs = [seat.user_sub for seat in session.players]
|
||||
plays: List[str] = state["plays"]
|
||||
return MatchResult(
|
||||
teams=[[subs[0]], [subs[1]]],
|
||||
winner_team=subs.index(winner),
|
||||
players=[
|
||||
PlayerResult(
|
||||
user_sub=seat.user_sub,
|
||||
seat=index,
|
||||
won=seat.user_sub == winner,
|
||||
team=seat.team,
|
||||
score=float(plays.count(seat.user_sub)),
|
||||
details={"plays": plays.count(seat.user_sub)},
|
||||
)
|
||||
for index, seat in enumerate(session.players)
|
||||
],
|
||||
summary={
|
||||
"target": state["target"],
|
||||
"plays": len(plays),
|
||||
"winner": winner,
|
||||
},
|
||||
)
|
||||
|
||||
def state_to_json(self, state: Any) -> Dict[str, Any]:
|
||||
return dict(state)
|
||||
|
||||
def state_from_json(self, data: Mapping[str, Any]) -> Any:
|
||||
return dict(data)
|
||||
|
||||
def next_deadline(self, session: GameSession) -> Optional[Deadline]:
|
||||
seconds = session.state.get("deadline_in_seconds")
|
||||
if (
|
||||
seconds is None
|
||||
or not session.state["started"]
|
||||
or session.state["finished"]
|
||||
):
|
||||
return None
|
||||
due_at = datetime.now(timezone.utc) + timedelta(seconds=float(seconds))
|
||||
return Deadline(
|
||||
kind="tick",
|
||||
due_at=due_at,
|
||||
token=f"tick:{len(session.state['plays'])}",
|
||||
)
|
||||
|
||||
def fire_deadline(self, session: GameSession, kind: str, token: str) -> None:
|
||||
current = self.next_deadline(session)
|
||||
if current is None or current.kind != kind or current.token != token:
|
||||
raise GameError("stale deadline")
|
||||
# The house plays for the first player.
|
||||
self._play(session, session.players[0].user_sub)
|
||||
|
||||
|
||||
def make_platform(
|
||||
engines: Sequence[GameEngine] = (DummyEngine(),),
|
||||
) -> Tuple[KayaApp, Platform, TortoiseMixin]:
|
||||
"""Build a throwaway app + platform wired with in-memory stores."""
|
||||
registry = GameRegistry(engines)
|
||||
game_store = InMemoryGameStore(registry)
|
||||
session_mixin = SessionMixin(InMemorySessionStore())
|
||||
oidc_mixin = OIDCMixin(
|
||||
OIDCConfig(
|
||||
issuer="http://localhost:8180/tavolo",
|
||||
client_id="tavolo",
|
||||
client_secret=None,
|
||||
redirect_uri="http://localhost:8080/auth/callback",
|
||||
post_login_redirect="/",
|
||||
post_logout_redirect="/",
|
||||
),
|
||||
session=session_mixin,
|
||||
)
|
||||
tortoise_mixin = TortoiseMixin(
|
||||
database_url="sqlite://:memory:",
|
||||
models_modules=["tavolo.platform.models"],
|
||||
skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}),
|
||||
)
|
||||
openapi_mixin = OpenAPIMixin(
|
||||
title="tavolo-platform-tests",
|
||||
version="0.1.0",
|
||||
description="test app",
|
||||
spec_path="/api/openapi.json",
|
||||
docs_path="/api/docs",
|
||||
)
|
||||
scheduler = DeadlineScheduler(game_store, registry, heartbeat_ms=50)
|
||||
platform = Platform(
|
||||
registry=registry,
|
||||
game_store=game_store,
|
||||
scheduler=scheduler,
|
||||
oidc=oidc_mixin,
|
||||
)
|
||||
app = KayaApp(
|
||||
mixins=[
|
||||
session_mixin,
|
||||
oidc_mixin,
|
||||
tortoise_mixin,
|
||||
openapi_mixin,
|
||||
PlatformMixin(platform),
|
||||
]
|
||||
)
|
||||
_tortoise_mixins.append(tortoise_mixin)
|
||||
_schedulers.append(scheduler)
|
||||
return app, platform, tortoise_mixin
|
||||
|
||||
|
||||
_tortoise_mixins: List[TortoiseMixin] = []
|
||||
_schedulers: List[DeadlineScheduler] = []
|
||||
|
||||
|
||||
def async_test(coro: Callable[..., Coroutine[Any, Any, None]]) -> Callable[..., None]:
|
||||
"""Like ``pwo.async_test`` (fresh loop per test), but tear down the
|
||||
platform pieces afterwards: close Tortoise contexts (otherwise
|
||||
orphaned aiosqlite threads block interpreter shutdown) and stop
|
||||
deadline consumers."""
|
||||
|
||||
@wraps(coro)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> None:
|
||||
async def run() -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
await coro(*args, **kwargs)
|
||||
finally:
|
||||
for scheduler in _schedulers:
|
||||
scheduler.stop_consumer(loop)
|
||||
_schedulers.clear()
|
||||
for mixin in _tortoise_mixins:
|
||||
await mixin.aclose()
|
||||
_tortoise_mixins.clear()
|
||||
|
||||
with asyncio.Runner() as runner:
|
||||
runner.run(run())
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
async def use_db(tortoise_mixin: TortoiseMixin):
|
||||
"""Bind the app's Tortoise context for this loop, for seeding rows."""
|
||||
from tortoise.context import TortoiseContext
|
||||
|
||||
await tortoise_mixin._bind()
|
||||
ctx: Optional[TortoiseContext] = tortoise_mixin._ctx
|
||||
assert ctx is not None
|
||||
return ctx
|
||||
|
||||
|
||||
def make_user(sub: str, name: Optional[str] = None) -> OIDCUser:
|
||||
return OIDCUser({"sub": sub, "preferred_username": name or sub})
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def oidc_user(oidc: OIDCMixin, sub: str, name: Optional[str] = None) -> Iterator[OIDCUser]:
|
||||
"""Context manager: patch ``oidc.get_user`` to return this user."""
|
||||
user = make_user(sub, name)
|
||||
patcher = _mock.patch.object(oidc, "get_user", return_value=user)
|
||||
patcher.start()
|
||||
try:
|
||||
yield user
|
||||
finally:
|
||||
patcher.stop()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def ws_users(users: Sequence[OIDCUser]) -> Iterator[None]:
|
||||
"""Context manager: patch ``auth.get_ws_user`` to hand out ``users``
|
||||
one per websocket connection, in order. Once exhausted it keeps
|
||||
returning the last user."""
|
||||
from tavolo.platform import auth
|
||||
|
||||
remaining = list(users)
|
||||
last = remaining[-1] if remaining else None
|
||||
|
||||
def _next(_ws):
|
||||
if remaining:
|
||||
return remaining.pop(0)
|
||||
return last
|
||||
|
||||
patcher = _mock.patch.object(auth, "get_ws_user", side_effect=_next)
|
||||
patcher.start()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
patcher.stop()
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Deadline-scheduler tests, driven by the DummyEngine.
|
||||
|
||||
Timeouts must be driven by the persisted deadlines and the shared queue,
|
||||
not by connected sockets: these tests seed sessions, enqueue their
|
||||
deadlines and let the background consumer fire them without a single
|
||||
websocket. The engine owns the meaning of each deadline; the scheduler
|
||||
owns enqueueing, delivery and removal.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from tavolo.platform import GameSession, Seat
|
||||
from tavolo.platform.deadlines import encode
|
||||
from helpers import DummyEngine, async_test, make_platform, use_db
|
||||
|
||||
|
||||
def _started_session(
|
||||
game_id: str = "dl-1",
|
||||
code: str = "DL0001",
|
||||
target: int = 3,
|
||||
deadline_in_seconds: Optional[float] = None,
|
||||
) -> GameSession:
|
||||
engine = DummyEngine()
|
||||
session = GameSession(
|
||||
id=game_id,
|
||||
game_type=engine.id,
|
||||
join_code=code,
|
||||
creator_sub="alice",
|
||||
players=[Seat(user_sub="alice", display_name="alice", team="A")],
|
||||
)
|
||||
options: Dict[str, Any] = {"target": target}
|
||||
if deadline_in_seconds is not None:
|
||||
options["deadline_in_seconds"] = deadline_in_seconds
|
||||
engine.create(session, options)
|
||||
engine.join(session, "bob", "bob")
|
||||
return session
|
||||
|
||||
|
||||
async def _wait_for(predicate, timeout: float = 5.0):
|
||||
"""Poll the store until ``predicate`` returns a truthy value."""
|
||||
deadline = asyncio.get_running_loop().time() + timeout
|
||||
while asyncio.get_running_loop().time() < deadline:
|
||||
result = await predicate()
|
||||
if result:
|
||||
return result
|
||||
await asyncio.sleep(0.05)
|
||||
return None
|
||||
|
||||
|
||||
class ConnectionIndependenceTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_tick_fires_with_no_connections(self) -> None:
|
||||
_, platform, _ = make_platform()
|
||||
store = platform.game_store
|
||||
scheduler = platform.scheduler
|
||||
session = _started_session(deadline_in_seconds=0.05)
|
||||
await store.save(session)
|
||||
await scheduler.sync_deadline(session)
|
||||
|
||||
# Nobody ever connects: the consumer must still fire the tick,
|
||||
# which plays for the first player.
|
||||
result = await _wait_for(
|
||||
lambda: _plays_is(store, session.id, 1),
|
||||
)
|
||||
self.assertIsNotNone(result, "deadline never fired")
|
||||
|
||||
@async_test
|
||||
async def test_no_deadline_nothing_enqueued(self) -> None:
|
||||
_, platform, _ = make_platform()
|
||||
session = _started_session() # no deadline_in_seconds option
|
||||
await platform.game_store.save(session)
|
||||
await platform.scheduler.sync_deadline(session)
|
||||
self.assertIsNone(await platform.game_store.next_deadline())
|
||||
|
||||
|
||||
async def _plays_is(store, game_id: str, count: int) -> Optional[GameSession]:
|
||||
session = await store.load(game_id)
|
||||
if session is not None and len(session.state["plays"]) == count:
|
||||
return session
|
||||
return None
|
||||
|
||||
|
||||
class ProcessDueTest(unittest.TestCase):
|
||||
"""Direct ``process_due`` behaviour: engine revalidation and idempotency."""
|
||||
|
||||
@async_test
|
||||
async def test_processing_twice_is_a_no_op(self) -> None:
|
||||
# Simulates a worker dying after firing but before removing the
|
||||
# entry: another worker re-delivers the same entry. The engine's
|
||||
# token has moved on, so the second delivery is stale.
|
||||
_, platform, _ = make_platform()
|
||||
store = platform.game_store
|
||||
scheduler = platform.scheduler
|
||||
session = _started_session(deadline_in_seconds=3600)
|
||||
await store.save(session)
|
||||
deadline = DummyEngine().next_deadline(session)
|
||||
assert deadline is not None
|
||||
member = encode({
|
||||
"game_id": session.id,
|
||||
"kind": deadline.kind,
|
||||
"token": deadline.token,
|
||||
})
|
||||
|
||||
await scheduler.process_due(member)
|
||||
await scheduler.process_due(member)
|
||||
|
||||
result = await store.load(session.id)
|
||||
assert result is not None
|
||||
# Fired exactly once: one play, not two.
|
||||
self.assertEqual(["alice"], result.state["plays"])
|
||||
|
||||
@async_test
|
||||
async def test_stale_entry_is_discarded(self) -> None:
|
||||
# A tick enqueued before a play landed in time: the token has
|
||||
# moved, so the entry must not fire.
|
||||
_, platform, _ = make_platform()
|
||||
scheduler = platform.scheduler
|
||||
store = platform.game_store
|
||||
session = _started_session(deadline_in_seconds=3600)
|
||||
session.state["plays"].append("alice") # a play landed in time
|
||||
await store.save(session)
|
||||
member = encode({
|
||||
"game_id": session.id,
|
||||
"kind": "tick",
|
||||
"token": "tick:0", # not the live token ("tick:1")
|
||||
})
|
||||
await store.add_deadline(member, due_at=0.0)
|
||||
|
||||
await scheduler.process_due(member)
|
||||
|
||||
result = await store.load(session.id)
|
||||
assert result is not None
|
||||
self.assertEqual(["alice"], result.state["plays"])
|
||||
# The entry was removed after processing.
|
||||
self.assertNotIn(member, await store.due_deadlines(float("inf")))
|
||||
|
||||
@async_test
|
||||
async def test_entry_for_expired_game_is_dropped(self) -> None:
|
||||
_, platform, _ = make_platform()
|
||||
scheduler = platform.scheduler
|
||||
store = platform.game_store
|
||||
member = encode({
|
||||
"game_id": "dl-gone",
|
||||
"kind": "tick",
|
||||
"token": "tick:0",
|
||||
})
|
||||
await store.add_deadline(member, due_at=0.0)
|
||||
await scheduler.process_due(member)
|
||||
self.assertNotIn(member, await store.due_deadlines(float("inf")))
|
||||
|
||||
@async_test
|
||||
async def test_malformed_entry_is_dropped(self) -> None:
|
||||
_, platform, _ = make_platform()
|
||||
scheduler = platform.scheduler
|
||||
store = platform.game_store
|
||||
await store.add_deadline("not json", due_at=0.0)
|
||||
await scheduler.process_due("not json")
|
||||
self.assertNotIn("not json", await store.due_deadlines(float("inf")))
|
||||
|
||||
@async_test
|
||||
async def test_finished_match_is_persisted_on_tick(self) -> None:
|
||||
# A tick that completes the match writes the result to Postgres.
|
||||
from tavolo.platform.models import Match
|
||||
|
||||
_, platform, tortoise_mixin = make_platform()
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
scheduler = platform.scheduler
|
||||
store = platform.game_store
|
||||
session = _started_session(target=1, deadline_in_seconds=3600)
|
||||
await store.save(session)
|
||||
deadline = DummyEngine().next_deadline(session)
|
||||
assert deadline is not None
|
||||
member = encode({
|
||||
"game_id": session.id,
|
||||
"kind": deadline.kind,
|
||||
"token": deadline.token,
|
||||
})
|
||||
with ctx:
|
||||
await scheduler.process_due(member)
|
||||
self.assertEqual(1, await Match.all().count())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Unit tests for the chess-style Elo math in :mod:`tavolo.platform.elo`."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from tavolo.platform.elo import (
|
||||
INITIAL_RATING,
|
||||
K_FACTOR,
|
||||
expected_score,
|
||||
match_delta,
|
||||
team_rating,
|
||||
)
|
||||
|
||||
|
||||
class ExpectedScoreTest(unittest.TestCase):
|
||||
def test_equal_ratings_give_even_odds(self) -> None:
|
||||
self.assertAlmostEqual(0.5, expected_score(1500, 1500))
|
||||
|
||||
def test_higher_rating_is_favoured(self) -> None:
|
||||
self.assertGreater(expected_score(1700, 1500), 0.5)
|
||||
self.assertLess(expected_score(1500, 1700), 0.5)
|
||||
|
||||
def test_scores_sum_to_one(self) -> None:
|
||||
self.assertAlmostEqual(
|
||||
1.0, expected_score(1600, 1400) + expected_score(1400, 1600)
|
||||
)
|
||||
|
||||
def test_four_hundred_points_is_ten_to_one(self) -> None:
|
||||
self.assertAlmostEqual(10 / 11, expected_score(1900, 1500))
|
||||
|
||||
|
||||
class TeamRatingTest(unittest.TestCase):
|
||||
def test_mean_of_members(self) -> None:
|
||||
self.assertEqual(1600, team_rating([1500, 1700]))
|
||||
|
||||
def test_empty_team_rejected(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
team_rating([])
|
||||
|
||||
|
||||
class MatchDeltaTest(unittest.TestCase):
|
||||
def test_equal_teams_exchange_half_k(self) -> None:
|
||||
delta = match_delta([1500, 1500], [1500, 1500], winner_team=0)
|
||||
self.assertEqual(K_FACTOR // 2, delta)
|
||||
|
||||
def test_favourite_gains_less_than_underdog(self) -> None:
|
||||
favourite = match_delta([1700, 1700], [1500, 1500], winner_team=0)
|
||||
underdog = match_delta([1500, 1500], [1700, 1700], winner_team=0)
|
||||
self.assertGreater(underdog, favourite)
|
||||
self.assertGreater(favourite, 0)
|
||||
|
||||
def test_losing_side_loses_the_winners_gain(self) -> None:
|
||||
# Zero-sum: the losers' delta is the negation of the winners'.
|
||||
win = match_delta([1600, 1500], [1400, 1500], winner_team=0)
|
||||
loss = match_delta([1600, 1500], [1400, 1500], winner_team=1)
|
||||
self.assertEqual(-win, -abs(win)) # winner gains
|
||||
# Losing the same pairing costs K * E, winning gains K * (1 - E);
|
||||
# both are computed from the same expectation, so loss = win - K.
|
||||
self.assertEqual(win - K_FACTOR, loss)
|
||||
|
||||
def test_team_average_decides_not_individual_ratings(self) -> None:
|
||||
# [1700, 1300] averages 1500, same as [1500, 1500].
|
||||
mixed = match_delta([1700, 1300], [1500, 1500], winner_team=0)
|
||||
even = match_delta([1500, 1500], [1500, 1500], winner_team=0)
|
||||
self.assertEqual(even, mixed)
|
||||
|
||||
def test_initial_rating_constant(self) -> None:
|
||||
self.assertEqual(1500, INITIAL_RATING)
|
||||
self.assertEqual(32, K_FACTOR)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Game lobby route tests via kaya's ASGI transport, on the DummyEngine."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from helpers import async_test, make_platform, oidc_user
|
||||
|
||||
|
||||
class GamesRouteTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_create_requires_auth(self) -> None:
|
||||
app, _, _ = make_platform()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
response = await client.post("/api/games", json={})
|
||||
self.assertEqual(401, response.status_code)
|
||||
self.assertEqual({"error": "unauthenticated"}, response.json())
|
||||
|
||||
@async_test
|
||||
async def test_create_and_read_lobby(self) -> None:
|
||||
app, platform, _ = make_platform()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
created = await client.post(
|
||||
"/api/games", json={"options": {"target": 5}}
|
||||
)
|
||||
self.assertEqual(201, created.status_code)
|
||||
body = created.json()
|
||||
self.assertEqual("lobby", body["phase"])
|
||||
self.assertEqual(1, body["seats_open"])
|
||||
self.assertEqual(5, body["target"])
|
||||
self.assertEqual("dummy", body["game_type"])
|
||||
self.assertEqual("A", body["players"][0]["team"])
|
||||
self.assertEqual(6, len(body["join_code"]))
|
||||
game_id = body["id"]
|
||||
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
snapshot = await client.get(f"/api/games/{game_id}")
|
||||
self.assertEqual(200, snapshot.status_code)
|
||||
snap = snapshot.json()
|
||||
self.assertEqual(game_id, snap["id"])
|
||||
self.assertEqual("dummy", snap["game_type"])
|
||||
self.assertTrue(snap["viewer_seated"])
|
||||
|
||||
with oidc_user(platform.oidc, "mallory"):
|
||||
forbidden = await client.get(f"/api/games/{game_id}")
|
||||
self.assertEqual(403, forbidden.status_code)
|
||||
|
||||
@async_test
|
||||
async def test_join_starts_game(self) -> None:
|
||||
app, platform, _ = make_platform()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
created = await client.post("/api/games", json={})
|
||||
code = created.json()["join_code"]
|
||||
|
||||
with oidc_user(platform.oidc, "bob"):
|
||||
started = await client.post("/api/games/join", json={"code": code})
|
||||
self.assertEqual(200, started.status_code)
|
||||
state = started.json()
|
||||
# The second join started the match, so the response is the
|
||||
# personalized view rather than the lobby payload.
|
||||
self.assertTrue(state["started"])
|
||||
self.assertFalse(state["finished"])
|
||||
self.assertEqual(0, state["plays"])
|
||||
self.assertTrue(state["viewer_seated"])
|
||||
|
||||
@async_test
|
||||
async def test_create_rejects_bad_options(self) -> None:
|
||||
app, platform, _ = make_platform()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
zero = await client.post(
|
||||
"/api/games", json={"options": {"target": 0}}
|
||||
)
|
||||
text = await client.post(
|
||||
"/api/games", json={"options": {"target": "three"}}
|
||||
)
|
||||
non_object = await client.post(
|
||||
"/api/games", json={"options": [1, 2]}
|
||||
)
|
||||
self.assertEqual(400, zero.status_code)
|
||||
self.assertEqual(400, text.status_code)
|
||||
self.assertEqual(400, non_object.status_code)
|
||||
|
||||
@async_test
|
||||
async def test_join_errors(self) -> None:
|
||||
app, platform, _ = make_platform()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
created = await client.post("/api/games", json={})
|
||||
code = created.json()["join_code"]
|
||||
|
||||
with oidc_user(platform.oidc, "bob"):
|
||||
unknown = await client.post("/api/games/join", json={"code": "ZZZZZZ"})
|
||||
self.assertEqual(404, unknown.status_code)
|
||||
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
duplicate = await client.post("/api/games/join", json={"code": code})
|
||||
self.assertEqual(409, duplicate.status_code)
|
||||
|
||||
with oidc_user(platform.oidc, "bob"):
|
||||
missing = await client.post("/api/games/join", json={})
|
||||
self.assertEqual(400, missing.status_code)
|
||||
|
||||
with oidc_user(platform.oidc, "bob"):
|
||||
await client.post("/api/games/join", json={"code": code})
|
||||
with oidc_user(platform.oidc, "erin"):
|
||||
late = await client.post("/api/games/join", json={"code": code})
|
||||
self.assertEqual(409, late.status_code)
|
||||
|
||||
@async_test
|
||||
async def test_get_unknown_game(self) -> None:
|
||||
app, platform, _ = make_platform()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
response = await client.get("/api/games/does-not-exist")
|
||||
self.assertEqual(404, response.status_code)
|
||||
|
||||
|
||||
class GameTypesRouteTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_lists_available_game_types(self) -> None:
|
||||
app, _, _ = make_platform()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/api/game-types")
|
||||
self.assertEqual(200, response.status_code)
|
||||
results = response.json()["results"]
|
||||
self.assertEqual(["dummy"], [g["id"] for g in results])
|
||||
self.assertEqual("Dummy game", results[0]["name"])
|
||||
self.assertTrue(results[0]["description"])
|
||||
self.assertEqual(2, results[0]["min_players"])
|
||||
self.assertEqual(2, results[0]["max_players"])
|
||||
self.assertIn("target", results[0]["options_schema"]["properties"])
|
||||
|
||||
@async_test
|
||||
async def test_create_defaults_game_type(self) -> None:
|
||||
app, platform, _ = make_platform()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
created = await client.post("/api/games", json={})
|
||||
self.assertEqual(201, created.status_code)
|
||||
self.assertEqual("dummy", created.json()["game_type"])
|
||||
|
||||
@async_test
|
||||
async def test_create_with_explicit_game_type(self) -> None:
|
||||
app, platform, _ = make_platform()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
created = await client.post(
|
||||
"/api/games", json={"game_type": "dummy"}
|
||||
)
|
||||
self.assertEqual(201, created.status_code)
|
||||
body = created.json()
|
||||
self.assertEqual("dummy", body["game_type"])
|
||||
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
snapshot = await client.get(f"/api/games/{body['id']}")
|
||||
self.assertEqual("dummy", snapshot.json()["game_type"])
|
||||
|
||||
@async_test
|
||||
async def test_create_rejects_unknown_game_type(self) -> None:
|
||||
app, platform, _ = make_platform()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
unknown = await client.post("/api/games", json={"game_type": "briscola"})
|
||||
non_string = await client.post("/api/games", json={"game_type": 42})
|
||||
self.assertEqual(400, unknown.status_code)
|
||||
self.assertEqual(400, non_string.status_code)
|
||||
|
||||
|
||||
class MeRouteTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_me_authenticated(self) -> None:
|
||||
app, platform, _ = make_platform()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
response = await client.get("/api/me")
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual({"sub": "alice", "name": "alice"}, response.json())
|
||||
|
||||
@async_test
|
||||
async def test_me_unauthenticated(self) -> None:
|
||||
app, _, _ = make_platform()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/api/me")
|
||||
self.assertEqual(401, response.status_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,392 @@
|
||||
"""Match-statistics tests: Postgres persistence and the stats endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from tavolo.platform.elo import INITIAL_RATING
|
||||
from tavolo.platform.models import Match, MatchPlayer, PlayerRating
|
||||
from tavolo.platform.stats import save_match_result
|
||||
from helpers import DummyEngine, async_test, make_platform, oidc_user, use_db
|
||||
|
||||
|
||||
def _finished_session(target: int = 2):
|
||||
"""A started dummy session one play short of completion."""
|
||||
from tavolo.platform import GameSession, Seat
|
||||
|
||||
engine = DummyEngine()
|
||||
session = GameSession(
|
||||
id="stats-game",
|
||||
game_type=engine.id,
|
||||
join_code="STATS1",
|
||||
creator_sub="alice",
|
||||
players=[Seat(user_sub="alice", display_name="alice", team="A")],
|
||||
)
|
||||
engine.create(session, {"target": target})
|
||||
engine.join(session, "bob", "bob")
|
||||
engine.handle_action(session, "alice", "play", {})
|
||||
return engine, session
|
||||
|
||||
|
||||
class SaveMatchResultTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_finished_match_is_persisted_once(self) -> None:
|
||||
_, _, tortoise_mixin = make_platform()
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
engine, session = _finished_session()
|
||||
engine.handle_action(session, "alice", "play", {})
|
||||
self.assertTrue(engine.is_finished(session))
|
||||
|
||||
with ctx:
|
||||
await save_match_result(session, engine)
|
||||
await save_match_result(session, engine) # idempotent
|
||||
self.assertEqual(1, await Match.all().count())
|
||||
self.assertEqual(2, await MatchPlayer.all().count())
|
||||
|
||||
match = await Match.all().first()
|
||||
assert match is not None
|
||||
# The game type travels from the session onto the row; the
|
||||
# engine's summary is stored verbatim as the result.
|
||||
self.assertEqual("dummy", match.game_type)
|
||||
self.assertEqual("alice", match.result["winner"])
|
||||
self.assertEqual(2, match.result["plays"])
|
||||
winners = await MatchPlayer.filter(won=True)
|
||||
self.assertEqual({"alice"}, {p.user_sub for p in winners})
|
||||
scores = {p.user_sub: p.score for p in await MatchPlayer.all()}
|
||||
self.assertEqual({"alice": 2.0, "bob": 0.0}, scores)
|
||||
|
||||
@async_test
|
||||
async def test_finished_match_updates_elo_ratings(self) -> None:
|
||||
_, _, tortoise_mixin = make_platform()
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
engine, session = _finished_session()
|
||||
engine.handle_action(session, "alice", "play", {})
|
||||
|
||||
with ctx:
|
||||
await save_match_result(session, engine)
|
||||
|
||||
ratings = {
|
||||
row.user_sub: row for row in await PlayerRating.all()
|
||||
}
|
||||
self.assertEqual(2, len(ratings))
|
||||
# Two players at 1500: winner gains K/2, loser loses it.
|
||||
self.assertEqual(INITIAL_RATING + 16, ratings["alice"].rating)
|
||||
self.assertEqual(1, ratings["alice"].matches_played)
|
||||
self.assertEqual(INITIAL_RATING - 16, ratings["bob"].rating)
|
||||
self.assertEqual(1, ratings["bob"].matches_played)
|
||||
|
||||
# The per-match delta is recorded on each participation row.
|
||||
deltas = {
|
||||
p.user_sub: p.elo_delta for p in await MatchPlayer.all()
|
||||
}
|
||||
self.assertEqual({"alice": 16, "bob": -16}, deltas)
|
||||
|
||||
@async_test
|
||||
async def test_elo_ratings_accumulate_across_matches(self) -> None:
|
||||
_, _, tortoise_mixin = make_platform()
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
engine, session = _finished_session()
|
||||
engine.handle_action(session, "alice", "play", {})
|
||||
|
||||
from tavolo.platform import GameSession, Seat
|
||||
|
||||
engine2, session2 = DummyEngine(), GameSession(
|
||||
id="stats-game-2",
|
||||
game_type="dummy",
|
||||
join_code="STATS2",
|
||||
creator_sub="alice",
|
||||
players=[Seat(user_sub="alice", display_name="alice", team="A")],
|
||||
)
|
||||
engine2.create(session2, {"target": 2})
|
||||
engine2.join(session2, "bob", "bob")
|
||||
# Bob wins the second match.
|
||||
engine2.handle_action(session2, "bob", "play", {})
|
||||
engine2.handle_action(session2, "bob", "play", {})
|
||||
|
||||
with ctx:
|
||||
await save_match_result(session, engine)
|
||||
await save_match_result(session2, engine2)
|
||||
|
||||
ratings = {
|
||||
row.user_sub: row.rating for row in await PlayerRating.all()
|
||||
}
|
||||
# Match 1: even teams, alice wins (+16/-16). Match 2: alice
|
||||
# is now the favourite (1516 vs 1484), so losing costs 17.
|
||||
self.assertEqual(INITIAL_RATING - 1, ratings["alice"])
|
||||
self.assertEqual(INITIAL_RATING + 1, ratings["bob"])
|
||||
bob = await PlayerRating.get(user_sub="bob")
|
||||
self.assertEqual(2, bob.matches_played)
|
||||
|
||||
@async_test
|
||||
async def test_unfinished_match_is_not_persisted(self) -> None:
|
||||
_, _, tortoise_mixin = make_platform()
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
engine, session = _finished_session()
|
||||
with ctx:
|
||||
await save_match_result(session, engine)
|
||||
self.assertEqual(0, await Match.all().count())
|
||||
|
||||
|
||||
async def _seed_two_matches(
|
||||
tortoise_mixin, game_types: tuple = ("dummy", "dummy")
|
||||
) -> None:
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
with ctx:
|
||||
for index, (winner, finished) in enumerate(
|
||||
[
|
||||
("alice", datetime(2026, 1, 1, 10, tzinfo=timezone.utc)),
|
||||
("bob", datetime(2026, 1, 2, 10, tzinfo=timezone.utc)),
|
||||
]
|
||||
):
|
||||
match = await Match.create(
|
||||
id=uuid.uuid4(),
|
||||
game_type=game_types[index],
|
||||
started_at=datetime(2025, 12, 31, tzinfo=timezone.utc),
|
||||
finished_at=finished,
|
||||
result={"winner": winner, "plays": 3 + index},
|
||||
)
|
||||
for seat, sub in enumerate(("alice", "bob")):
|
||||
await MatchPlayer.create(
|
||||
id=uuid.uuid4(),
|
||||
match=match,
|
||||
user_sub=sub,
|
||||
display_name=sub,
|
||||
seat=seat,
|
||||
team="A" if seat == 0 else "B",
|
||||
won=(sub == winner),
|
||||
score=2.0 + index if sub == winner else 1.0,
|
||||
)
|
||||
|
||||
|
||||
class StatsRouteTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_my_matches_newest_first(self) -> None:
|
||||
app, platform, tortoise_mixin = make_platform()
|
||||
await _seed_two_matches(tortoise_mixin)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
response = await client.get("/api/me/matches")
|
||||
self.assertEqual(200, response.status_code)
|
||||
results = response.json()["results"]
|
||||
self.assertEqual(2, len(results))
|
||||
self.assertEqual("bob", results[0]["result"]["winner"]) # newest first
|
||||
self.assertFalse(results[0]["you_won"])
|
||||
self.assertTrue(results[1]["you_won"])
|
||||
self.assertEqual(2, len(results[0]["players"]))
|
||||
self.assertIn("next_cursor", response.json())
|
||||
|
||||
@async_test
|
||||
async def test_my_matches_pagination(self) -> None:
|
||||
app, platform, tortoise_mixin = make_platform()
|
||||
await _seed_two_matches(tortoise_mixin)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
first = await client.get("/api/me/matches?limit=1")
|
||||
cursor = first.json()["next_cursor"]
|
||||
self.assertIsNotNone(cursor)
|
||||
second = await client.get(f"/api/me/matches?limit=1&cursor={cursor}")
|
||||
self.assertEqual(1, len(first.json()["results"]))
|
||||
self.assertEqual(1, len(second.json()["results"]))
|
||||
self.assertNotEqual(
|
||||
first.json()["results"][0]["id"],
|
||||
second.json()["results"][0]["id"],
|
||||
)
|
||||
|
||||
@async_test
|
||||
async def test_my_matches_requires_auth(self) -> None:
|
||||
app, _, _ = make_platform()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/api/me/matches")
|
||||
self.assertEqual(401, response.status_code)
|
||||
|
||||
@async_test
|
||||
async def test_leaderboard_aggregates(self) -> None:
|
||||
app, _, tortoise_mixin = make_platform()
|
||||
await _seed_two_matches(tortoise_mixin)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/api/leaderboard")
|
||||
self.assertEqual(200, response.status_code)
|
||||
by_sub = {row["user_sub"]: row for row in response.json()["results"]}
|
||||
self.assertEqual(2, by_sub["alice"]["matches"])
|
||||
self.assertEqual(1, by_sub["alice"]["wins"]) # alice won match 1
|
||||
self.assertEqual(3.0, by_sub["alice"]["points"]) # 2.0 + 1.0
|
||||
self.assertEqual(1, by_sub["bob"]["wins"]) # bob won match 2
|
||||
self.assertEqual(4.0, by_sub["bob"]["points"]) # 1.0 + 3.0
|
||||
# Bob leads on points after tying Alice on wins.
|
||||
self.assertEqual("bob", response.json()["results"][0]["user_sub"])
|
||||
|
||||
@async_test
|
||||
async def test_leaderboard_includes_elo_and_sorts_by_it(self) -> None:
|
||||
app, _, tortoise_mixin = make_platform()
|
||||
await _seed_two_matches(tortoise_mixin)
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
with ctx:
|
||||
# Alice outranks everyone despite Bob leading on points.
|
||||
await PlayerRating.create(
|
||||
id=uuid.uuid4(),
|
||||
user_sub="alice",
|
||||
game_type="dummy",
|
||||
rating=1600,
|
||||
matches_played=2,
|
||||
)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/api/leaderboard")
|
||||
self.assertEqual(200, response.status_code)
|
||||
results = response.json()["results"]
|
||||
by_sub = {row["user_sub"]: row for row in results}
|
||||
self.assertEqual(1600, by_sub["alice"]["elo"])
|
||||
# Players without a rating row report the initial rating.
|
||||
self.assertEqual(INITIAL_RATING, by_sub["bob"]["elo"])
|
||||
# Elo outranks wins/points.
|
||||
self.assertEqual("alice", results[0]["user_sub"])
|
||||
|
||||
@async_test
|
||||
async def test_leaderboard_elo_scoped_by_game_type(self) -> None:
|
||||
app, platform, tortoise_mixin = make_platform(
|
||||
engines=(DummyEngine(), SecondEngine())
|
||||
)
|
||||
await _seed_two_matches(tortoise_mixin)
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
with ctx:
|
||||
await PlayerRating.create(
|
||||
id=uuid.uuid4(),
|
||||
user_sub="bob",
|
||||
game_type="dummy",
|
||||
rating=1516,
|
||||
matches_played=1,
|
||||
)
|
||||
# Bob's rating in another game must not leak into the
|
||||
# dummy leaderboard.
|
||||
await PlayerRating.create(
|
||||
id=uuid.uuid4(),
|
||||
user_sub="bob",
|
||||
game_type="second",
|
||||
rating=1800,
|
||||
matches_played=1,
|
||||
)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/api/leaderboard?game_type=dummy")
|
||||
self.assertEqual(200, response.status_code)
|
||||
results = response.json()["results"]
|
||||
by_sub = {row["user_sub"]: row for row in results}
|
||||
self.assertEqual(1516, by_sub["bob"]["elo"])
|
||||
self.assertEqual(INITIAL_RATING, by_sub["alice"]["elo"])
|
||||
self.assertEqual("second", platform.registry.all()[1].id)
|
||||
|
||||
@async_test
|
||||
async def test_my_matches_include_elo_delta(self) -> None:
|
||||
app, platform, tortoise_mixin = make_platform()
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
engine, session = _finished_session()
|
||||
engine.handle_action(session, "alice", "play", {})
|
||||
with ctx:
|
||||
await save_match_result(session, engine)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
response = await client.get("/api/me/matches")
|
||||
self.assertEqual(200, response.status_code)
|
||||
players = {
|
||||
p["user_sub"]: p
|
||||
for p in response.json()["results"][0]["players"]
|
||||
}
|
||||
self.assertEqual(16, players["alice"]["elo_delta"])
|
||||
self.assertEqual(-16, players["bob"]["elo_delta"])
|
||||
self.assertEqual(16, response.json()["results"][0]["your_elo_delta"])
|
||||
|
||||
@async_test
|
||||
async def test_my_ratings_requires_auth(self) -> None:
|
||||
app, _, _ = make_platform()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/api/me/ratings")
|
||||
self.assertEqual(401, response.status_code)
|
||||
|
||||
@async_test
|
||||
async def test_my_ratings_returns_only_own_rows(self) -> None:
|
||||
app, platform, tortoise_mixin = make_platform()
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
with ctx:
|
||||
await PlayerRating.create(
|
||||
id=uuid.uuid4(),
|
||||
user_sub="alice",
|
||||
game_type="dummy",
|
||||
rating=1516,
|
||||
matches_played=1,
|
||||
)
|
||||
await PlayerRating.create(
|
||||
id=uuid.uuid4(),
|
||||
user_sub="bob",
|
||||
game_type="dummy",
|
||||
rating=1484,
|
||||
matches_played=1,
|
||||
)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
response = await client.get("/api/me/ratings")
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual(
|
||||
[{"game_type": "dummy", "rating": 1516, "matches_played": 1}],
|
||||
response.json()["results"],
|
||||
)
|
||||
|
||||
|
||||
class SecondEngine(DummyEngine):
|
||||
id = "second"
|
||||
name = "Second game"
|
||||
|
||||
|
||||
class GameTypeFilterTest(unittest.TestCase):
|
||||
"""Stats endpoints scope results by the match's game type."""
|
||||
|
||||
@async_test
|
||||
async def test_my_matches_filter_by_game_type(self) -> None:
|
||||
# The second seed names a game the registry does not know; rows are
|
||||
# written directly, so this only exercises the SQL filter.
|
||||
app, platform, tortoise_mixin = make_platform()
|
||||
await _seed_two_matches(tortoise_mixin, game_types=("dummy", "other_game"))
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
all_matches = await client.get("/api/me/matches")
|
||||
scoped = await client.get("/api/me/matches?game_type=dummy")
|
||||
unknown = await client.get("/api/me/matches?game_type=briscola")
|
||||
self.assertEqual(2, len(all_matches.json()["results"]))
|
||||
self.assertEqual(
|
||||
{"dummy", "other_game"},
|
||||
{m["game_type"] for m in all_matches.json()["results"]},
|
||||
)
|
||||
scoped_results = scoped.json()["results"]
|
||||
self.assertEqual(1, len(scoped_results))
|
||||
self.assertEqual("dummy", scoped_results[0]["game_type"])
|
||||
self.assertEqual(400, unknown.status_code)
|
||||
|
||||
@async_test
|
||||
async def test_leaderboard_filter_by_game_type(self) -> None:
|
||||
app, platform, tortoise_mixin = make_platform()
|
||||
await _seed_two_matches(tortoise_mixin, game_types=("dummy", "other_game"))
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
scoped = await client.get("/api/leaderboard?game_type=dummy")
|
||||
unknown = await client.get("/api/leaderboard?game_type=briscola")
|
||||
self.assertEqual(200, scoped.status_code)
|
||||
by_sub = {row["user_sub"]: row for row in scoped.json()["results"]}
|
||||
# Only the first match counts: one match per player, alice won.
|
||||
self.assertEqual(1, by_sub["alice"]["matches"])
|
||||
self.assertEqual(1, by_sub["alice"]["wins"])
|
||||
self.assertEqual(0, by_sub["bob"]["wins"])
|
||||
self.assertEqual(400, unknown.status_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,142 @@
|
||||
"""In-memory game store behaviour (the Redis store shares this interface)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
|
||||
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(_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("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_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(_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(_registry())
|
||||
await store.save(_session())
|
||||
found = await store.find_by_code("code01") # case-insensitive
|
||||
self.assertIsNotNone(found)
|
||||
assert found is not None
|
||||
self.assertEqual("g1", found.id)
|
||||
|
||||
@async_test
|
||||
async def test_load_returns_a_copy(self) -> None:
|
||||
store = InMemoryGameStore(_registry())
|
||||
await store.save(_session())
|
||||
first = await store.load("g1")
|
||||
assert first is not None
|
||||
first.state["target"] = 999
|
||||
second = await store.load("g1")
|
||||
assert second is not None
|
||||
self.assertEqual(5, second.state["target"])
|
||||
|
||||
@async_test
|
||||
async def test_publish_reaches_subscriber(self) -> None:
|
||||
store = InMemoryGameStore(_registry())
|
||||
await store.save(_session())
|
||||
|
||||
received = []
|
||||
|
||||
async with store.subscribe("g1") as events:
|
||||
await store.publish("g1")
|
||||
async for _ in events:
|
||||
received.append(True)
|
||||
break
|
||||
|
||||
self.assertEqual([True], received)
|
||||
|
||||
@async_test
|
||||
async def test_lock_serializes_concurrent_mutations(self) -> None:
|
||||
store = InMemoryGameStore(_registry())
|
||||
order = []
|
||||
|
||||
async def holder() -> None:
|
||||
async with store.lock("g5"):
|
||||
order.append("holder-enter")
|
||||
await asyncio.sleep(0.05)
|
||||
order.append("holder-exit")
|
||||
|
||||
async def contender() -> None:
|
||||
await asyncio.sleep(0.01)
|
||||
async with store.lock("g5"):
|
||||
order.append("contender")
|
||||
|
||||
await asyncio.gather(holder(), contender())
|
||||
self.assertEqual(
|
||||
["holder-enter", "holder-exit", "contender"], order
|
||||
)
|
||||
|
||||
@async_test
|
||||
async def test_deadline_queue(self) -> None:
|
||||
store = InMemoryGameStore(_registry())
|
||||
self.assertIsNone(await store.next_deadline())
|
||||
self.assertEqual([], await store.due_deadlines(now=100.0))
|
||||
|
||||
await store.add_deadline("b", due_at=50.0)
|
||||
await store.add_deadline("a", due_at=10.0)
|
||||
await store.add_deadline("c", due_at=200.0)
|
||||
# Re-adding an existing member only updates its due time.
|
||||
await store.add_deadline("b", due_at=60.0)
|
||||
|
||||
self.assertEqual(10.0, await store.next_deadline())
|
||||
self.assertEqual(["a"], await store.due_deadlines(now=10.0))
|
||||
self.assertEqual(["a", "b"], await store.due_deadlines(now=100.0))
|
||||
# Due entries come out in due-time order and stay queued until removed.
|
||||
self.assertEqual(["a", "b"], await store.due_deadlines(now=100.0))
|
||||
|
||||
await store.remove_deadline("a")
|
||||
await store.remove_deadline("a") # removing twice is a no-op
|
||||
self.assertEqual(60.0, await store.next_deadline())
|
||||
self.assertEqual(["b"], await store.due_deadlines(now=100.0))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,36 @@
|
||||
# tavolo-scopone
|
||||
|
||||
Scopone scientifico — the four-player, fixed-partnership Italian card
|
||||
game — as a [`tavolo-platform`](../tavolo-platform/README.md) game
|
||||
implementation.
|
||||
|
||||
## Contents
|
||||
|
||||
- `state.py` — `ScoponeState` (pure game data: phases, players, hands,
|
||||
table, scores, deadlines), `PlayerState`, `Card`, `Move`, with JSON
|
||||
(de)serialization. No session envelope, no transport, no I/O.
|
||||
- `engine.py` — the pure rules engine: deck, legal captures, plays,
|
||||
auto-play, hand scoring (carte, denara, settebello, primiera, scope,
|
||||
napola), hand-end acknowledgements. Deterministic and I/O-free apart
|
||||
from logging, so the whole rule set is unit-testable.
|
||||
- `errors.py` — scopone-specific errors (`CardNotInHand`); every other
|
||||
failure mode is a shared `tavolo.platform.errors` subclass.
|
||||
- `plugin.py` — `ScoponeEngine(GameEngine)`: the platform-facing adapter.
|
||||
It translates create/join/websocket actions/deadlines into rules-engine
|
||||
calls and back, validates the `target_score`/`napola` creation options,
|
||||
and extracts the `MatchResult` (teams, winner, per-player scores and the
|
||||
match summary persisted as the match's JSON `result`).
|
||||
|
||||
Timeouts are constructor arguments (`turn_timeout_seconds`,
|
||||
`hand_ack_timeout_seconds`), wired from the environment by the
|
||||
application composition root.
|
||||
|
||||
## Development (from `server/`)
|
||||
|
||||
```sh
|
||||
.venv/bin/python -m unittest discover -s packages/tavolo-scopone/tests
|
||||
.venv/bin/python -m mypy -p tavolo.scopone
|
||||
```
|
||||
|
||||
`test_engine.py` covers the pure rules; `test_plugin.py` covers the
|
||||
platform contract (actions, deadlines, serialization, results).
|
||||
@@ -0,0 +1,22 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tavolo-scopone"
|
||||
version = "0.1.0"
|
||||
description = "Scopone scientifico game implementation for the tavolo platform"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"tavolo-platform",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
namespaces = true
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
ignore_missing_imports = true
|
||||
plugins = []
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Scopone scientifico: tavolo's first game implementation.
|
||||
|
||||
The pure rules (:mod:`tavolo.scopone.engine`, :mod:`tavolo.scopone.state`)
|
||||
know nothing about HTTP, Redis or Postgres; :mod:`tavolo.scopone.plugin`
|
||||
adapts them to the platform's
|
||||
:class:`~tavolo.platform.engine.GameEngine` contract so the
|
||||
game-independent platform can host them.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .plugin import ScoponeEngine
|
||||
|
||||
__all__ = ["ScoponeEngine"]
|
||||
+79
-46
@@ -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
|
||||
-----------------
|
||||
@@ -22,6 +24,10 @@ Rules implemented
|
||||
cards), ``settebello`` (the 7 of diamonds), ``primiera`` (best
|
||||
seven/five/four/three card of each suit, all four suits required), plus
|
||||
one point per ``scopa``. Ties on carte/denara/primiera award nothing.
|
||||
* Optional ``napola`` rule (enabled by default): the longest run of
|
||||
consecutive denari starting from the ace scores one point per card when
|
||||
it reaches at least three cards (A-2-3 = 3, A-2-3-4 = 4, ...). A team
|
||||
capturing the whole denari suit (ace to king) wins the match instantly.
|
||||
* The match ends when a team reaches the target score with a clear lead; a
|
||||
tie at or above the target is broken by playing another hand.
|
||||
"""
|
||||
@@ -33,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,
|
||||
@@ -51,9 +59,9 @@ from .state import (
|
||||
SUITS,
|
||||
TEAM_NAMES,
|
||||
Card,
|
||||
GameState,
|
||||
Move,
|
||||
PlayerState,
|
||||
ScoponeState,
|
||||
parse_card,
|
||||
)
|
||||
|
||||
@@ -64,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
|
||||
@@ -127,33 +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",
|
||||
) -> GameState:
|
||||
napola: bool = True,
|
||||
) -> 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")
|
||||
@@ -167,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")
|
||||
@@ -175,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 = []
|
||||
@@ -197,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
|
||||
@@ -208,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,
|
||||
@@ -217,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")
|
||||
@@ -291,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,
|
||||
@@ -317,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:
|
||||
@@ -342,20 +343,28 @@ 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],
|
||||
a,
|
||||
b,
|
||||
)
|
||||
# A full napola (the whole denari suit) wins the match outright,
|
||||
# regardless of the score.
|
||||
napola = details.get("napola")
|
||||
if isinstance(napola, dict):
|
||||
for team, name in enumerate(TEAM_NAMES):
|
||||
if napola.get(name) == 10:
|
||||
state.phase = PHASE_FINISHED
|
||||
state.winner = team
|
||||
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.
|
||||
@@ -365,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.
|
||||
@@ -404,7 +413,23 @@ def primiera_score(captured: Sequence[Card]) -> int:
|
||||
return sum(best.values())
|
||||
|
||||
|
||||
def hand_points(state: GameState) -> Tuple[List[int], Dict[str, object]]:
|
||||
def napola_score(captured: Sequence[Card]) -> int:
|
||||
"""Return the napola value of a capture pile.
|
||||
|
||||
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,
|
||||
...), so the whole suit (ace to king) is worth 10. Shorter runs score
|
||||
nothing. Only one team can score a napola: the ace of denari belongs
|
||||
to exactly one capture pile.
|
||||
"""
|
||||
ranks = {card.rank for card in captured if card.suit == "D"}
|
||||
run = 0
|
||||
while run + 1 in ranks:
|
||||
run += 1
|
||||
return run if run >= 3 else 0
|
||||
|
||||
|
||||
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]
|
||||
@@ -460,16 +485,26 @@ def hand_points(state: GameState) -> Tuple[List[int], Dict[str, object]]:
|
||||
"scope": {"A": scope[0], "B": scope[1]},
|
||||
"award": award,
|
||||
}
|
||||
# Napola (optional rule): consecutive denari from the ace. A run of 10
|
||||
# means the team swept the whole suit and wins the match instantly.
|
||||
if state.napola:
|
||||
napola = [napola_score(piles[t]) for t in (0, 1)]
|
||||
for team in (0, 1):
|
||||
points[team] += napola[team]
|
||||
award["napola"] = next(
|
||||
(TEAM_NAMES[t] for t in (0, 1) if napola[t] > 0), None
|
||||
)
|
||||
details["napola"] = {"A": napola[0], "B": napola[1]}
|
||||
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]] = []
|
||||
@@ -488,11 +523,9 @@ 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,
|
||||
"hand_number": state.hand_number,
|
||||
"dealer": state.dealer,
|
||||
"turn": state.turn,
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Scopone scientifico errors.
|
||||
|
||||
Most failure modes are game-independent and live in
|
||||
:mod:`tavolo.platform.errors`; only genuinely scopone-specific errors
|
||||
are defined here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from tavolo.platform.errors import (
|
||||
AlreadyJoined,
|
||||
GameError,
|
||||
GameFinished,
|
||||
GameNotFound,
|
||||
GameNotStarted,
|
||||
IllegalMove,
|
||||
LobbyFull,
|
||||
NotYourTurn,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AlreadyJoined",
|
||||
"CardNotInHand",
|
||||
"GameError",
|
||||
"GameFinished",
|
||||
"GameNotFound",
|
||||
"GameNotStarted",
|
||||
"IllegalMove",
|
||||
"LobbyFull",
|
||||
"NotYourTurn",
|
||||
]
|
||||
|
||||
|
||||
class CardNotInHand(GameError):
|
||||
"""The played card is not held by the player."""
|
||||
@@ -0,0 +1,266 @@
|
||||
"""The platform-facing adapter for scopone scientifico.
|
||||
|
||||
:class:`ScoponeEngine` implements
|
||||
:class:`~tavolo.platform.engine.GameEngine` on top of the pure rules in
|
||||
:mod:`tavolo.scopone.engine`: it translates platform calls (create, join,
|
||||
websocket actions, deadlines) into rules-engine calls and back, keeping
|
||||
all scopone knowledge inside this package. Nothing outside
|
||||
``tavolo.scopone`` needs to know about phases, turns or hand scoring.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from logging import getLogger
|
||||
from typing import Any, Dict, Mapping, Optional
|
||||
|
||||
from tavolo.platform.engine import (
|
||||
Deadline,
|
||||
GameEngine,
|
||||
GameSession,
|
||||
MatchResult,
|
||||
PlayerResult,
|
||||
Seat,
|
||||
)
|
||||
from tavolo.platform.errors import GameError, IllegalMove
|
||||
|
||||
from . import engine
|
||||
from .state import (
|
||||
DEFAULT_TARGET_SCORE,
|
||||
PHASE_FINISHED,
|
||||
PHASE_HAND_END,
|
||||
PHASE_LOBBY,
|
||||
PHASE_PLAYING,
|
||||
TEAM_NAMES,
|
||||
ScoponeState,
|
||||
)
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
|
||||
def _deadline_ms(iso: Optional[str]) -> Optional[int]:
|
||||
"""Epoch milliseconds for an ISO-8601 deadline, ``None`` when absent
|
||||
or unparseable."""
|
||||
if not iso:
|
||||
return None
|
||||
try:
|
||||
return int(datetime.fromisoformat(iso).timestamp() * 1000)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
class ScoponeEngine(GameEngine):
|
||||
"""Scopone scientifico as a platform game engine."""
|
||||
|
||||
id = "scopone_scientifico"
|
||||
name = "Scopone scientifico"
|
||||
description = (
|
||||
"Four players in fixed partnerships, ten cards each and an empty "
|
||||
"table. First team to the target score wins."
|
||||
)
|
||||
min_players = 4
|
||||
max_players = 4
|
||||
options_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target_score": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"default": DEFAULT_TARGET_SCORE,
|
||||
"description": "Match points the winning team must reach.",
|
||||
},
|
||||
"napola": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": "Score the napola rule; a full denari "
|
||||
"sweep wins the match",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
turn_timeout_seconds: int = engine.DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||
hand_ack_timeout_seconds: int = engine.DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||
) -> None:
|
||||
self._turn_timeout = turn_timeout_seconds
|
||||
self._hand_ack_timeout = hand_ack_timeout_seconds
|
||||
|
||||
# -- lobby ------------------------------------------------------------
|
||||
|
||||
def create(self, session: GameSession, options: Mapping[str, Any]) -> None:
|
||||
target_score = options.get("target_score", DEFAULT_TARGET_SCORE)
|
||||
if isinstance(target_score, bool) or not isinstance(target_score, int):
|
||||
raise IllegalMove("target_score must be an integer")
|
||||
napola = options.get("napola", True)
|
||||
if not isinstance(napola, bool):
|
||||
raise IllegalMove("napola must be a boolean")
|
||||
creator = session.players[0]
|
||||
session.state = engine.create_game(
|
||||
creator_sub=creator.user_sub,
|
||||
creator_name=creator.display_name,
|
||||
target_score=target_score,
|
||||
hand_ack_timeout=self._hand_ack_timeout,
|
||||
turn_timeout=self._turn_timeout,
|
||||
napola=napola,
|
||||
)
|
||||
# The creator takes seat 0, i.e. team A.
|
||||
session.players[0] = Seat(
|
||||
user_sub=creator.user_sub,
|
||||
display_name=creator.display_name,
|
||||
team=TEAM_NAMES[0],
|
||||
)
|
||||
|
||||
def join(self, session: GameSession, user_sub: str, display_name: str) -> None:
|
||||
seat = len(session.players)
|
||||
engine.join_game(session.state, user_sub, display_name)
|
||||
# join_game raises before appending on any violation, so the seat
|
||||
# list stays in sync with the engine's players.
|
||||
session.players.append(
|
||||
Seat(
|
||||
user_sub=user_sub,
|
||||
display_name=display_name,
|
||||
team=TEAM_NAMES[seat % 2],
|
||||
)
|
||||
)
|
||||
|
||||
def in_lobby(self, session: GameSession) -> bool:
|
||||
return session.state.phase == PHASE_LOBBY
|
||||
|
||||
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
|
||||
state: ScoponeState = session.state
|
||||
return {
|
||||
"phase": state.phase,
|
||||
"target_score": state.target_score,
|
||||
"napola": state.napola,
|
||||
}
|
||||
|
||||
# -- play -------------------------------------------------------------
|
||||
|
||||
def handle_action(
|
||||
self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any]
|
||||
) -> None:
|
||||
state: ScoponeState = session.state
|
||||
if action == "play":
|
||||
card = payload.get("card")
|
||||
capture = payload.get("capture")
|
||||
if not isinstance(card, str):
|
||||
raise IllegalMove("'card' must be a card code string")
|
||||
if capture is not None and (
|
||||
not isinstance(capture, list)
|
||||
or any(not isinstance(item, str) for item in capture)
|
||||
):
|
||||
raise IllegalMove("'capture' must be a list of card codes")
|
||||
try:
|
||||
engine.play(state, user_sub, card, capture)
|
||||
except ValueError:
|
||||
raise IllegalMove("invalid card code")
|
||||
elif action == "ack":
|
||||
engine.acknowledge_hand(state, user_sub)
|
||||
else:
|
||||
raise IllegalMove(f"unknown action: {action!r}")
|
||||
|
||||
def view_for(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
|
||||
return engine.state_for_player(session.state, user_sub)
|
||||
|
||||
def is_finished(self, session: GameSession) -> bool:
|
||||
return session.state.phase == PHASE_FINISHED
|
||||
|
||||
def game_over_view(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
|
||||
state: ScoponeState = session.state
|
||||
return {
|
||||
"scores": {"A": state.scores[0], "B": state.scores[1]},
|
||||
"winner": None if state.winner is None else TEAM_NAMES[state.winner],
|
||||
}
|
||||
|
||||
# -- (de)serialization -------------------------------------------------
|
||||
|
||||
def state_to_json(self, state: Any) -> Dict[str, Any]:
|
||||
assert isinstance(state, ScoponeState)
|
||||
return state.to_json()
|
||||
|
||||
def state_from_json(self, data: Mapping[str, Any]) -> Any:
|
||||
return ScoponeState.from_json(dict(data))
|
||||
|
||||
# -- deadlines ----------------------------------------------------------
|
||||
|
||||
def next_deadline(self, session: GameSession) -> Optional[Deadline]:
|
||||
state: ScoponeState = session.state
|
||||
if state.phase == PHASE_PLAYING and state.turn_deadline:
|
||||
due_ms = _deadline_ms(state.turn_deadline)
|
||||
if due_ms is None:
|
||||
return None
|
||||
return Deadline(
|
||||
kind="turn",
|
||||
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
|
||||
token=f"turn:{state.hand_number}:{state.turn}:{due_ms}",
|
||||
)
|
||||
if state.phase == PHASE_HAND_END and state.hand_end_deadline:
|
||||
due_ms = _deadline_ms(state.hand_end_deadline)
|
||||
if due_ms is None:
|
||||
return None
|
||||
return Deadline(
|
||||
kind="hand_end",
|
||||
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
|
||||
token=f"hand_end:{state.hand_number}:{due_ms}",
|
||||
)
|
||||
return None
|
||||
|
||||
def fire_deadline(self, session: GameSession, kind: str, token: str) -> None:
|
||||
current = self.next_deadline(session)
|
||||
if current is None or current.kind != kind or current.token != token:
|
||||
# Overtaken by events (a play landed in time, the hand was
|
||||
# acknowledged, the deadline moved): nothing to do.
|
||||
raise GameError("stale deadline")
|
||||
state: ScoponeState = session.state
|
||||
if kind == "turn":
|
||||
engine.auto_play(state)
|
||||
actor = state.last_move.seat if state.last_move is not None else None
|
||||
log.info(
|
||||
"auto-played for seat %s (turn timeout, hand %d)",
|
||||
actor,
|
||||
state.hand_number,
|
||||
)
|
||||
elif kind == "hand_end":
|
||||
for player in state.players:
|
||||
engine.acknowledge_hand(state, player.sub)
|
||||
log.info(
|
||||
"hand %d auto-advanced after the acknowledgement timeout",
|
||||
state.hand_number,
|
||||
)
|
||||
else: # pragma: no cover - next_deadline never emits other kinds
|
||||
raise GameError(f"unknown deadline kind: {kind!r}")
|
||||
|
||||
# -- results -------------------------------------------------------------
|
||||
|
||||
def result(self, session: GameSession) -> MatchResult:
|
||||
state: ScoponeState = session.state
|
||||
if state.winner is None:
|
||||
raise GameError("no result: the match is not finished")
|
||||
teams = [
|
||||
[p.sub for p in state.players if p.team == 0],
|
||||
[p.sub for p in state.players if p.team == 1],
|
||||
]
|
||||
return MatchResult(
|
||||
teams=teams,
|
||||
winner_team=state.winner,
|
||||
players=[
|
||||
PlayerResult(
|
||||
user_sub=player.sub,
|
||||
seat=player.seat,
|
||||
won=player.team == state.winner,
|
||||
team=TEAM_NAMES[player.team],
|
||||
score=float(state.scores[player.team]),
|
||||
details={"scope": player.scope},
|
||||
)
|
||||
for player in state.players
|
||||
],
|
||||
summary={
|
||||
"team_a_score": state.scores[0],
|
||||
"team_b_score": state.scores[1],
|
||||
"winner_team": TEAM_NAMES[state.winner],
|
||||
"target_score": state.target_score,
|
||||
"hands_played": state.hand_number,
|
||||
"hand_scores": state.hand_scores,
|
||||
},
|
||||
)
|
||||
+18
-32
@@ -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,14 +149,11 @@ 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.
|
||||
napola: bool = True
|
||||
phase: str = PHASE_LOBBY
|
||||
players: List[PlayerState] = field(default_factory=list)
|
||||
table: List[Card] = field(default_factory=list)
|
||||
@@ -163,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
|
||||
@@ -176,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
|
||||
|
||||
@@ -184,11 +182,8 @@ 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,
|
||||
"players": [p.to_json() for p in self.players],
|
||||
"table": [c.to_json() for c in self.table],
|
||||
@@ -199,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,
|
||||
@@ -211,13 +203,10 @@ 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)),
|
||||
players=[PlayerState.from_json(p) for p in data.get("players", [])],
|
||||
table=[Card.from_json(c) for c in data.get("table", [])],
|
||||
@@ -228,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"),
|
||||
@@ -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,
|
||||
@@ -239,9 +236,98 @@ class ScoringTest(unittest.TestCase):
|
||||
self.assertEqual([0, 0], points)
|
||||
|
||||
|
||||
class NapolaTest(unittest.TestCase):
|
||||
def test_napola_score_runs(self) -> None:
|
||||
self.assertEqual(0, engine.napola_score(
|
||||
[card(c) for c in ["02D", "03D", "04D"]])) # no ace
|
||||
self.assertEqual(0, engine.napola_score(
|
||||
[card(c) for c in ["01D", "02D"]])) # too short
|
||||
self.assertEqual(3, engine.napola_score(
|
||||
[card(c) for c in ["03D", "01D", "02D"]])) # order-independent
|
||||
self.assertEqual(4, engine.napola_score(
|
||||
[card(c) for c in ["01D", "02D", "03D", "04D", "07C"]]))
|
||||
self.assertEqual(3, engine.napola_score(
|
||||
[card(c) for c in ["01D", "02D", "03D", "05D"]])) # broken run
|
||||
self.assertEqual(10, engine.napola_score(
|
||||
[card(f"{rank:02d}D") for rank in range(1, 11)]))
|
||||
|
||||
def test_hand_points_napola(self) -> None:
|
||||
state = make_state(
|
||||
[[], [], [], []],
|
||||
table=[],
|
||||
captured=[
|
||||
["01D", "02D", "03D", "04C"], # seat 0, team A
|
||||
["05D", "06D", "07D", "08D"], # seat 1, team B
|
||||
["09D", "10D", "01C", "02C"], # seat 2, team A
|
||||
["03C", "05C", "06C", "07C"], # seat 3, team B
|
||||
],
|
||||
)
|
||||
points, details = engine.hand_points(state)
|
||||
# Team A has the ace-led run 01D-03D (3 points); team B's denari
|
||||
# start at the 5, so no napola. Carte tie (8 each), denara to A
|
||||
# (5 vs 4), settebello to B, primiere tied at 0 (missing suits).
|
||||
self.assertEqual({"A": 3, "B": 0}, details["napola"])
|
||||
self.assertEqual("A", details["award"]["napola"])
|
||||
self.assertEqual([4, 1], points)
|
||||
|
||||
def test_napola_disabled(self) -> None:
|
||||
state = make_state(
|
||||
[[], [], [], []],
|
||||
table=[],
|
||||
captured=[
|
||||
["01D", "02D", "03D", "04C"],
|
||||
["05D", "06D", "07D", "08D"],
|
||||
["09D", "10D", "01C", "02C"],
|
||||
["03C", "05C", "06C", "07C"],
|
||||
],
|
||||
)
|
||||
state.napola = False
|
||||
points, details = engine.hand_points(state)
|
||||
self.assertNotIn("napola", details)
|
||||
self.assertEqual([1, 1], points)
|
||||
|
||||
def test_full_denari_sweep_wins_match_instantly(self) -> None:
|
||||
# Team A already captured the whole denari suit; the last play of
|
||||
# the hand cannot capture. Team B leads 50-0, yet the napola ends
|
||||
# the match in team A's favour, well below the target of 100.
|
||||
state = make_state(
|
||||
[["02C"], [], [], []],
|
||||
table=[],
|
||||
target=100,
|
||||
captured=[
|
||||
[f"{rank:02d}D" for rank in range(1, 11)],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
],
|
||||
)
|
||||
state.scores = [0, 50]
|
||||
engine.play(state, "p0", "02C")
|
||||
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||
self.assertEqual(0, state.winner)
|
||||
self.assertLess(state.scores[0], 100)
|
||||
self.assertEqual(10, state.hand_scores[-1]["napola"]["A"])
|
||||
|
||||
def test_napola_serialization_roundtrip(self) -> None:
|
||||
state = make_state([["02D"], [], [], []], table=[])
|
||||
self.assertTrue(state.napola)
|
||||
state.napola = False
|
||||
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(ScoponeState.from_json(data).napola)
|
||||
|
||||
def test_create_game_napola_default_and_override(self) -> None:
|
||||
self.assertTrue(engine.create_game("p0", "p0").napola)
|
||||
self.assertFalse(
|
||||
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")
|
||||
@@ -293,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}")
|
||||
|
||||
@@ -316,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)
|
||||
@@ -436,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}")
|
||||
@@ -0,0 +1,289 @@
|
||||
"""ScoponeEngine adapter tests: the platform contract over the pure rules."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from tavolo.platform import GameSession, Seat
|
||||
from tavolo.platform.errors import GameError, IllegalMove
|
||||
from tavolo.scopone import ScoponeEngine
|
||||
from tavolo.scopone.state import PHASE_FINISHED, PHASE_HAND_END, PHASE_PLAYING, ScoponeState
|
||||
|
||||
|
||||
def _session(engine: ScoponeEngine, **options) -> GameSession:
|
||||
session = GameSession(
|
||||
id="s1",
|
||||
game_type=engine.id,
|
||||
join_code="CODE01",
|
||||
creator_sub="alice",
|
||||
players=[Seat(user_sub="alice", display_name="Alice")],
|
||||
)
|
||||
engine.create(session, options)
|
||||
return session
|
||||
|
||||
|
||||
def _started(engine: ScoponeEngine, **options) -> GameSession:
|
||||
session = _session(engine, **options)
|
||||
for name in ("bob", "carol", "dave"):
|
||||
engine.join(session, name, name.capitalize())
|
||||
return session
|
||||
|
||||
|
||||
class CreateTest(unittest.TestCase):
|
||||
def test_create_seats_creator_on_team_a(self) -> None:
|
||||
engine = ScoponeEngine()
|
||||
session = _session(engine)
|
||||
self.assertEqual("A", session.players[0].team)
|
||||
self.assertIsInstance(session.state, ScoponeState)
|
||||
self.assertEqual(11, session.state.target_score)
|
||||
self.assertTrue(session.state.napola)
|
||||
|
||||
def test_create_options(self) -> None:
|
||||
engine = ScoponeEngine()
|
||||
session = _session(engine, target_score=16, napola=False)
|
||||
self.assertEqual(16, session.state.target_score)
|
||||
self.assertFalse(session.state.napola)
|
||||
|
||||
def test_create_rejects_bad_options(self) -> None:
|
||||
engine = ScoponeEngine()
|
||||
with self.assertRaises(IllegalMove):
|
||||
_session(engine, target_score=0)
|
||||
with self.assertRaises(IllegalMove):
|
||||
_session(engine, target_score="eleven")
|
||||
with self.assertRaises(IllegalMove):
|
||||
_session(engine, napola="yes")
|
||||
|
||||
def test_timeouts_come_from_the_engine(self) -> None:
|
||||
engine = ScoponeEngine(turn_timeout_seconds=7, hand_ack_timeout_seconds=9)
|
||||
session = _session(engine)
|
||||
self.assertEqual(7, session.state.turn_timeout)
|
||||
self.assertEqual(9, session.state.hand_ack_timeout)
|
||||
|
||||
|
||||
class JoinTest(unittest.TestCase):
|
||||
def test_join_assigns_teams_and_starts(self) -> None:
|
||||
engine = ScoponeEngine()
|
||||
session = _session(engine)
|
||||
self.assertTrue(engine.in_lobby(session))
|
||||
for name, team in (("bob", "B"), ("carol", "A"), ("dave", "B")):
|
||||
engine.join(session, name, name.capitalize())
|
||||
self.assertEqual(team, session.players[-1].team)
|
||||
self.assertFalse(engine.in_lobby(session))
|
||||
self.assertEqual(PHASE_PLAYING, session.state.phase)
|
||||
|
||||
def test_join_errors(self) -> None:
|
||||
from tavolo.platform.errors import AlreadyJoined, GameNotStarted
|
||||
|
||||
engine = ScoponeEngine()
|
||||
session = _session(engine)
|
||||
with self.assertRaises(AlreadyJoined):
|
||||
engine.join(session, "alice", "Alice")
|
||||
for name in ("bob", "carol", "dave"):
|
||||
engine.join(session, name, name.capitalize())
|
||||
# The lobby filled up and the match started: late joins and even
|
||||
# re-joins are rejected as "already started".
|
||||
with self.assertRaises(GameNotStarted):
|
||||
engine.join(session, "erin", "Erin")
|
||||
with self.assertRaises(GameNotStarted):
|
||||
engine.join(session, "alice", "Alice")
|
||||
|
||||
|
||||
class ActionTest(unittest.TestCase):
|
||||
def test_unknown_action_rejected(self) -> None:
|
||||
engine = ScoponeEngine()
|
||||
session = _started(engine)
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "alice", "dance", {})
|
||||
|
||||
def test_play_validates_payload(self) -> None:
|
||||
engine = ScoponeEngine()
|
||||
session = _started(engine)
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "bob", "play", {"card": 42})
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "bob", "play", {})
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "bob", "play", {"card": "01D", "capture": "02C"})
|
||||
|
||||
def test_invalid_card_code_is_illegal_move(self) -> None:
|
||||
engine = ScoponeEngine()
|
||||
session = _started(engine)
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "bob", "play", {"card": "nope"})
|
||||
|
||||
def test_finished_match_rejects_actions(self) -> None:
|
||||
from tavolo.platform.errors import GameFinished
|
||||
|
||||
engine = ScoponeEngine()
|
||||
session = _started(engine, target_score=1)
|
||||
# Drive to completion: keep playing legal moves until finished.
|
||||
from tavolo.scopone import engine as rules
|
||||
|
||||
moves = 0
|
||||
while not engine.is_finished(session) and moves < 200000:
|
||||
state = session.state
|
||||
if state.phase == "hand_end":
|
||||
for p in state.players:
|
||||
engine.handle_action(session, p.sub, "ack", {})
|
||||
continue
|
||||
player = next(p for p in state.players if p.seat == state.turn)
|
||||
played = player.hand[0]
|
||||
options = rules.legal_captures(state.table, played)
|
||||
payload = {"card": played.code}
|
||||
if options:
|
||||
payload["capture"] = [c.code for c in options[0]]
|
||||
engine.handle_action(session, player.sub, "play", payload)
|
||||
moves += 1
|
||||
self.assertTrue(engine.is_finished(session))
|
||||
with self.assertRaises(GameFinished):
|
||||
engine.handle_action(session, "alice", "play", {"card": "01D"})
|
||||
|
||||
|
||||
class ViewTest(unittest.TestCase):
|
||||
def test_view_for_hides_other_hands(self) -> None:
|
||||
engine = ScoponeEngine()
|
||||
session = _started(engine)
|
||||
view = engine.view_for(session, "alice")
|
||||
players = {p["seat"]: p for p in view["players"]}
|
||||
self.assertIn("hand", players[0])
|
||||
self.assertNotIn("hand", players[1])
|
||||
# The envelope is the platform's job, not the view's.
|
||||
self.assertNotIn("id", view)
|
||||
self.assertNotIn("join_code", view)
|
||||
self.assertNotIn("game_type", view)
|
||||
|
||||
def test_lobby_view(self) -> None:
|
||||
engine = ScoponeEngine()
|
||||
session = _session(engine, target_score=16)
|
||||
lobby = engine.lobby_view(session)
|
||||
self.assertEqual("lobby", lobby["phase"])
|
||||
self.assertEqual(16, lobby["target_score"])
|
||||
self.assertTrue(lobby["napola"])
|
||||
|
||||
|
||||
class SerializationTest(unittest.TestCase):
|
||||
def test_state_roundtrip(self) -> None:
|
||||
engine = ScoponeEngine()
|
||||
session = _started(engine)
|
||||
restored = engine.state_from_json(engine.state_to_json(session.state))
|
||||
self.assertIsInstance(restored, ScoponeState)
|
||||
self.assertEqual(session.state.phase, restored.phase)
|
||||
self.assertEqual(session.state.turn, restored.turn)
|
||||
self.assertEqual(
|
||||
[p.sub for p in session.state.players],
|
||||
[p.sub for p in restored.players],
|
||||
)
|
||||
|
||||
|
||||
class DeadlineTest(unittest.TestCase):
|
||||
def test_no_deadline_in_lobby(self) -> None:
|
||||
engine = ScoponeEngine()
|
||||
self.assertIsNone(engine.next_deadline(_session(engine)))
|
||||
|
||||
def test_turn_deadline_and_revalidation(self) -> None:
|
||||
engine = ScoponeEngine()
|
||||
session = _started(engine)
|
||||
deadline = engine.next_deadline(session)
|
||||
assert deadline is not None
|
||||
self.assertEqual("turn", deadline.kind)
|
||||
# A forged token is stale.
|
||||
with self.assertRaises(GameError):
|
||||
engine.fire_deadline(session, "turn", "turn:1:1:0")
|
||||
turn_before = session.state.turn
|
||||
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||
self.assertNotEqual(turn_before, session.state.turn)
|
||||
|
||||
def test_stale_deadline_after_play(self) -> None:
|
||||
from tavolo.scopone import engine as rules
|
||||
|
||||
engine = ScoponeEngine()
|
||||
session = _started(engine)
|
||||
deadline = engine.next_deadline(session)
|
||||
assert deadline is not None
|
||||
# A play lands in time: the armed deadline is overtaken.
|
||||
state = session.state
|
||||
player = next(p for p in state.players if p.seat == state.turn)
|
||||
played = player.hand[0]
|
||||
options = rules.legal_captures(state.table, played)
|
||||
payload = {"card": played.code}
|
||||
if options:
|
||||
payload["capture"] = [c.code for c in options[0]]
|
||||
engine.handle_action(session, player.sub, "play", payload)
|
||||
with self.assertRaises(GameError):
|
||||
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||
|
||||
def test_hand_end_deadline_advances(self) -> None:
|
||||
engine = ScoponeEngine()
|
||||
session = _started(engine)
|
||||
state = session.state
|
||||
# Force the hand-end phase with an imminent deadline.
|
||||
state.phase = PHASE_HAND_END
|
||||
state.hand_end_deadline = (
|
||||
datetime.now(timezone.utc) + timedelta(seconds=60)
|
||||
).isoformat()
|
||||
deadline = engine.next_deadline(session)
|
||||
assert deadline is not None
|
||||
self.assertEqual("hand_end", deadline.kind)
|
||||
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||
self.assertEqual(PHASE_PLAYING, session.state.phase)
|
||||
self.assertEqual(2, session.state.hand_number)
|
||||
|
||||
|
||||
class ResultTest(unittest.TestCase):
|
||||
def test_result_of_finished_match(self) -> None:
|
||||
from tavolo.scopone import engine as rules
|
||||
|
||||
engine = ScoponeEngine()
|
||||
session = _started(engine, target_score=1)
|
||||
moves = 0
|
||||
while not engine.is_finished(session) and moves < 200000:
|
||||
state = session.state
|
||||
if state.phase == "hand_end":
|
||||
for p in state.players:
|
||||
engine.handle_action(session, p.sub, "ack", {})
|
||||
continue
|
||||
player = next(p for p in state.players if p.seat == state.turn)
|
||||
played = player.hand[0]
|
||||
options = rules.legal_captures(state.table, played)
|
||||
payload = {"card": played.code}
|
||||
if options:
|
||||
payload["capture"] = [c.code for c in options[0]]
|
||||
engine.handle_action(session, player.sub, "play", payload)
|
||||
moves += 1
|
||||
result = engine.result(session)
|
||||
self.assertEqual(2, len(result.teams))
|
||||
self.assertIn(result.winner_team, (0, 1))
|
||||
self.assertEqual(4, len(result.players))
|
||||
for player in result.players:
|
||||
self.assertEqual(
|
||||
player.won, player.team == ("A" if result.winner_team == 0 else "B")
|
||||
)
|
||||
self.assertIn("team_a_score", result.summary)
|
||||
self.assertIn("hands_played", result.summary)
|
||||
|
||||
def test_result_requires_finished_match(self) -> None:
|
||||
engine = ScoponeEngine()
|
||||
with self.assertRaises(GameError):
|
||||
engine.result(_started(engine))
|
||||
|
||||
def test_game_over_view(self) -> None:
|
||||
engine = ScoponeEngine()
|
||||
session = _started(engine)
|
||||
session.state.phase = PHASE_FINISHED
|
||||
session.state.winner = 1
|
||||
session.state.scores = [3, 11]
|
||||
over = engine.game_over_view(session, "alice")
|
||||
self.assertEqual({"A": 3, "B": 11}, over["scores"])
|
||||
self.assertEqual("B", over["winner"])
|
||||
|
||||
def test_registry_metadata(self) -> None:
|
||||
engine = ScoponeEngine()
|
||||
self.assertEqual("scopone_scientifico", engine.id)
|
||||
self.assertEqual(4, engine.min_players)
|
||||
self.assertEqual(4, engine.max_players)
|
||||
self.assertIn("target_score", engine.options_schema["properties"])
|
||||
self.assertIn("napola", engine.options_schema["properties"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+10
-20
@@ -3,22 +3,24 @@ 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",
|
||||
"kaya-session-redis",
|
||||
"kaya-oidc",
|
||||
"kaya-openapi",
|
||||
"kaya-rsgi",
|
||||
"granian>=2.0",
|
||||
"tortoise-orm",
|
||||
"aerich",
|
||||
"asyncpg",
|
||||
"aerich",
|
||||
"httpx",
|
||||
"PyJWT[crypto]",
|
||||
"pwo",
|
||||
@@ -31,10 +33,13 @@ dev = [
|
||||
"mypy",
|
||||
"httpx-ws",
|
||||
]
|
||||
otel = [
|
||||
"kaya-otel>=0.0.4",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
namespaces = false
|
||||
namespaces = true
|
||||
|
||||
# Database migrations (aerich). See the Migrations section in README.md.
|
||||
[tool.aerich]
|
||||
@@ -45,18 +50,3 @@ location = "./migrations"
|
||||
python_version = "3.12"
|
||||
ignore_missing_imports = true
|
||||
plugins = []
|
||||
|
||||
# TortoiseORM auto-generates `<fk>_id` attributes on ForeignKeyField at
|
||||
# runtime; without the (unavailable here) tortoise mypy plugin the stubs
|
||||
# only declare the relation field. These are real attributes, not bugs.
|
||||
[[tool.mypy.overrides]]
|
||||
module = "tavolo.models"
|
||||
disable_error_code = ["attr-defined"]
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "tavolo.routes.*"
|
||||
disable_error_code = ["attr-defined"]
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "tavolo.game.*"
|
||||
disable_error_code = ["attr-defined"]
|
||||
|
||||
+32
-25
@@ -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,57 +49,58 @@ 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
|
||||
iso8601==2.1.0
|
||||
# via tortoise-orm
|
||||
kaya-core==0.0.3
|
||||
kaya-core==0.0.4
|
||||
# via
|
||||
# kaya-cors
|
||||
# kaya-oidc
|
||||
# kaya-openapi
|
||||
# kaya-rsgi
|
||||
# kaya-session
|
||||
# tavolo (pyproject.toml)
|
||||
kaya-oidc==0.0.3
|
||||
# via tavolo (pyproject.toml)
|
||||
kaya-openapi==0.0.3
|
||||
# via tavolo (pyproject.toml)
|
||||
kaya-rsgi==0.0.3
|
||||
# via tavolo (pyproject.toml)
|
||||
kaya-session==0.0.3
|
||||
# tavolo-app (pyproject.toml)
|
||||
kaya-cors==0.0.4
|
||||
# via tavolo-app (pyproject.toml)
|
||||
kaya-oidc==0.0.4
|
||||
# via tavolo-app (pyproject.toml)
|
||||
kaya-openapi==0.0.4
|
||||
# via tavolo-app (pyproject.toml)
|
||||
kaya-rsgi==0.0.4
|
||||
# via tavolo-app (pyproject.toml)
|
||||
kaya-session==0.0.4
|
||||
# via
|
||||
# kaya-oidc
|
||||
# kaya-session-redis
|
||||
# tavolo (pyproject.toml)
|
||||
kaya-session-redis==0.0.3
|
||||
# via tavolo (pyproject.toml)
|
||||
# tavolo-app (pyproject.toml)
|
||||
kaya-session-redis==0.0.4
|
||||
# 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
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Scopone scientifico backend built on the kaya framework."""
|
||||
@@ -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",
|
||||
}
|
||||
},
|
||||
|
||||
+142
-29
@@ -1,53 +1,137 @@
|
||||
"""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)
|
||||
|
||||
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.
|
||||
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). The SPA shell routes are registered at the bottom;
|
||||
imports must happen after ``app`` is built.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib.metadata import version as _pkg_version
|
||||
from logging import getLogger
|
||||
from typing import Optional
|
||||
|
||||
from kaya.core import KayaApp
|
||||
from kaya.core import KayaApp, KayaMixin
|
||||
from kaya.cors import CorsMixin
|
||||
from kaya.oidc import OIDCConfig, OIDCMixin
|
||||
from kaya.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
|
||||
from .config import Settings, settings
|
||||
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__)
|
||||
|
||||
|
||||
def cors_mixin_from_settings(settings: Settings) -> Optional[CorsMixin]:
|
||||
"""Build a :class:`~kaya.cors.CorsMixin` from the CORS settings.
|
||||
|
||||
Returns ``None`` — CORS disabled — unless at least one of
|
||||
``CORS_ALLOW_ORIGINS`` / ``CORS_ALLOW_ORIGIN_REGEX`` is configured.
|
||||
Settings left unset fall back to the mixin's own defaults.
|
||||
"""
|
||||
if settings.cors_allow_origins is None and settings.cors_allow_origin_regex is None:
|
||||
return None
|
||||
return CorsMixin(
|
||||
allow_origins=settings.cors_allow_origins or (),
|
||||
allow_origin_regex=settings.cors_allow_origin_regex,
|
||||
allow_methods=settings.cors_allow_methods or ("GET",),
|
||||
allow_headers=settings.cors_allow_headers or (),
|
||||
allow_credentials=settings.cors_allow_credentials,
|
||||
expose_headers=settings.cors_expose_headers or (),
|
||||
max_age=settings.cors_max_age,
|
||||
)
|
||||
|
||||
|
||||
def otel_mixin_from_settings(settings: Settings) -> Optional[KayaMixin]:
|
||||
"""Build a :class:`~kaya.otel.OTelMixin` from the OTEL_* settings.
|
||||
|
||||
Returns ``None`` — telemetry disabled — unless ``OTEL_ENABLED`` is
|
||||
truthy. kaya-otel is an optional dependency (the ``otel`` extra), so it
|
||||
is imported lazily here: default installs and the test suite never need
|
||||
the OpenTelemetry packages.
|
||||
"""
|
||||
if not settings.otel_enabled:
|
||||
return None
|
||||
try:
|
||||
from kaya.otel import OTelMixin
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"OTEL_ENABLED is set but kaya-otel is not installed; "
|
||||
"install tavolo-app with the 'otel' extra"
|
||||
) from exc
|
||||
headers = dict(
|
||||
pair.split("=", 1)
|
||||
for pair in (settings.otel_exporter_headers or ())
|
||||
if "=" in pair
|
||||
)
|
||||
return OTelMixin(
|
||||
service_name=settings.otel_service_name,
|
||||
endpoint=settings.otel_exporter_endpoint,
|
||||
headers=headers or None,
|
||||
excluded_paths=settings.otel_excluded_paths,
|
||||
)
|
||||
|
||||
|
||||
registry = GameRegistry()
|
||||
registry.register(
|
||||
ScoponeEngine(
|
||||
turn_timeout_seconds=settings.turn_timeout_seconds,
|
||||
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
|
||||
)
|
||||
)
|
||||
log.info("registered games: %s", ", ".join(e.id for e in registry.all()))
|
||||
log.debug(
|
||||
"scopone timeouts: hand_ack=%ds turn=%ds",
|
||||
settings.hand_ack_timeout_seconds,
|
||||
settings.turn_timeout_seconds,
|
||||
)
|
||||
|
||||
session_store: SessionStore
|
||||
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)
|
||||
@@ -65,29 +149,58 @@ 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"}),
|
||||
)
|
||||
|
||||
app = KayaApp(mixins=[session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin])
|
||||
log.debug(
|
||||
"timeouts: hand_ack=%ds turn=%ds",
|
||||
settings.hand_ack_timeout_seconds,
|
||||
settings.turn_timeout_seconds,
|
||||
scheduler = DeadlineScheduler(
|
||||
game_store,
|
||||
registry,
|
||||
heartbeat_ms=settings.deadline_heartbeat_ms,
|
||||
)
|
||||
platform = Platform(
|
||||
registry=registry,
|
||||
game_store=game_store,
|
||||
scheduler=scheduler,
|
||||
oidc=oidc_mixin,
|
||||
)
|
||||
|
||||
# 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
|
||||
mixins: list[KayaMixin] = [session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin,
|
||||
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
|
||||
# in reverse), so the span covers session loading, OIDC handling and the
|
||||
# handler itself. CORS, when enabled, is still prepended before it so
|
||||
# preflight short-circuits stay untraced.
|
||||
mixins.insert(0, otel_mixin)
|
||||
log.info(
|
||||
"OpenTelemetry enabled: service=%s endpoint=%s",
|
||||
settings.otel_service_name,
|
||||
settings.otel_exporter_endpoint or "(OTLP default)",
|
||||
)
|
||||
|
||||
cors_mixin = cors_mixin_from_settings(settings)
|
||||
if cors_mixin is not None:
|
||||
# First in the list: preflight requests are answered before the session
|
||||
# and OIDC hooks run.
|
||||
mixins.insert(0, cors_mixin)
|
||||
log.info(
|
||||
"CORS enabled: origins=%s origin_regex=%s credentials=%s",
|
||||
settings.cors_allow_origins,
|
||||
settings.cors_allow_origin_regex,
|
||||
settings.cors_allow_credentials,
|
||||
)
|
||||
|
||||
app = KayaApp(mixins=mixins)
|
||||
|
||||
# Register the SPA shell. The catch-all only matches paths no other route
|
||||
# claimed (asset files under /static are served by Granian itself and never
|
||||
# reach the app).
|
||||
from . import static # noqa: E402,F401
|
||||
|
||||
@@ -7,7 +7,7 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from typing import Optional, Tuple
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
@@ -20,6 +20,25 @@ def _env(name: str, default: Optional[str] = None) -> str:
|
||||
return value
|
||||
|
||||
|
||||
def _env_list(name: str) -> Optional[Tuple[str, ...]]:
|
||||
"""Parse a comma-separated environment variable into a tuple of values.
|
||||
|
||||
Items are stripped and empty items dropped. Unset or empty variables
|
||||
yield ``None``.
|
||||
"""
|
||||
value = os.environ.get(name)
|
||||
if value is None or value.strip() == "":
|
||||
return None
|
||||
return tuple(part.strip() for part in value.split(",") if part.strip())
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool = False) -> bool:
|
||||
value = os.environ.get(name)
|
||||
if value is None or value == "":
|
||||
return default
|
||||
return value.strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _database_url_from_parts(engine: str,
|
||||
user: str,
|
||||
password: Optional[str],
|
||||
@@ -71,14 +90,41 @@ 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
|
||||
# heartbeat only bounds the discovery delay for deadlines enqueued by
|
||||
# other workers.
|
||||
deadline_heartbeat_ms: int
|
||||
# Path to a YAML logging configuration file (logging.config.dictConfig
|
||||
# schema). Unset uses the built-in default: DEBUG to the console.
|
||||
logging_config: Optional[str]
|
||||
# CORS (kaya-cors' CorsMixin). Disabled unless CORS_ALLOW_ORIGINS or
|
||||
# CORS_ALLOW_ORIGIN_REGEX is set; the app serves the SPA and the API
|
||||
# from the same origin, so no CORS headers are needed by default.
|
||||
cors_allow_origins: Optional[Tuple[str, ...]]
|
||||
cors_allow_origin_regex: Optional[str]
|
||||
cors_allow_methods: Optional[Tuple[str, ...]]
|
||||
cors_allow_headers: Optional[Tuple[str, ...]]
|
||||
cors_allow_credentials: bool
|
||||
cors_expose_headers: Optional[Tuple[str, ...]]
|
||||
cors_max_age: int
|
||||
# OpenTelemetry (kaya-otel's OTelMixin). Disabled unless OTEL_ENABLED is
|
||||
# truthy; the exporter endpoint falls back to the OTLP/HTTP default
|
||||
# (localhost:4318) when OTEL_EXPORTER_OTLP_ENDPOINT is unset.
|
||||
otel_enabled: bool
|
||||
otel_service_name: str
|
||||
otel_exporter_endpoint: Optional[str]
|
||||
otel_exporter_headers: Optional[Tuple[str, ...]]
|
||||
# Paths excluded from tracing and metrics (exact matches). Defaults to
|
||||
# the health endpoint, which k8s probes would otherwise spam.
|
||||
otel_excluded_paths: Tuple[str, ...]
|
||||
|
||||
@staticmethod
|
||||
def from_env() -> "Settings":
|
||||
@@ -113,7 +159,26 @@ class Settings:
|
||||
static_dir=_env("STATIC_DIR", "web/dist"),
|
||||
hand_ack_timeout_seconds=int(_env("HAND_ACK_TIMEOUT_SECONDS", "30")),
|
||||
turn_timeout_seconds=int(_env("TURN_TIMEOUT_SECONDS", "30")),
|
||||
deadline_heartbeat_ms=int(_env("DEADLINE_HEARTBEAT_MS", "1000")),
|
||||
logging_config=os.environ.get("LOGGING_CONFIG"),
|
||||
# CORS is disabled unless CORS_ALLOW_ORIGINS (a comma-separated
|
||||
# list of origins, or "*" for any) or CORS_ALLOW_ORIGIN_REGEX
|
||||
# is set.
|
||||
cors_allow_origins=_env_list("CORS_ALLOW_ORIGINS"),
|
||||
cors_allow_origin_regex=os.environ.get("CORS_ALLOW_ORIGIN_REGEX") or None,
|
||||
cors_allow_methods=_env_list("CORS_ALLOW_METHODS"),
|
||||
cors_allow_headers=_env_list("CORS_ALLOW_HEADERS"),
|
||||
cors_allow_credentials=_env_bool("CORS_ALLOW_CREDENTIALS"),
|
||||
cors_expose_headers=_env_list("CORS_EXPOSE_HEADERS"),
|
||||
cors_max_age=int(_env("CORS_MAX_AGE", "600")),
|
||||
# OpenTelemetry is opt-in: set OTEL_ENABLED=1 to export traces
|
||||
# and metrics via OTLP/HTTP (requires the ``otel`` extra).
|
||||
otel_enabled=_env_bool("OTEL_ENABLED"),
|
||||
otel_service_name=_env("OTEL_SERVICE_NAME", "tavolo"),
|
||||
otel_exporter_endpoint=os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") or None,
|
||||
# Comma-separated key=value pairs, e.g. "Authorization=Bearer x".
|
||||
otel_exporter_headers=_env_list("OTEL_EXPORTER_OTLP_HEADERS"),
|
||||
otel_excluded_paths=_env_list("OTEL_EXCLUDED_PATHS") or ("/api/health",),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Scopone scientifico domain package."""
|
||||
@@ -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."""
|
||||
@@ -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)
|
||||
@@ -1,56 +0,0 @@
|
||||
"""Tortoise ORM models: match statistics persisted in Postgres.
|
||||
|
||||
Live game state lives in Redis (see :mod:`tavolo.store`); only completed
|
||||
matches are written here. The two tables answer the question "every match
|
||||
a player took part in, with the final score":
|
||||
|
||||
* :class:`Match` — one row per finished match with both teams' scores.
|
||||
* :class:`MatchPlayer` — one row per participant, linking an OIDC
|
||||
``sub`` to a seat/team and whether they won.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from tortoise import fields
|
||||
from tortoise.models import Model
|
||||
|
||||
|
||||
class Match(Model):
|
||||
"""A completed match of one of the registered game types."""
|
||||
|
||||
id = fields.UUIDField(pk=True)
|
||||
# Which card game was played (tavolo.games.GAME_TYPES); the default
|
||||
# backfills matches recorded before game types existed.
|
||||
game_type = fields.CharField(max_length=32, db_index=True, default="scopone_scientifico")
|
||||
team_a_score = fields.SmallIntField()
|
||||
team_b_score = fields.SmallIntField()
|
||||
# "A" or "B".
|
||||
winner_team = fields.CharField(max_length=1)
|
||||
target_score = fields.SmallIntField()
|
||||
hands_played = fields.SmallIntField()
|
||||
started_at = fields.DatetimeField()
|
||||
finished_at = fields.DatetimeField()
|
||||
|
||||
players: fields.ReverseRelation["MatchPlayer"]
|
||||
|
||||
class Meta:
|
||||
table = "match"
|
||||
ordering = ["-finished_at"]
|
||||
|
||||
|
||||
class MatchPlayer(Model):
|
||||
"""Participation of one user in one match."""
|
||||
|
||||
id = fields.UUIDField(pk=True)
|
||||
match: fields.ForeignKeyRelation[Match] = fields.ForeignKeyField(
|
||||
"models.Match", related_name="players", on_delete=fields.CASCADE
|
||||
)
|
||||
# OIDC subject of the player; no local users table.
|
||||
user_sub = fields.CharField(max_length=255, db_index=True)
|
||||
display_name = fields.CharField(max_length=200)
|
||||
seat = fields.SmallIntField()
|
||||
team = fields.CharField(max_length=1)
|
||||
won = fields.BooleanField()
|
||||
|
||||
class Meta:
|
||||
table = "match_player"
|
||||
unique_together = (("match", "user_sub"),)
|
||||
@@ -1 +0,0 @@
|
||||
"""HTTP route modules."""
|
||||
@@ -1,244 +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
|
||||
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,
|
||||
"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},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
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
|
||||
|
||||
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,
|
||||
)
|
||||
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)
|
||||
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))
|
||||
@@ -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",)},
|
||||
)
|
||||
@@ -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)})
|
||||
@@ -1,151 +0,0 @@
|
||||
"""Player statistics endpoints, served from Postgres.
|
||||
|
||||
Every finished match is persisted by :func:`tavolo.stats.save_match_result`.
|
||||
These endpoints expose a player's own match history and a global
|
||||
leaderboard aggregated from the same two tables.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from kaya.core import HttpContext
|
||||
from kaya.openapi import operation
|
||||
|
||||
from ..app import app, oidc_mixin
|
||||
from ..auth import require_auth
|
||||
from ..games import get_game_type
|
||||
from ..http import extract_query_params, send_error, send_json
|
||||
from ..models import Match, MatchPlayer
|
||||
from ..openapi import PAGINATION_PARAMETERS
|
||||
from ..pagination import CursorDecodeError, paginate, parse_cursor_params
|
||||
|
||||
GAME_TYPE_PARAMETER: Dict[str, Any] = {
|
||||
"name": "game_type",
|
||||
"in": "query",
|
||||
"required": False,
|
||||
"schema": {"type": "string"},
|
||||
"description": "Only count matches of this game (id from GET /api/game-types).",
|
||||
}
|
||||
|
||||
|
||||
def _parse_game_type(query_string: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Parse the ``game_type`` query parameter.
|
||||
|
||||
Returns ``(value, error)``: ``(None, None)`` when absent, ``(id, None)``
|
||||
when valid, ``(None, message)`` when it names no registered game."""
|
||||
values = extract_query_params(query_string).get("game_type")
|
||||
if not values:
|
||||
return None, None
|
||||
game_type = values[0]
|
||||
if get_game_type(game_type) is None:
|
||||
return None, f"unknown game_type: {game_type!r}"
|
||||
return game_type, None
|
||||
|
||||
|
||||
async def _serialize_match(match: Match, viewer: str) -> Dict[str, Any]:
|
||||
participants = await MatchPlayer.filter(match_id=match.id).order_by("seat")
|
||||
return {
|
||||
"id": str(match.id),
|
||||
"game_type": match.game_type,
|
||||
"team_a_score": match.team_a_score,
|
||||
"team_b_score": match.team_b_score,
|
||||
"winner_team": match.winner_team,
|
||||
"target_score": match.target_score,
|
||||
"hands_played": match.hands_played,
|
||||
"started_at": match.started_at.isoformat(),
|
||||
"finished_at": match.finished_at.isoformat(),
|
||||
"you_won": any(p.user_sub == viewer and p.won for p in participants),
|
||||
"players": [
|
||||
{
|
||||
"user_sub": p.user_sub,
|
||||
"display_name": p.display_name,
|
||||
"seat": p.seat,
|
||||
"team": p.team,
|
||||
"won": p.won,
|
||||
}
|
||||
for p in participants
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@app.GET("/api/me/matches")
|
||||
@operation(summary="List my matches",
|
||||
description="Cursor-paginated history of finished matches the caller "
|
||||
"played, newest first, with the final score.",
|
||||
tags=["stats"],
|
||||
parameters=[*PAGINATION_PARAMETERS, GAME_TYPE_PARAMETER],
|
||||
responses={
|
||||
200: {"description": "A page of matches"},
|
||||
400: {"description": "Invalid pagination cursor or game_type"},
|
||||
401: {"description": "Authentication required"},
|
||||
})
|
||||
@require_auth
|
||||
async def my_matches(ctx: HttpContext) -> None:
|
||||
try:
|
||||
cursor = parse_cursor_params(ctx.query_string)
|
||||
except CursorDecodeError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
game_type, error = _parse_game_type(ctx.query_string)
|
||||
if error is not None:
|
||||
await send_error(ctx, 400, error)
|
||||
return
|
||||
user = oidc_mixin.get_user(ctx)
|
||||
assert user is not None
|
||||
queryset = Match.filter(players__user_sub=user.sub).distinct()
|
||||
if game_type is not None:
|
||||
queryset = queryset.filter(game_type=game_type)
|
||||
matches, next_cursor = await paginate(
|
||||
queryset, [("finished_at", "DESC"), ("id", "ASC")], cursor
|
||||
)
|
||||
results = [await _serialize_match(m, user.sub) for m in matches]
|
||||
await send_json(ctx, 200, {"results": results, "next_cursor": next_cursor})
|
||||
|
||||
|
||||
@app.GET("/api/leaderboard")
|
||||
@operation(summary="Global leaderboard",
|
||||
description="Aggregated wins, matches played and team points for every "
|
||||
"player with at least one finished match. Sorted by wins.",
|
||||
tags=["stats"],
|
||||
parameters=[GAME_TYPE_PARAMETER],
|
||||
responses={
|
||||
200: {"description": "The leaderboard"},
|
||||
400: {"description": "Unknown game_type"},
|
||||
})
|
||||
async def leaderboard(ctx: HttpContext) -> None:
|
||||
game_type, error = _parse_game_type(ctx.query_string)
|
||||
if error is not None:
|
||||
await send_error(ctx, 400, error)
|
||||
return
|
||||
queryset = MatchPlayer.all()
|
||||
if game_type is not None:
|
||||
queryset = queryset.filter(match__game_type=game_type)
|
||||
rows = await queryset.prefetch_related("match")
|
||||
aggregate: Dict[str, Dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
entry = aggregate.setdefault(
|
||||
row.user_sub,
|
||||
{
|
||||
"user_sub": row.user_sub,
|
||||
"display_name": row.display_name,
|
||||
"matches": 0,
|
||||
"wins": 0,
|
||||
"points": 0,
|
||||
},
|
||||
)
|
||||
entry["matches"] += 1
|
||||
entry["wins"] += 1 if row.won else 0
|
||||
match = row.match
|
||||
if match is not None:
|
||||
entry["points"] += (
|
||||
match.team_a_score if row.team == "A" else match.team_b_score
|
||||
)
|
||||
# Keep the most recent display name seen.
|
||||
entry["display_name"] = row.display_name
|
||||
|
||||
ranking: List[Dict[str, Any]] = sorted(
|
||||
aggregate.values(),
|
||||
key=lambda e: (e["wins"], e["points"], -e["matches"]),
|
||||
reverse=True,
|
||||
)
|
||||
await send_json(ctx, 200, {"results": ranking})
|
||||
@@ -17,8 +17,8 @@ from pathlib import Path
|
||||
|
||||
from kaya.core import HttpContext
|
||||
|
||||
from ..app import app
|
||||
from ..config import settings
|
||||
from .app import app
|
||||
from .config import settings
|
||||
|
||||
|
||||
async def _send_shell(ctx: HttpContext) -> None:
|
||||
@@ -1,70 +0,0 @@
|
||||
"""Copy finished match results from Redis into Postgres.
|
||||
|
||||
Called once when a game reaches the finished phase (guarded by the
|
||||
``stats_saved`` flag on the state). The write is transactional so a match
|
||||
never appears with only some of its players.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from logging import getLogger
|
||||
from typing import Optional
|
||||
|
||||
from tortoise.transactions import in_transaction
|
||||
|
||||
from .game.state import PHASE_FINISHED, TEAM_NAMES, GameState
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_timestamp(value: Optional[str]) -> datetime:
|
||||
if value:
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
pass
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
async def save_match_result(state: GameState) -> None:
|
||||
"""Persist ``state`` to Postgres if it is finished and not yet saved."""
|
||||
if state.stats_saved or state.phase != PHASE_FINISHED or state.winner is None:
|
||||
return
|
||||
|
||||
from .models import Match, MatchPlayer
|
||||
|
||||
started_at = _parse_timestamp(state.created_at)
|
||||
finished_at = _parse_timestamp(state.finished_at)
|
||||
async with in_transaction():
|
||||
match = await Match.create(
|
||||
id=uuid.uuid4(),
|
||||
game_type=state.game_type,
|
||||
team_a_score=state.scores[0],
|
||||
team_b_score=state.scores[1],
|
||||
winner_team=TEAM_NAMES[state.winner],
|
||||
target_score=state.target_score,
|
||||
hands_played=state.hand_number,
|
||||
started_at=started_at,
|
||||
finished_at=finished_at,
|
||||
)
|
||||
for player in state.players:
|
||||
await MatchPlayer.create(
|
||||
id=uuid.uuid4(),
|
||||
match=match,
|
||||
user_sub=player.sub,
|
||||
display_name=player.name,
|
||||
seat=player.seat,
|
||||
team=TEAM_NAMES[player.team],
|
||||
won=player.team == state.winner,
|
||||
)
|
||||
state.stats_saved = True
|
||||
log.info(
|
||||
"match result persisted: game %s (%s), team %s won %d-%d over %d hands",
|
||||
state.id,
|
||||
state.game_type,
|
||||
TEAM_NAMES[state.winner],
|
||||
state.scores[0],
|
||||
state.scores[1],
|
||||
state.hand_number,
|
||||
)
|
||||
@@ -1,194 +0,0 @@
|
||||
"""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:<id>`` 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.
|
||||
|
||||
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
|
||||
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.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
from abc import ABC, abstractmethod
|
||||
from logging import getLogger
|
||||
from typing import AsyncContextManager, AsyncIterator, Dict, Optional, Set
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from .game.state import GameState
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
GAME_KEY_PREFIX = "tavolo:game:"
|
||||
CODE_KEY_PREFIX = "tavolo:code:"
|
||||
CHANNEL_PREFIX = "tavolo:game:"
|
||||
|
||||
# Sentinel pushed into in-memory subscriber queues to signal a change.
|
||||
_BUMP = b"update"
|
||||
|
||||
|
||||
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``."""
|
||||
|
||||
@abstractmethod
|
||||
async def save(self, state: GameState) -> None:
|
||||
"""Persist ``state``, 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``."""
|
||||
|
||||
@abstractmethod
|
||||
def lock(self, game_id: str) -> AsyncContextManager[None]:
|
||||
"""Async context manager serializing mutations of one game."""
|
||||
|
||||
@abstractmethod
|
||||
def subscribe(self, game_id: str) -> AsyncContextManager[AsyncIterator[None]]:
|
||||
"""Async context manager yielding an async iterator of change signals."""
|
||||
|
||||
@abstractmethod
|
||||
async def publish(self, game_id: str) -> None:
|
||||
"""Signal that the state of ``game_id`` changed."""
|
||||
|
||||
|
||||
def _channel(game_id: str) -> str:
|
||||
return f"{CHANNEL_PREFIX}{game_id}:events"
|
||||
|
||||
|
||||
class RedisGameStore(GameStore):
|
||||
def __init__(self, redis: Redis, ttl_seconds: int = 86400) -> None:
|
||||
self._redis = redis
|
||||
self._ttl = ttl_seconds
|
||||
|
||||
def lock(self, game_id: str):
|
||||
# Lock and state use distinct key names; the lock expires on its own
|
||||
# if a worker dies mid-mutation.
|
||||
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]:
|
||||
|
||||
raw = await self._redis.get(f"{GAME_KEY_PREFIX}{game_id}")
|
||||
if raw is None:
|
||||
log.debug("redis load %s: miss", game_id)
|
||||
return None
|
||||
if isinstance(raw, bytes):
|
||||
raw = raw.decode("utf-8")
|
||||
log.debug("redis load %s: hit", game_id)
|
||||
return GameState.from_json(json.loads(raw))
|
||||
|
||||
async def save(self, state: GameState) -> None:
|
||||
|
||||
payload = json.dumps(state.to_json())
|
||||
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)
|
||||
await pipe.execute()
|
||||
log.debug("redis save %s (phase %s, ttl %ds)", state.id, state.phase, self._ttl)
|
||||
|
||||
async def find_by_code(self, code: str) -> Optional[GameState]:
|
||||
game_id = await self._redis.get(f"{CODE_KEY_PREFIX}{code.upper()}")
|
||||
if game_id is None:
|
||||
return None
|
||||
if isinstance(game_id, bytes):
|
||||
game_id = game_id.decode("utf-8")
|
||||
return await self.load(str(game_id))
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]:
|
||||
pubsub = self._redis.pubsub()
|
||||
await pubsub.subscribe(_channel(game_id))
|
||||
try:
|
||||
yield _redis_events(pubsub)
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
await pubsub.unsubscribe(_channel(game_id))
|
||||
await pubsub.aclose()
|
||||
|
||||
async def publish(self, game_id: str) -> None:
|
||||
await self._redis.publish(_channel(game_id), "update")
|
||||
log.debug("redis publish %s", game_id)
|
||||
|
||||
|
||||
async def _redis_events(pubsub) -> AsyncIterator[None]:
|
||||
async for message in pubsub.listen():
|
||||
if message.get("type") == "message":
|
||||
yield 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] = {}
|
||||
self._codes: Dict[str, str] = {}
|
||||
self._locks: Dict[str, asyncio.Lock] = {}
|
||||
self._subscribers: Dict[str, Set[asyncio.Queue]] = {}
|
||||
|
||||
def _lock_for(self, game_id: str) -> asyncio.Lock:
|
||||
lock = self._locks.get(game_id)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
self._locks[game_id] = lock
|
||||
return lock
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def lock(self, game_id: str) -> AsyncIterator[None]:
|
||||
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 save(self, state: GameState) -> None:
|
||||
self._games[state.id] = GameState.from_json(state.to_json())
|
||||
self._codes[state.join_code] = state.id
|
||||
|
||||
async def find_by_code(self, code: str) -> Optional[GameState]:
|
||||
game_id = self._codes.get(code.upper())
|
||||
if game_id is None:
|
||||
return None
|
||||
return await self.load(game_id)
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]:
|
||||
queue: asyncio.Queue = asyncio.Queue()
|
||||
self._subscribers.setdefault(game_id, set()).add(queue)
|
||||
try:
|
||||
yield _queue_events(queue)
|
||||
finally:
|
||||
subscribers = self._subscribers.get(game_id)
|
||||
if subscribers is not None:
|
||||
subscribers.discard(queue)
|
||||
if not subscribers:
|
||||
self._subscribers.pop(game_id, None)
|
||||
|
||||
async def publish(self, game_id: str) -> None:
|
||||
for queue in list(self._subscribers.get(game_id, ())):
|
||||
queue.put_nowait(_BUMP)
|
||||
|
||||
|
||||
async def _queue_events(queue: asyncio.Queue) -> AsyncIterator[None]:
|
||||
while True:
|
||||
await queue.get()
|
||||
yield None
|
||||
@@ -1,338 +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).
|
||||
|
||||
If a player does not move before the per-game ``turn_timeout``, the server
|
||||
plays a random card (with a random legal capture when one is required) for
|
||||
them, so a disconnected or idle player cannot stall the match. The timer is
|
||||
re-armed by every client connection and state broadcast, and fires
|
||||
immediately when a reconnect finds the deadline already past.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from contextlib import suppress
|
||||
from datetime import datetime, timezone
|
||||
from logging import getLogger
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
|
||||
from kaya.core import WebSocket
|
||||
|
||||
from . import auth
|
||||
from .app import app, game_store
|
||||
from .game import engine
|
||||
from .game.errors import GameError
|
||||
from .game.state import PHASE_FINISHED, PHASE_HAND_END, PHASE_PLAYING, GameState
|
||||
from .stats import save_match_result
|
||||
|
||||
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))
|
||||
schedule_turn_timer(game_id, 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
|
||||
schedule_turn_timer(game_id, state)
|
||||
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 ------------------------------------------------
|
||||
|
||||
# Running auto-continue timers, keyed by (game_id, hand_number), so a hand's
|
||||
# timeout is scheduled only once even when several clients are connected.
|
||||
_hand_end_timers: Dict[tuple, asyncio.Task] = {}
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def schedule_hand_end_timer(game_id: str, hand_number: int, timeout: int) -> None:
|
||||
"""Deal the next hand after the acknowledgement timeout, even if not
|
||||
everyone has clicked. Fizzles if the hand already advanced."""
|
||||
key = (game_id, hand_number)
|
||||
if key in _hand_end_timers:
|
||||
return
|
||||
|
||||
async def _auto_advance() -> None:
|
||||
try:
|
||||
await asyncio.sleep(timeout)
|
||||
async with game_store.lock(game_id):
|
||||
state = await game_store.load(game_id)
|
||||
if (
|
||||
state is None
|
||||
or state.phase != engine.PHASE_HAND_END
|
||||
or state.hand_number != hand_number
|
||||
):
|
||||
return
|
||||
for player in state.players:
|
||||
engine.acknowledge_hand(state, player.sub)
|
||||
await game_store.save(state)
|
||||
await game_store.publish(game_id)
|
||||
log.info(
|
||||
"game %s: hand %d auto-advanced after the acknowledgement timeout",
|
||||
game_id,
|
||||
hand_number,
|
||||
)
|
||||
finally:
|
||||
_hand_end_timers.pop(key, None)
|
||||
|
||||
_hand_end_timers[key] = asyncio.create_task(_auto_advance())
|
||||
|
||||
|
||||
# --- auto-play on turn timeout ------------------------------------------------
|
||||
|
||||
# Running turn timers, keyed by (game_id, hand_number, turn, deadline), so a
|
||||
# turn's timeout is scheduled only once even when several clients are
|
||||
# connected. Including the deadline means a re-arm after a reconnect cannot
|
||||
# duplicate a timer for a turn that was already auto-played.
|
||||
_turn_timers: Dict[tuple, asyncio.Task] = {}
|
||||
|
||||
|
||||
def schedule_turn_timer(game_id: str, state: GameState) -> None:
|
||||
"""Auto-play a random legal card if the player on turn misses the
|
||||
deadline. Fizzles if the turn already advanced."""
|
||||
if state.phase != PHASE_PLAYING or not state.turn_deadline:
|
||||
return
|
||||
key = (game_id, state.hand_number, state.turn, state.turn_deadline)
|
||||
if key in _turn_timers:
|
||||
return
|
||||
|
||||
hand_number = state.hand_number
|
||||
turn = state.turn
|
||||
deadline_raw = state.turn_deadline
|
||||
try:
|
||||
deadline = datetime.fromisoformat(deadline_raw)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
async def _auto_play() -> None:
|
||||
try:
|
||||
delay = (deadline - datetime.now(timezone.utc)).total_seconds()
|
||||
await asyncio.sleep(max(delay, 0))
|
||||
async with game_store.lock(game_id):
|
||||
state = await game_store.load(game_id)
|
||||
if (
|
||||
state is None
|
||||
or state.phase != PHASE_PLAYING
|
||||
or state.hand_number != hand_number
|
||||
or state.turn != turn
|
||||
or state.turn_deadline != deadline_raw
|
||||
):
|
||||
# The turn moved on (or the game ended) without this
|
||||
# timer firing: make sure the current turn is armed.
|
||||
if state is not None:
|
||||
schedule_turn_timer(game_id, state)
|
||||
return
|
||||
try:
|
||||
engine.auto_play(state)
|
||||
except GameError:
|
||||
return
|
||||
log.info(
|
||||
"game %s: auto-played for %s (turn timeout, hand %d)",
|
||||
game_id,
|
||||
state.players[turn].sub if turn < len(state.players) else "?",
|
||||
hand_number,
|
||||
)
|
||||
await _after_play(state, game_id)
|
||||
finally:
|
||||
_turn_timers.pop(key, None)
|
||||
|
||||
_turn_timers[key] = asyncio.create_task(_auto_play())
|
||||
|
||||
|
||||
async def _after_play(state: GameState, game_id: str) -> None:
|
||||
"""Persist a successful move and notify every connected player.
|
||||
|
||||
Callers must hold the per-game lock. Handles the two terminal
|
||||
transitions: the match result is written to Postgres once, and a
|
||||
hand-end summary schedules the auto-continue timeout.
|
||||
"""
|
||||
if state.phase == PHASE_FINISHED:
|
||||
await save_match_result(state)
|
||||
log.info(
|
||||
"game %s finished: team %s wins %d-%d",
|
||||
game_id,
|
||||
"A" if state.winner == 0 else "B",
|
||||
state.scores[0],
|
||||
state.scores[1],
|
||||
)
|
||||
elif state.phase == PHASE_HAND_END:
|
||||
schedule_hand_end_timer(game_id, state.hand_number, state.hand_ack_timeout)
|
||||
await game_store.save(state)
|
||||
await game_store.publish(game_id)
|
||||
|
||||
|
||||
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 _after_play(state, game_id)
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Test helpers package."""
|
||||
"""Test helpers package for the tavolo-app integration suite."""
|
||||
from .asynctest import async_test
|
||||
from .oidc import make_user, oidc_user, ws_users
|
||||
|
||||
__all__ = ["make_user", "oidc_user", "ws_users"]
|
||||
__all__ = ["async_test", "make_user", "oidc_user", "ws_users"]
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""An ``async_test`` that also closes the app's Tortoise context.
|
||||
|
||||
``pwo.async_test`` runs every test in a fresh event loop (``asyncio.Runner``).
|
||||
The app's :class:`~tavolo.platform.tortoise_mixin.TortoiseMixin` builds one
|
||||
``TortoiseContext`` per loop, so without an explicit close each test orphans
|
||||
an aiosqlite connection whose non-daemon worker thread keeps the interpreter
|
||||
alive after the suite reports "OK".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Coroutine
|
||||
|
||||
from tavolo.app import scheduler, tortoise_mixin
|
||||
|
||||
|
||||
def async_test(coro: Callable[..., Coroutine[Any, Any, None]]) -> Callable[..., None]:
|
||||
"""Like ``pwo.async_test``, but close the Tortoise context and stop the
|
||||
deadline consumer afterwards."""
|
||||
|
||||
@wraps(coro)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> None:
|
||||
async def run() -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
await coro(*args, **kwargs)
|
||||
finally:
|
||||
scheduler.stop_consumer(loop)
|
||||
await tortoise_mixin.aclose()
|
||||
|
||||
with asyncio.Runner() as runner:
|
||||
runner.run(run())
|
||||
|
||||
return wrapper
|
||||
@@ -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:
|
||||
|
||||
@@ -81,5 +81,89 @@ class DatabaseUrlTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class CorsSettingsTests(unittest.TestCase):
|
||||
def test_cors_disabled_by_default(self):
|
||||
settings = _settings({})
|
||||
self.assertIsNone(settings.cors_allow_origins)
|
||||
self.assertIsNone(settings.cors_allow_origin_regex)
|
||||
self.assertIsNone(settings.cors_allow_methods)
|
||||
self.assertIsNone(settings.cors_allow_headers)
|
||||
self.assertFalse(settings.cors_allow_credentials)
|
||||
self.assertIsNone(settings.cors_expose_headers)
|
||||
self.assertEqual(600, settings.cors_max_age)
|
||||
|
||||
def test_allow_origins_parses_comma_separated_list(self):
|
||||
settings = _settings({
|
||||
"CORS_ALLOW_ORIGINS": "https://a.example, https://b.example ,,https://c.example",
|
||||
})
|
||||
self.assertEqual(
|
||||
("https://a.example", "https://b.example", "https://c.example"),
|
||||
settings.cors_allow_origins,
|
||||
)
|
||||
|
||||
def test_allow_origins_star_is_passed_through(self):
|
||||
settings = _settings({"CORS_ALLOW_ORIGINS": "*"})
|
||||
self.assertEqual(("*",), settings.cors_allow_origins)
|
||||
|
||||
def test_allow_origin_regex_is_passed_through(self):
|
||||
settings = _settings({"CORS_ALLOW_ORIGIN_REGEX": r"https://.*\.example\.com"})
|
||||
self.assertEqual(r"https://.*\.example\.com", settings.cors_allow_origin_regex)
|
||||
|
||||
def test_allow_methods_and_headers_parse_as_lists(self):
|
||||
settings = _settings({
|
||||
"CORS_ALLOW_METHODS": "GET,POST",
|
||||
"CORS_ALLOW_HEADERS": "Authorization, X-Custom-Header",
|
||||
"CORS_EXPOSE_HEADERS": "X-Total-Count",
|
||||
})
|
||||
self.assertEqual(("GET", "POST"), settings.cors_allow_methods)
|
||||
self.assertEqual(("Authorization", "X-Custom-Header"), settings.cors_allow_headers)
|
||||
self.assertEqual(("X-Total-Count",), settings.cors_expose_headers)
|
||||
|
||||
def test_allow_credentials_parses_boolean(self):
|
||||
for value in ("1", "true", "TRUE", "yes", "on"):
|
||||
self.assertTrue(_settings({"CORS_ALLOW_CREDENTIALS": value}).cors_allow_credentials)
|
||||
for value in ("0", "false", "no", "off", "anything-else"):
|
||||
self.assertFalse(_settings({"CORS_ALLOW_CREDENTIALS": value}).cors_allow_credentials)
|
||||
|
||||
def test_max_age_parses_int(self):
|
||||
settings = _settings({"CORS_MAX_AGE": "3600"})
|
||||
self.assertEqual(3600, settings.cors_max_age)
|
||||
|
||||
|
||||
class OTelSettingsTests(unittest.TestCase):
|
||||
def test_otel_disabled_by_default(self):
|
||||
settings = _settings({})
|
||||
self.assertFalse(settings.otel_enabled)
|
||||
self.assertEqual("tavolo", settings.otel_service_name)
|
||||
self.assertIsNone(settings.otel_exporter_endpoint)
|
||||
self.assertIsNone(settings.otel_exporter_headers)
|
||||
|
||||
def test_otel_enabled_parses_boolean(self):
|
||||
for value in ("1", "true", "TRUE", "yes", "on"):
|
||||
self.assertTrue(_settings({"OTEL_ENABLED": value}).otel_enabled)
|
||||
for value in ("0", "false", "no", "off", "anything-else"):
|
||||
self.assertFalse(_settings({"OTEL_ENABLED": value}).otel_enabled)
|
||||
|
||||
def test_otel_settings_are_passed_through(self):
|
||||
settings = _settings({
|
||||
"OTEL_SERVICE_NAME": "cards",
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector:4318",
|
||||
"OTEL_EXPORTER_OTLP_HEADERS": "Authorization=Bearer t, X-Tenant=one",
|
||||
})
|
||||
self.assertEqual("cards", settings.otel_service_name)
|
||||
self.assertEqual("http://collector:4318", settings.otel_exporter_endpoint)
|
||||
self.assertEqual(
|
||||
("Authorization=Bearer t", "X-Tenant=one"),
|
||||
settings.otel_exporter_headers,
|
||||
)
|
||||
|
||||
def test_otel_excluded_paths_defaults_to_health_endpoint(self):
|
||||
self.assertEqual(("/api/health",), _settings({}).otel_excluded_paths)
|
||||
|
||||
def test_otel_excluded_paths_parses_comma_separated_list(self):
|
||||
settings = _settings({"OTEL_EXCLUDED_PATHS": "/api/health, /metrics"})
|
||||
self.assertEqual(("/api/health", "/metrics"), settings.otel_excluded_paths)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Integration tests for the CORS configuration in :mod:`tavolo.app`.
|
||||
|
||||
The mixin under test is kaya-cors' :class:`~kaya.cors.CorsMixin`; these
|
||||
tests only verify that :func:`tavolo.app.cors_mixin_from_settings` maps the
|
||||
environment-driven :class:`~tavolo.config.Settings` onto it correctly. A
|
||||
minimal ``KayaApp`` is used instead of the global ``app`` so the tests do
|
||||
not depend on the environment the suite was imported with.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from kaya.core import HttpContext, KayaApp
|
||||
|
||||
from tavolo.app import cors_mixin_from_settings
|
||||
from tavolo.config import Settings
|
||||
from tests.helpers import async_test
|
||||
|
||||
ORIGIN = "https://cards.example"
|
||||
|
||||
|
||||
def _settings(env: dict) -> Settings:
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
return Settings.from_env()
|
||||
|
||||
|
||||
def _app(settings: Settings) -> KayaApp:
|
||||
mixin = cors_mixin_from_settings(settings)
|
||||
assert mixin is not None
|
||||
app = KayaApp(mixins=[mixin])
|
||||
|
||||
@app.GET("/api/health")
|
||||
async def health(ctx: HttpContext) -> None:
|
||||
await ctx.send_str(200, "ok")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
class CorsMixinFromSettingsTests(unittest.TestCase):
|
||||
def test_disabled_when_unconfigured(self):
|
||||
self.assertIsNone(cors_mixin_from_settings(_settings({})))
|
||||
|
||||
def test_enabled_by_allow_origins(self):
|
||||
self.assertIsNotNone(cors_mixin_from_settings(
|
||||
_settings({"CORS_ALLOW_ORIGINS": ORIGIN})))
|
||||
|
||||
def test_enabled_by_allow_origin_regex_alone(self):
|
||||
self.assertIsNotNone(cors_mixin_from_settings(
|
||||
_settings({"CORS_ALLOW_ORIGIN_REGEX": r"https://.*\.example\.com"})))
|
||||
|
||||
|
||||
class CorsBehaviorTests(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_request_without_origin_is_untouched(self) -> None:
|
||||
app = _app(_settings({"CORS_ALLOW_ORIGINS": ORIGIN}))
|
||||
async with AsyncClient(transport=ASGITransport(app=app),
|
||||
base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/api/health")
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertNotIn("access-control-allow-origin", response.headers)
|
||||
|
||||
@async_test
|
||||
async def test_simple_request_with_allowed_origin(self) -> None:
|
||||
app = _app(_settings({"CORS_ALLOW_ORIGINS": ORIGIN}))
|
||||
async with AsyncClient(transport=ASGITransport(app=app),
|
||||
base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/api/health", headers={"Origin": ORIGIN})
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual(ORIGIN, response.headers["access-control-allow-origin"])
|
||||
|
||||
@async_test
|
||||
async def test_simple_request_with_disallowed_origin(self) -> None:
|
||||
app = _app(_settings({"CORS_ALLOW_ORIGINS": ORIGIN}))
|
||||
async with AsyncClient(transport=ASGITransport(app=app),
|
||||
base_url="http://127.0.0.1") as client:
|
||||
response = await client.get(
|
||||
"/api/health", headers={"Origin": "https://mallory.example"})
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertNotIn("access-control-allow-origin", response.headers)
|
||||
|
||||
@async_test
|
||||
async def test_preflight_allowed(self) -> None:
|
||||
app = _app(_settings({
|
||||
"CORS_ALLOW_ORIGINS": ORIGIN,
|
||||
"CORS_ALLOW_METHODS": "GET,POST",
|
||||
"CORS_MAX_AGE": "3600",
|
||||
}))
|
||||
async with AsyncClient(transport=ASGITransport(app=app),
|
||||
base_url="http://127.0.0.1") as client:
|
||||
response = await client.options("/api/health", headers={
|
||||
"Origin": ORIGIN,
|
||||
"Access-Control-Request-Method": "POST",
|
||||
})
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual(ORIGIN, response.headers["access-control-allow-origin"])
|
||||
self.assertEqual("GET, POST", response.headers["access-control-allow-methods"])
|
||||
self.assertEqual("3600", response.headers["access-control-max-age"])
|
||||
|
||||
@async_test
|
||||
async def test_preflight_disallowed_origin(self) -> None:
|
||||
app = _app(_settings({"CORS_ALLOW_ORIGINS": ORIGIN}))
|
||||
async with AsyncClient(transport=ASGITransport(app=app),
|
||||
base_url="http://127.0.0.1") as client:
|
||||
response = await client.options("/api/health", headers={
|
||||
"Origin": "https://mallory.example",
|
||||
"Access-Control-Request-Method": "GET",
|
||||
})
|
||||
self.assertEqual(400, response.status_code)
|
||||
self.assertIn("Disallowed CORS", response.text)
|
||||
|
||||
@async_test
|
||||
async def test_credentials_echo_origin_and_set_flag(self) -> None:
|
||||
app = _app(_settings({
|
||||
"CORS_ALLOW_ORIGINS": "*",
|
||||
"CORS_ALLOW_CREDENTIALS": "true",
|
||||
}))
|
||||
async with AsyncClient(transport=ASGITransport(app=app),
|
||||
base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/api/health", headers={"Origin": ORIGIN})
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual(ORIGIN, response.headers["access-control-allow-origin"])
|
||||
self.assertEqual("true", response.headers["access-control-allow-credentials"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,206 @@
|
||||
"""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 sessions, queue their
|
||||
deadlines and let the background consumer fire them without a single
|
||||
websocket.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
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 _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,
|
||||
)
|
||||
return session
|
||||
|
||||
|
||||
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:
|
||||
session = await predicate()
|
||||
if session is not None:
|
||||
return session
|
||||
await asyncio.sleep(0.05)
|
||||
return None
|
||||
|
||||
|
||||
class ConnectionIndependenceTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_turn_timeout_fires_with_no_connections(self) -> None:
|
||||
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(session.id, 2),
|
||||
)
|
||||
self.assertIsNotNone(result, "turn deadline never fired")
|
||||
assert result is not 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.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()
|
||||
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.
|
||||
result = await _wait_for(
|
||||
lambda: _phase_is("dl-handend-1", "playing"),
|
||||
)
|
||||
self.assertIsNotNone(result, "hand-end deadline never fired")
|
||||
assert result is not None
|
||||
self.assertEqual(2, result.state.hand_number)
|
||||
self.assertEqual([], result.state.acked)
|
||||
|
||||
|
||||
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[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: 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()
|
||||
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 scheduler.process_due(member)
|
||||
await scheduler.process_due(member)
|
||||
|
||||
result = await game_store.load(session.id)
|
||||
assert result is not None
|
||||
# Advanced exactly once: hand 2, not hand 3.
|
||||
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 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 scheduler.process_due(member)
|
||||
|
||||
result = await game_store.load(session.id)
|
||||
assert result is not None
|
||||
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 = encode({
|
||||
"game_id": "dl-gone",
|
||||
"kind": "turn",
|
||||
"token": "turn:1:0:0",
|
||||
})
|
||||
await game_store.add_deadline(member, due_at=0.0)
|
||||
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 scheduler.process_due("not json")
|
||||
self.assertNotIn("not json", await game_store.due_deadlines(float("inf")))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Unit tests for the OpenTelemetry wiring in :mod:`tavolo.app`.
|
||||
|
||||
The mixin under test is kaya-otel's :class:`~kaya.otel.OTelMixin`, an
|
||||
optional dependency (the ``otel`` extra); these tests only verify that
|
||||
:func:`tavolo.app.otel_mixin_from_settings` maps the ``OTEL_*`` settings
|
||||
onto mixin construction. The ``kaya.otel`` module is stubbed in
|
||||
``sys.modules`` so the suite does not need the extra installed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from tavolo.app import otel_mixin_from_settings
|
||||
from tavolo.config import Settings
|
||||
|
||||
|
||||
def _settings(env: dict) -> Settings:
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
return Settings.from_env()
|
||||
|
||||
|
||||
class _StubOTelMixin:
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
|
||||
|
||||
def _stub_kaya_otel():
|
||||
"""Install a fake ``kaya.otel`` module and return it."""
|
||||
module = types.ModuleType("kaya.otel")
|
||||
module.OTelMixin = _StubOTelMixin # type: ignore[attr-defined]
|
||||
return patch.dict(sys.modules, {"kaya.otel": module})
|
||||
|
||||
|
||||
class OTelMixinFromSettingsTests(unittest.TestCase):
|
||||
def test_disabled_by_default(self):
|
||||
self.assertIsNone(otel_mixin_from_settings(_settings({})))
|
||||
|
||||
def test_enabled_by_otel_enabled(self):
|
||||
with _stub_kaya_otel():
|
||||
mixin = otel_mixin_from_settings(_settings({"OTEL_ENABLED": "1"}))
|
||||
self.assertIsNotNone(mixin)
|
||||
|
||||
def test_settings_are_passed_through(self):
|
||||
with _stub_kaya_otel():
|
||||
mixin = otel_mixin_from_settings(_settings({
|
||||
"OTEL_ENABLED": "true",
|
||||
"OTEL_SERVICE_NAME": "cards",
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector:4318",
|
||||
"OTEL_EXPORTER_OTLP_HEADERS": "Authorization=Bearer t, X-Tenant=one",
|
||||
}))
|
||||
assert isinstance(mixin, _StubOTelMixin)
|
||||
self.assertEqual({
|
||||
"service_name": "cards",
|
||||
"endpoint": "http://collector:4318",
|
||||
"headers": {"Authorization": "Bearer t", "X-Tenant": "one"},
|
||||
"excluded_paths": ("/api/health",),
|
||||
}, mixin.kwargs)
|
||||
|
||||
def test_defaults_when_only_enabled(self):
|
||||
with _stub_kaya_otel():
|
||||
mixin = otel_mixin_from_settings(_settings({"OTEL_ENABLED": "on"}))
|
||||
assert isinstance(mixin, _StubOTelMixin)
|
||||
self.assertEqual("tavolo", mixin.kwargs["service_name"])
|
||||
self.assertIsNone(mixin.kwargs["endpoint"])
|
||||
self.assertIsNone(mixin.kwargs["headers"])
|
||||
self.assertEqual(("/api/health",), mixin.kwargs["excluded_paths"])
|
||||
|
||||
def test_excluded_paths_are_passed_through(self):
|
||||
with _stub_kaya_otel():
|
||||
mixin = otel_mixin_from_settings(_settings({
|
||||
"OTEL_ENABLED": "1",
|
||||
"OTEL_EXCLUDED_PATHS": "/api/health,/metrics",
|
||||
}))
|
||||
assert isinstance(mixin, _StubOTelMixin)
|
||||
self.assertEqual(("/api/health", "/metrics"), mixin.kwargs["excluded_paths"])
|
||||
|
||||
def test_missing_extra_raises_runtime_error(self):
|
||||
with patch.dict(sys.modules, {"kaya.otel": None}):
|
||||
with self.assertRaises(RuntimeError):
|
||||
otel_mixin_from_settings(_settings({"OTEL_ENABLED": "1"}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,13 +1,12 @@
|
||||
"""Game lobby route tests via kaya's ASGI transport."""
|
||||
"""Game lobby route tests via kaya's ASGI transport (scopone game)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from pwo import async_test
|
||||
|
||||
from tavolo.app import app
|
||||
from tests.helpers import oidc_user
|
||||
from tests.helpers import async_test, oidc_user
|
||||
|
||||
|
||||
class GamesRouteTest(unittest.TestCase):
|
||||
@@ -24,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"])
|
||||
@@ -71,6 +72,28 @@ class GamesRouteTest(unittest.TestCase):
|
||||
self.assertNotIn("hand", state["players"][0])
|
||||
self.assertEqual(1, state["turn"])
|
||||
|
||||
@async_test
|
||||
async def test_create_napola_option(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
default = await client.post("/api/games", json={})
|
||||
self.assertEqual(201, default.status_code)
|
||||
self.assertTrue(default.json()["napola"])
|
||||
|
||||
with oidc_user("alice"):
|
||||
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={"options": {"napola": "yes"}}
|
||||
)
|
||||
self.assertEqual(400, invalid.status_code)
|
||||
|
||||
@async_test
|
||||
async def test_join_errors(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
@@ -103,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)
|
||||
@@ -130,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:
|
||||
|
||||
@@ -8,11 +8,10 @@ from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from pwo import async_test
|
||||
|
||||
from tavolo.app import app
|
||||
from tavolo.config import settings
|
||||
from tests.helpers import oidc_user
|
||||
from tests.helpers import async_test, oidc_user
|
||||
|
||||
|
||||
class MeRouteTest(unittest.TestCase):
|
||||
@@ -43,7 +42,7 @@ class StaticRouteTest(unittest.TestCase):
|
||||
(Path(dist) / "index.html").write_text("<html>spa</html>")
|
||||
|
||||
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("/")
|
||||
@@ -59,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("/")
|
||||
|
||||
+106
-178
@@ -1,211 +1,139 @@
|
||||
"""Match-statistics tests: Postgres persistence and the stats endpoints."""
|
||||
"""Scopone result-persistence tests: engine result to Postgres to API.
|
||||
|
||||
The platform suite covers the generic machinery against a toy game;
|
||||
these tests pin the scopone-specific shape: the match ``result`` summary,
|
||||
per-player scores/teams, Elo deltas and what the history and leaderboard
|
||||
endpoints expose for a finished scopone match.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from pwo import async_test
|
||||
|
||||
from tavolo.app import app, tortoise_mixin
|
||||
from tavolo.game import engine
|
||||
from tavolo.game.state import GameState
|
||||
from tavolo.models import Match, MatchPlayer
|
||||
from tavolo.stats import save_match_result
|
||||
from tests.helpers import oidc_user
|
||||
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
|
||||
|
||||
PLAYERS = ("alice", "bob", "carol", "dave")
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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 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"])
|
||||
|
||||
|
||||
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"]))
|
||||
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:
|
||||
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__":
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
"""In-memory game store behaviour (the Redis store shares this interface)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
|
||||
from pwo import async_test
|
||||
|
||||
from tavolo.game import engine
|
||||
from tavolo.store import InMemoryGameStore
|
||||
|
||||
|
||||
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)
|
||||
|
||||
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])
|
||||
|
||||
@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_test
|
||||
async def test_load_missing_returns_none(self) -> None:
|
||||
store = InMemoryGameStore()
|
||||
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
|
||||
self.assertIsNotNone(found)
|
||||
assert found is not None
|
||||
self.assertEqual("g2", 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")
|
||||
assert first is not None
|
||||
first.phase = "tampered"
|
||||
second = await store.load("g3")
|
||||
assert second is not None
|
||||
self.assertEqual("lobby", second.phase)
|
||||
|
||||
@async_test
|
||||
async def test_publish_reaches_subscriber(self) -> None:
|
||||
store = InMemoryGameStore()
|
||||
state = engine.create_game("g4", "CODE04", "alice", "alice")
|
||||
await store.save(state)
|
||||
|
||||
received = []
|
||||
|
||||
async with store.subscribe("g4") as events:
|
||||
await store.publish("g4")
|
||||
async for _ in events:
|
||||
received.append(True)
|
||||
break
|
||||
|
||||
self.assertEqual([True], received)
|
||||
|
||||
@async_test
|
||||
async def test_lock_serializes_concurrent_mutations(self) -> None:
|
||||
store = InMemoryGameStore()
|
||||
order = []
|
||||
|
||||
async def holder() -> None:
|
||||
async with store.lock("g5"):
|
||||
order.append("holder-enter")
|
||||
await asyncio.sleep(0.05)
|
||||
order.append("holder-exit")
|
||||
|
||||
async def contender() -> None:
|
||||
await asyncio.sleep(0.01)
|
||||
async with store.lock("g5"):
|
||||
order.append("contender")
|
||||
|
||||
await asyncio.gather(holder(), contender())
|
||||
self.assertEqual(
|
||||
["holder-enter", "holder-exit", "contender"], order
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -7,16 +7,45 @@ import unittest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from httpx_ws import WebSocketDisconnect, aconnect_ws
|
||||
from httpx_ws.transport import ASGIWebSocketTransport
|
||||
from pwo import async_test
|
||||
|
||||
from tavolo.app import app, game_store
|
||||
from tavolo.game import engine
|
||||
from tavolo.game.state import Card, GameState, PlayerState
|
||||
from tests.helpers import make_user, oidc_user, ws_users
|
||||
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."""
|
||||
@@ -133,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):
|
||||
@@ -241,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"])
|
||||
|
||||
+17
-2
@@ -35,9 +35,12 @@ pub async fn game_types() -> Result<Vec<GameTypeInfo>, String> {
|
||||
Ok(page.results)
|
||||
}
|
||||
|
||||
pub async fn create_game(game_type: &str, target_score: i32) -> Result<GameView, String> {
|
||||
/// Create a lobby game. `options` is the game-specific creation object
|
||||
/// described by the engine's `options_schema` (see
|
||||
/// [`crate::model::GameTypeInfo::option_fields`]).
|
||||
pub async fn create_game(game_type: &str, options: serde_json::Value) -> Result<GameView, String> {
|
||||
let resp = Request::post("/api/games")
|
||||
.json(&serde_json::json!({ "game_type": game_type, "target_score": target_score }))
|
||||
.json(&serde_json::json!({ "game_type": game_type, "options": options }))
|
||||
.map_err(|e| e.to_string())?
|
||||
.send()
|
||||
.await
|
||||
@@ -91,6 +94,18 @@ pub async fn my_matches(cursor: Option<&str>) -> Result<MatchesPage, String> {
|
||||
resp.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Fetch the caller's Elo ratings (one row per game type played).
|
||||
pub async fn my_ratings() -> Result<RatingsPage, String> {
|
||||
let resp = Request::get("/api/me/ratings")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
}
|
||||
resp.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub async fn leaderboard() -> Result<LeaderboardPage, String> {
|
||||
let resp = Request::get("/api/leaderboard")
|
||||
.send()
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod card;
|
||||
pub mod summary;
|
||||
pub mod toast;
|
||||
|
||||
@@ -70,7 +70,7 @@ pub fn summary_rows(summary: HandSummary) -> View {
|
||||
// Denara
|
||||
award_row(
|
||||
card_img("02D".to_string(), "score-mini"),
|
||||
"Denara",
|
||||
"Denari",
|
||||
match &summary.award.denara {
|
||||
Some(t) => {
|
||||
let (w, l) = winner_first(summary.denara.a, summary.denara.b, Some(t));
|
||||
@@ -113,6 +113,45 @@ pub fn summary_rows(summary: HandSummary) -> View {
|
||||
summary.award.primiera.clone(),
|
||||
"+1",
|
||||
),
|
||||
// Napola (only when the rule is enabled for this match)
|
||||
match summary.napola {
|
||||
None => view! {},
|
||||
Some(napola) => {
|
||||
let n = match summary.award.napola.as_deref() {
|
||||
Some("A") => napola.a,
|
||||
Some("B") => napola.b,
|
||||
_ => 0,
|
||||
};
|
||||
let text = match (&summary.award.napola, n) {
|
||||
(Some(t), 10) => format!(
|
||||
"Team {t} swept the whole denari suit — napola! Instant match win"
|
||||
),
|
||||
(Some(t), n) => format!(
|
||||
"Team {t} captured {n} consecutive denari from the ace"
|
||||
),
|
||||
(None, _) => "No napola this hand".to_string(),
|
||||
};
|
||||
let chip = match &summary.award.napola {
|
||||
Some(t) => format!("Team {t} +{n}"),
|
||||
None => "tie".to_string(),
|
||||
};
|
||||
let cls = match summary.award.napola.as_deref() {
|
||||
Some("A") => "score-row team-a",
|
||||
Some("B") => "score-row team-b",
|
||||
_ => "score-row tie",
|
||||
};
|
||||
view! {
|
||||
div(class=cls) {
|
||||
div(class="score-icon") { (card_img("01D".to_string(), "score-mini")) }
|
||||
div(class="score-body") {
|
||||
div(class="score-title") { "Napola" }
|
||||
div(class="score-text") { (text) }
|
||||
}
|
||||
div(class="score-points") { (chip) }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// Scope
|
||||
{
|
||||
let a = summary.scope.a;
|
||||
@@ -232,7 +271,7 @@ pub fn hand_summary_modal(
|
||||
if let Some(s) = socket.get_clone() {
|
||||
s.ack();
|
||||
}
|
||||
}) { "Understood — next hand" }
|
||||
}) { "Understood, next hand" }
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
//! Auto-dismissing error toast.
|
||||
use gloo_timers::callback::Timeout;
|
||||
use sycamore::prelude::*;
|
||||
|
||||
const TOAST_MS: u32 = 10_000;
|
||||
|
||||
/// Renders the error from `error` as a toast; hides it after `TOAST_MS`.
|
||||
/// A new error replaces the message and restarts the timer.
|
||||
pub fn toast(error: Signal<Option<String>>) -> View {
|
||||
create_effect(move || {
|
||||
if error.get_clone().is_some() {
|
||||
// Held until cleanup; dropped (cancelled) when the effect re-runs.
|
||||
let timeout = Timeout::new(TOAST_MS, move || error.set(None));
|
||||
on_cleanup(move || drop(timeout));
|
||||
}
|
||||
});
|
||||
view! {
|
||||
(move || error.get_clone().map(|e| view! { div(class="toast") { (e) } }))
|
||||
}
|
||||
}
|
||||
+412
-7
@@ -2,6 +2,10 @@
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct User {
|
||||
@@ -75,6 +79,8 @@ pub struct Award {
|
||||
pub settebello: Option<String>,
|
||||
#[serde(default)]
|
||||
pub primiera: Option<String>,
|
||||
#[serde(default)]
|
||||
pub napola: Option<String>,
|
||||
}
|
||||
|
||||
/// The scoring breakdown of one completed hand.
|
||||
@@ -86,6 +92,10 @@ pub struct HandSummary {
|
||||
pub settebello: TeamBools,
|
||||
pub primiera: TeamCounts,
|
||||
pub scope: TeamCounts,
|
||||
/// Napola run lengths per team; absent when the rule is disabled (or
|
||||
/// the summary predates the option).
|
||||
#[serde(default)]
|
||||
pub napola: Option<TeamCounts>,
|
||||
pub award: Award,
|
||||
#[serde(default)]
|
||||
pub hand: i32,
|
||||
@@ -104,6 +114,9 @@ pub struct GameView {
|
||||
/// Which card game this match is (id from /api/game-types).
|
||||
#[serde(default)]
|
||||
pub game_type: String,
|
||||
/// Whether the napola rule is scored in this match.
|
||||
#[serde(default = "default_true")]
|
||||
pub napola: bool,
|
||||
pub phase: String,
|
||||
#[serde(default)]
|
||||
pub target_score: i32,
|
||||
@@ -169,29 +182,73 @@ pub struct MatchPlayer {
|
||||
pub user_sub: String,
|
||||
pub display_name: String,
|
||||
pub seat: usize,
|
||||
pub team: String,
|
||||
/// Game-defined team label; absent for games without fixed teams.
|
||||
#[serde(default)]
|
||||
pub team: Option<String>,
|
||||
pub won: bool,
|
||||
/// Points the player scored in this match.
|
||||
#[serde(default)]
|
||||
pub score: f64,
|
||||
/// Elo change this match produced for the player; absent for matches
|
||||
/// recorded before ratings existed.
|
||||
#[serde(default)]
|
||||
pub elo_delta: Option<i32>,
|
||||
/// Game-specific extras reported by the engine.
|
||||
#[serde(default)]
|
||||
pub details: serde_json::Value,
|
||||
}
|
||||
|
||||
/// The game-specific outcome of a finished match, as reported by the
|
||||
/// engine (for scopone: the teams' final scores, the winner, the target
|
||||
/// score, hands played and the per-hand audit). Games define their own
|
||||
/// shape, so callers read it through [`MatchSummary::result_str`] and
|
||||
/// [`MatchSummary::result_i64`], which return `None` for absent or
|
||||
/// mistyped values.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct MatchSummary {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub game_type: String,
|
||||
pub team_a_score: i32,
|
||||
pub team_b_score: i32,
|
||||
pub winner_team: String,
|
||||
pub target_score: i32,
|
||||
pub hands_played: i32,
|
||||
#[serde(default)]
|
||||
pub result: serde_json::Value,
|
||||
pub started_at: String,
|
||||
pub finished_at: String,
|
||||
#[serde(default)]
|
||||
pub you_won: bool,
|
||||
/// The viewer's Elo change in this match; absent when unrated.
|
||||
#[serde(default)]
|
||||
pub your_elo_delta: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub players: Vec<MatchPlayer>,
|
||||
}
|
||||
|
||||
impl MatchSummary {
|
||||
/// Read a string field from the game-specific result, if present.
|
||||
pub fn result_str(&self, key: &str) -> Option<String> {
|
||||
self.result
|
||||
.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// Read an integer field from the game-specific result, if present.
|
||||
/// JSON floats with an integral value (e.g. `11.0`) are accepted.
|
||||
pub fn result_i64(&self, key: &str) -> Option<i64> {
|
||||
self.result.get(key).and_then(|v| {
|
||||
v.as_i64().or_else(|| {
|
||||
v.as_f64().and_then(|f| {
|
||||
if f.fract() == 0.0 {
|
||||
Some(f as i64)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct MatchesPage {
|
||||
#[serde(default)]
|
||||
@@ -200,14 +257,47 @@ pub struct MatchesPage {
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
fn default_elo() -> i32 {
|
||||
1500
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct LeaderboardEntry {
|
||||
pub user_sub: String,
|
||||
pub display_name: String,
|
||||
/// Chess-style Elo rating for the requested game type.
|
||||
#[serde(default = "default_elo")]
|
||||
pub elo: i32,
|
||||
pub matches: i32,
|
||||
pub wins: i32,
|
||||
pub points: i32,
|
||||
/// Aggregated points; the backend reports a float.
|
||||
#[serde(default)]
|
||||
pub points: f64,
|
||||
}
|
||||
|
||||
/// Render aggregated points: integral values without decimals.
|
||||
pub fn fmt_points(points: f64) -> String {
|
||||
if points.fract() == 0.0 {
|
||||
format!("{}", points as i64)
|
||||
} else {
|
||||
format!("{points:.1}")
|
||||
}
|
||||
}
|
||||
|
||||
/// The caller's Elo rating for one game type (GET /api/me/ratings).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct PlayerRating {
|
||||
pub game_type: String,
|
||||
pub rating: i32,
|
||||
pub matches_played: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct RatingsPage {
|
||||
#[serde(default)]
|
||||
pub results: Vec<PlayerRating>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
@@ -224,6 +314,15 @@ pub struct GameTypeInfo {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
/// Player-count range of the game; zero when the backend predates them.
|
||||
#[serde(default)]
|
||||
pub min_players: usize,
|
||||
#[serde(default)]
|
||||
pub max_players: usize,
|
||||
/// JSON-schema fragment describing the game-specific creation options
|
||||
/// accepted by POST /api/games (see [`OptionField`]).
|
||||
#[serde(default)]
|
||||
pub options_schema: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
@@ -232,6 +331,88 @@ pub struct GameTypesPage {
|
||||
pub results: Vec<GameTypeInfo>,
|
||||
}
|
||||
|
||||
/// One creation option rendered from a game's `options_schema`.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum OptionKind {
|
||||
Integer { min: Option<i64>, max: Option<i64> },
|
||||
Boolean,
|
||||
Text,
|
||||
Enum { values: Vec<String> },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct OptionField {
|
||||
pub key: String,
|
||||
pub title: String,
|
||||
pub kind: OptionKind,
|
||||
pub default: serde_json::Value,
|
||||
}
|
||||
|
||||
fn schema_string(schema: &serde_json::Value, key: &str) -> Option<String> {
|
||||
schema.get(key).and_then(|v| v.as_str()).map(str::to_string)
|
||||
}
|
||||
|
||||
fn schema_i64(schema: &serde_json::Value, key: &str) -> Option<i64> {
|
||||
schema.get(key).and_then(|v| {
|
||||
v.as_i64().or_else(|| {
|
||||
v.as_f64()
|
||||
.and_then(|f| if f.fract() == 0.0 { Some(f as i64) } else { None })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
impl GameTypeInfo {
|
||||
/// Parse the game's `options_schema` properties into renderable
|
||||
/// fields, in alphabetical key order. Properties of unrecognized
|
||||
/// types are skipped.
|
||||
pub fn option_fields(&self) -> Vec<OptionField> {
|
||||
let Some(properties) = self.options_schema.get("properties").and_then(|v| v.as_object()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
properties
|
||||
.iter()
|
||||
.filter_map(|(key, schema)| {
|
||||
let title = schema_string(schema, "title")
|
||||
.or_else(|| schema_string(schema, "description"))
|
||||
.unwrap_or_else(|| key.clone());
|
||||
let default = schema.get("default").cloned().unwrap_or(serde_json::Value::Null);
|
||||
let kind = match schema.get("type").and_then(|v| v.as_str()) {
|
||||
Some("integer") => OptionKind::Integer {
|
||||
min: schema_i64(schema, "minimum"),
|
||||
max: schema_i64(schema, "maximum"),
|
||||
},
|
||||
Some("boolean") => OptionKind::Boolean,
|
||||
Some("string") if schema.get("enum").and_then(|v| v.as_array()).is_some() => {
|
||||
OptionKind::Enum {
|
||||
values: schema["enum"]
|
||||
.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|v| v.as_str().map(str::to_string))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
Some("string") => OptionKind::Text,
|
||||
_ => return None,
|
||||
};
|
||||
Some(OptionField { key: key.clone(), title, kind, default })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The schema defaults as a JSON object, for initializing the
|
||||
/// creation form (and as the request body when the user changes
|
||||
/// nothing).
|
||||
pub fn default_options(&self) -> serde_json::Map<String, serde_json::Value> {
|
||||
self.option_fields()
|
||||
.into_iter()
|
||||
.map(|f| (f.key, f.default))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a card code (e.g. `07D`) to its asset path.
|
||||
pub fn card_asset(code: &str) -> String {
|
||||
format!("/assets/cards/{code}.svg")
|
||||
@@ -272,6 +453,7 @@ pub fn card_label(code: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn card_asset_maps_code() {
|
||||
@@ -286,4 +468,227 @@ mod tests {
|
||||
assert_eq!("Re di bastoni", card_label("10B"));
|
||||
assert_eq!("7 di denari", card_label("07D"));
|
||||
}
|
||||
|
||||
fn scopone_match() -> MatchSummary {
|
||||
serde_json::from_value(json!({
|
||||
"id": "m1",
|
||||
"game_type": "scopone_scientifico",
|
||||
"result": {
|
||||
"team_a_score": 11,
|
||||
"team_b_score": 7.0,
|
||||
"winner_team": "A",
|
||||
"target_score": 11,
|
||||
"hands_played": 3,
|
||||
},
|
||||
"started_at": "2026-01-01T00:00:00+00:00",
|
||||
"finished_at": "2026-01-01T01:00:00+00:00",
|
||||
"you_won": true,
|
||||
"players": [
|
||||
{"user_sub": "a", "display_name": "a", "seat": 0,
|
||||
"team": "A", "won": true, "score": 11.0},
|
||||
{"user_sub": "b", "display_name": "b", "seat": 1,
|
||||
"team": null, "won": false},
|
||||
],
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_summary_reads_scopone_result() {
|
||||
let m = scopone_match();
|
||||
assert_eq!(Some(11), m.result_i64("team_a_score"));
|
||||
// Integral floats are accepted.
|
||||
assert_eq!(Some(7), m.result_i64("team_b_score"));
|
||||
assert_eq!(Some("A".to_string()), m.result_str("winner_team"));
|
||||
assert_eq!(None, m.result_str("team_a_score"));
|
||||
assert_eq!(None, m.result_i64("missing"));
|
||||
assert_eq!(None, m.result_i64("winner_team"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_summary_tolerates_empty_result() {
|
||||
let m: MatchSummary = serde_json::from_value(json!({
|
||||
"id": "m2",
|
||||
"started_at": "x",
|
||||
"finished_at": "y",
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(None, m.result_i64("team_a_score"));
|
||||
assert_eq!(None, m.result_str("winner_team"));
|
||||
assert!(m.players.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_player_team_is_optional() {
|
||||
let m = scopone_match();
|
||||
assert_eq!(Some("A".to_string()), m.players[0].team);
|
||||
assert_eq!(None, m.players[1].team);
|
||||
assert_eq!(11.0, m.players[0].score);
|
||||
assert_eq!(0.0, m.players[1].score);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmt_points_drops_integral_decimals() {
|
||||
assert_eq!("11", fmt_points(11.0));
|
||||
assert_eq!("0", fmt_points(0.0));
|
||||
assert_eq!("2.5", fmt_points(2.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaderboard_points_accept_floats() {
|
||||
let e: LeaderboardEntry = serde_json::from_value(json!({
|
||||
"user_sub": "a",
|
||||
"display_name": "a",
|
||||
"matches": 2,
|
||||
"wins": 1,
|
||||
"points": 19.0,
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(19.0, e.points);
|
||||
assert_eq!(1500, e.elo);
|
||||
}
|
||||
|
||||
fn scopone_game_type() -> GameTypeInfo {
|
||||
serde_json::from_value(json!({
|
||||
"id": "scopone_scientifico",
|
||||
"name": "Scopone scientifico",
|
||||
"description": "d",
|
||||
"min_players": 4,
|
||||
"max_players": 4,
|
||||
"options_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target_score": {
|
||||
"type": "integer", "minimum": 1, "maximum": 100,
|
||||
"default": 11,
|
||||
},
|
||||
"napola": {"type": "boolean", "default": true},
|
||||
},
|
||||
},
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_fields_parse_scopone_schema() {
|
||||
let fields = scopone_game_type().option_fields();
|
||||
assert_eq!(2, fields.len());
|
||||
let by_key: std::collections::HashMap<_, _> =
|
||||
fields.into_iter().map(|f| (f.key.clone(), f)).collect();
|
||||
let target = &by_key["target_score"];
|
||||
assert!(matches!(
|
||||
target.kind,
|
||||
OptionKind::Integer { min: Some(1), max: Some(100) }
|
||||
));
|
||||
assert_eq!(json!(11), target.default);
|
||||
assert_eq!(OptionKind::Boolean, by_key["napola"].kind);
|
||||
assert_eq!(json!(true), by_key["napola"].default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_fields_skip_unknown_types() {
|
||||
let g: GameTypeInfo = serde_json::from_value(json!({
|
||||
"id": "x",
|
||||
"name": "x",
|
||||
"options_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mystery": {"type": "object"},
|
||||
"mode": {"type": "string", "enum": ["a", "b"], "default": "a"},
|
||||
"nick": {"type": "string"},
|
||||
},
|
||||
},
|
||||
}))
|
||||
.unwrap();
|
||||
let fields = g.option_fields();
|
||||
assert_eq!(2, fields.len());
|
||||
assert_eq!(
|
||||
OptionKind::Enum { values: vec!["a".to_string(), "b".to_string()] },
|
||||
fields[0].kind
|
||||
);
|
||||
assert_eq!(OptionKind::Text, fields[1].kind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_options_collect_schema_defaults() {
|
||||
let opts = scopone_game_type().default_options();
|
||||
assert_eq!(Some(&json!(11)), opts.get("target_score"));
|
||||
assert_eq!(Some(&json!(true)), opts.get("napola"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_view_parses_new_lobby_payload() {
|
||||
// Exact shape of the platform's lobby payload: envelope fields,
|
||||
// seats with team labels, seats_open, plus the engine's
|
||||
// lobby_view (phase/target_score/napola for scopone).
|
||||
let g: GameView = serde_json::from_value(json!({
|
||||
"id": "11111111-2222-3333-4444-555555555555",
|
||||
"join_code": "ABC123",
|
||||
"game_type": "scopone_scientifico",
|
||||
"players": [
|
||||
{"sub": "alice", "name": "Alice", "seat": 0, "team": "A"},
|
||||
],
|
||||
"seats_open": 3,
|
||||
"phase": "lobby",
|
||||
"target_score": 16,
|
||||
"napola": false,
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!("ABC123", g.join_code);
|
||||
assert_eq!(Some(3), g.seats_open);
|
||||
assert_eq!("lobby", g.phase);
|
||||
assert_eq!(16, g.target_score);
|
||||
assert!(!g.napola);
|
||||
assert_eq!("A", g.players[0].team);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_view_parses_new_snapshot_payload() {
|
||||
// Envelope merged with the engine view; hands hidden for others.
|
||||
let g: GameView = serde_json::from_value(json!({
|
||||
"id": "11111111-2222-3333-4444-555555555555",
|
||||
"join_code": "ABC123",
|
||||
"game_type": "scopone_scientifico",
|
||||
"phase": "playing",
|
||||
"target_score": 11,
|
||||
"napola": true,
|
||||
"hand_number": 1,
|
||||
"dealer": 0,
|
||||
"turn": 1,
|
||||
"scores": {"A": 0, "B": 0},
|
||||
"winner": null,
|
||||
"table": [],
|
||||
"players": [
|
||||
{"sub": "alice", "name": "Alice", "seat": 0, "team": "A",
|
||||
"cards_left": 10, "captured_count": 0, "scope": 0},
|
||||
{"sub": "bob", "name": "Bob", "seat": 1, "team": "B",
|
||||
"cards_left": 10, "captured_count": 0, "scope": 0,
|
||||
"hand": ["01D"]},
|
||||
],
|
||||
"last_hand": null,
|
||||
"last_move": null,
|
||||
"acknowledged": [],
|
||||
"hand_end_deadline": null,
|
||||
"turn_deadline": "2026-01-01T00:00:30+00:00",
|
||||
"your_turn": true,
|
||||
"legal_moves": {},
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!("playing", g.phase);
|
||||
assert_eq!(None, g.players[0].hand);
|
||||
assert_eq!(Some(vec!["01D".to_string()]), g.players[1].hand);
|
||||
assert_eq!(Some(true), g.your_turn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_type_without_schema_has_no_fields() {
|
||||
let g: GameTypeInfo = serde_json::from_value(json!({
|
||||
"id": "legacy",
|
||||
"name": "Legacy",
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(g.option_fields().is_empty());
|
||||
assert!(g.default_options().is_empty());
|
||||
assert_eq!(0, g.min_players);
|
||||
}
|
||||
}
|
||||
|
||||
+258
-35
@@ -1,8 +1,12 @@
|
||||
//! Live game page: table view over the websocket.
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use sycamore::prelude::*;
|
||||
|
||||
use crate::components::card::{card_back, card_img};
|
||||
use crate::components::summary::{hand_summary_modal, summary_rows};
|
||||
use crate::components::toast::toast;
|
||||
use crate::model::{card_label, GameView, MoveView, PlayerView, Scores, ServerMessage};
|
||||
use crate::ws::{self, GameSocket};
|
||||
|
||||
@@ -69,6 +73,143 @@ fn move_banner(mv: MoveView) -> View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Signals shared by the websocket connection and its reconnect attempts.
|
||||
#[derive(Clone, Copy)]
|
||||
struct ConnCtx {
|
||||
socket: Signal<Option<GameSocket>>,
|
||||
game: Signal<Option<GameView>>,
|
||||
over: Signal<Option<(Scores, Option<String>)>>,
|
||||
error: Signal<Option<String>>,
|
||||
closed: Signal<bool>,
|
||||
/// Reconnect attempts exhausted; only a manual retry resumes.
|
||||
gave_up: Signal<bool>,
|
||||
/// The server closed the connection deliberately (auth or game gone);
|
||||
/// retrying is pointless.
|
||||
fatal: Signal<bool>,
|
||||
attempts: Signal<u32>,
|
||||
capture_choice: Signal<Option<(String, Vec<Vec<String>>)>>,
|
||||
selected: Signal<Option<String>>,
|
||||
}
|
||||
|
||||
/// Reconnect attempts: 1s, 2s, 4s, … capped at 30s, at most this many.
|
||||
const MAX_RECONNECT_ATTEMPTS: u32 = 10;
|
||||
|
||||
fn backoff_ms(attempt: u32) -> u32 {
|
||||
(1000u32 << attempt.min(5)).min(30_000)
|
||||
}
|
||||
|
||||
/// Connect the game websocket, wiring state updates and reconnects.
|
||||
///
|
||||
/// The server pushes a full state snapshot on connect, so a reconnect is
|
||||
/// also a resync: no client-side state merging is needed.
|
||||
fn start_connect(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
|
||||
let on_message = {
|
||||
let alive = alive.clone();
|
||||
move |msg: ServerMessage| {
|
||||
if !alive.get() {
|
||||
// The page is unmounted; its signals are disposed.
|
||||
return;
|
||||
}
|
||||
match msg {
|
||||
ServerMessage::State { game: g } => {
|
||||
ctx.capture_choice.set(None);
|
||||
ctx.selected.set(None);
|
||||
// A received state proves the (re)connection works.
|
||||
ctx.attempts.set(0);
|
||||
ctx.gave_up.set(false);
|
||||
ctx.closed.set(false);
|
||||
ctx.game.set(Some(g));
|
||||
}
|
||||
ServerMessage::GameOver { scores, winner } => {
|
||||
ctx.over.set(Some((scores, winner)))
|
||||
}
|
||||
ServerMessage::Error { message, .. } => ctx.error.set(Some(message)),
|
||||
}
|
||||
}
|
||||
};
|
||||
let on_close = {
|
||||
let id = id.clone();
|
||||
let alive = alive.clone();
|
||||
move |code: Option<u16>| {
|
||||
if !alive.get() {
|
||||
return;
|
||||
}
|
||||
ctx.closed.set(true);
|
||||
match code {
|
||||
Some(4401) => {
|
||||
ctx.fatal.set(true);
|
||||
ctx.error
|
||||
.set(Some("Session expired — please log in again.".to_string()));
|
||||
}
|
||||
Some(4403) | Some(4404) => {
|
||||
ctx.fatal.set(true);
|
||||
ctx.error
|
||||
.set(Some("This game is no longer available.".to_string()));
|
||||
}
|
||||
_ => schedule_retry(id.clone(), ctx, alive.clone()),
|
||||
}
|
||||
}
|
||||
};
|
||||
match ws::connect(&id, on_message, on_close) {
|
||||
Some(s) => ctx.socket.set(Some(s)),
|
||||
// WebSocket::open failed synchronously: treat as a transient loss.
|
||||
None if alive.get() => {
|
||||
ctx.closed.set(true);
|
||||
schedule_retry(id, ctx, alive);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Retry `start_connect` with exponential backoff, unless we gave up.
|
||||
fn schedule_retry(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
|
||||
let attempt = ctx.attempts.get();
|
||||
if attempt >= MAX_RECONNECT_ATTEMPTS {
|
||||
ctx.gave_up.set(true);
|
||||
return;
|
||||
}
|
||||
ctx.attempts.set(attempt + 1);
|
||||
gloo_timers::callback::Timeout::new(backoff_ms(attempt), move || {
|
||||
if alive.get() {
|
||||
start_connect(id, ctx, alive);
|
||||
}
|
||||
})
|
||||
.forget();
|
||||
}
|
||||
|
||||
/// Slim banner shown over the table while the socket is down.
|
||||
fn conn_banner(
|
||||
closed: bool,
|
||||
gave_up: bool,
|
||||
fatal: bool,
|
||||
has_game: bool,
|
||||
reconnect: Rc<dyn Fn()>,
|
||||
) -> View {
|
||||
if !closed || !has_game {
|
||||
return view! {};
|
||||
}
|
||||
if fatal {
|
||||
view! {
|
||||
div(class="conn-banner") {
|
||||
"Connection closed by the server. "
|
||||
a(href="/") { "Back to lobby" }
|
||||
}
|
||||
}
|
||||
} else if gave_up {
|
||||
view! {
|
||||
div(class="conn-banner") {
|
||||
"Connection lost."
|
||||
button(class="button", on:click=move |_| reconnect()) { "Retry now" }
|
||||
a(href="/") { "Back to lobby" }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
view! {
|
||||
div(class="conn-banner") { "Connection lost — reconnecting…" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component(inline_props)]
|
||||
pub fn GamePage(id: String) -> View {
|
||||
let game = create_signal(Option::<GameView>::None);
|
||||
@@ -77,32 +218,51 @@ pub fn GamePage(id: String) -> View {
|
||||
let selected = create_signal(Option::<String>::None);
|
||||
let over = create_signal(Option::<(Scores, Option<String>)>::None);
|
||||
let closed = create_signal(false);
|
||||
let gave_up = create_signal(false);
|
||||
let fatal = create_signal(false);
|
||||
let attempts = create_signal(0u32);
|
||||
let socket = create_signal(Option::<GameSocket>::None);
|
||||
// Ticking clock driving the hand-end countdown display.
|
||||
let now = create_signal(js_sys::Date::now());
|
||||
gloo_timers::callback::Interval::new(500, move || now.set(js_sys::Date::now())).forget();
|
||||
let ticker = gloo_timers::callback::Interval::new(500, move || now.set(js_sys::Date::now()));
|
||||
|
||||
{
|
||||
let on_message = move |msg: ServerMessage| match msg {
|
||||
ServerMessage::State { game: g } => {
|
||||
capture_choice.set(None);
|
||||
selected.set(None);
|
||||
game.set(Some(g));
|
||||
}
|
||||
ServerMessage::GameOver { scores, winner } => {
|
||||
over.set(Some((scores, winner)));
|
||||
}
|
||||
ServerMessage::Error { message, .. } => error.set(Some(message)),
|
||||
};
|
||||
let on_close = move || closed.set(true);
|
||||
match ws::connect(&id, on_message, on_close) {
|
||||
Some(s) => socket.set(Some(s)),
|
||||
None => error.set(Some("Could not connect to the game".to_string())),
|
||||
// Stops the ticker and any pending reconnect once the page unmounts.
|
||||
let alive = Rc::new(Cell::new(true));
|
||||
on_cleanup({
|
||||
let alive = alive.clone();
|
||||
move || {
|
||||
alive.set(false);
|
||||
drop(ticker);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let id = Rc::new(id);
|
||||
let ctx = ConnCtx {
|
||||
socket,
|
||||
game,
|
||||
over,
|
||||
error,
|
||||
closed,
|
||||
gave_up,
|
||||
fatal,
|
||||
attempts,
|
||||
capture_choice,
|
||||
selected,
|
||||
};
|
||||
start_connect(id.clone(), ctx, alive.clone());
|
||||
let reconnect: Rc<dyn Fn()> = Rc::new(move || {
|
||||
ctx.attempts.set(0);
|
||||
ctx.gave_up.set(false);
|
||||
ctx.closed.set(false);
|
||||
start_connect(id.clone(), ctx, alive.clone());
|
||||
});
|
||||
|
||||
// Clicking a card in the player's own hand.
|
||||
let on_hand_card = move |code: String| {
|
||||
if closed.get() {
|
||||
// A dead socket would swallow the play silently.
|
||||
return;
|
||||
}
|
||||
let Some(g) = game.get_clone() else { return };
|
||||
if g.your_turn != Some(true) {
|
||||
return;
|
||||
@@ -121,31 +281,68 @@ pub fn GamePage(id: String) -> View {
|
||||
}
|
||||
};
|
||||
|
||||
let reconnect_banner = reconnect.clone();
|
||||
view! {
|
||||
div(class="game-page") {
|
||||
(move || error.get_clone().map(|e| view! { div(class="toast") { (e) } }))
|
||||
(toast(error))
|
||||
(move || conn_banner(
|
||||
closed.get(),
|
||||
gave_up.get(),
|
||||
fatal.get(),
|
||||
game.get_clone().is_some(),
|
||||
reconnect_banner.clone(),
|
||||
))
|
||||
(move || match game.get_clone() {
|
||||
None => {
|
||||
let status = if closed.get() {
|
||||
"Connection closed."
|
||||
if fatal.get() {
|
||||
view! {
|
||||
div(class="panel status-panel") {
|
||||
p { "Connection closed." }
|
||||
p { a(href="/") { "Back to lobby" } }
|
||||
}
|
||||
}
|
||||
} else if gave_up.get() {
|
||||
let reconnect = reconnect.clone();
|
||||
view! {
|
||||
div(class="panel status-panel") {
|
||||
p { "Connection lost." }
|
||||
p {
|
||||
button(class="button primary", on:click=move |_| reconnect()) {
|
||||
"Retry now"
|
||||
}
|
||||
}
|
||||
p { a(href="/") { "Back to lobby" } }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
"Connecting to the game…"
|
||||
};
|
||||
view! {
|
||||
div(class="panel status-panel") {
|
||||
p { (status) }
|
||||
p { a(href="/") { "Back to lobby" } }
|
||||
let status = if closed.get() {
|
||||
"Connection lost — reconnecting…"
|
||||
} else {
|
||||
"Connecting to the game…"
|
||||
};
|
||||
view! {
|
||||
div(class="panel status-panel") {
|
||||
p { (status) }
|
||||
p { a(href="/") { "Back to lobby" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(g) if g.phase == "lobby" => lobby_view(g),
|
||||
Some(g) => table_view(g, on_hand_card, selected, now),
|
||||
// The live table is scopone-specific; other games get a
|
||||
// fallback until they ship their own play view.
|
||||
Some(g) if g.game_type == "scopone_scientifico" => {
|
||||
table_view(g, on_hand_card, selected, now)
|
||||
}
|
||||
Some(g) => unsupported_game_view(g),
|
||||
})
|
||||
(move || capture_choice.get_clone().map(|(card, options)| {
|
||||
capture_picker(card, options, socket, capture_choice)
|
||||
}))
|
||||
(move || match game.get_clone() {
|
||||
Some(g) if g.phase == "hand_end" => hand_summary_modal(g, socket, now),
|
||||
Some(g) if g.phase == "hand_end" && g.game_type == "scopone_scientifico" => {
|
||||
hand_summary_modal(g, socket, now)
|
||||
}
|
||||
_ => view! {},
|
||||
})
|
||||
(move || game_over_view(over.get_clone(), game.get_clone()))
|
||||
@@ -153,9 +350,13 @@ pub fn GamePage(id: String) -> View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lobby view while waiting for the fourth player.
|
||||
/// Lobby view while waiting for the remaining players. The seat count
|
||||
/// comes from the payload (`seats_open`), so games with other player
|
||||
/// counts render correctly.
|
||||
fn lobby_view(game: GameView) -> View {
|
||||
let seats_open = 4usize.saturating_sub(game.players.len());
|
||||
let seats_open = game
|
||||
.seats_open
|
||||
.unwrap_or_else(|| 4usize.saturating_sub(game.players.len()));
|
||||
let join_code = game.join_code.clone();
|
||||
let players = game
|
||||
.players
|
||||
@@ -184,6 +385,18 @@ fn lobby_view(game: GameView) -> View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback for live games the web client has no play view for yet.
|
||||
fn unsupported_game_view(game: GameView) -> View {
|
||||
let game_type = game.game_type.clone();
|
||||
view! {
|
||||
div(class="panel status-panel") {
|
||||
h2 { "Unsupported game" }
|
||||
p { "Live play for “" (game_type) "” isn't supported in the web client yet." }
|
||||
p { a(href="/") { "Back to lobby" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn player_for_seat(game: &GameView, seat: usize) -> Option<PlayerView> {
|
||||
game.players.iter().find(|p| p.seat == seat).cloned()
|
||||
}
|
||||
@@ -246,9 +459,10 @@ fn table_view(
|
||||
let hand_number = game.hand_number;
|
||||
let target_score = game.target_score;
|
||||
|
||||
let hand = player_for_seat(&game, viewer_seat)
|
||||
.and_then(|p| p.hand)
|
||||
.unwrap_or_default();
|
||||
let viewer = player_for_seat(&game, viewer_seat);
|
||||
let my_captured = viewer.as_ref().map(|p| p.captured_count).unwrap_or(0);
|
||||
let my_scope = viewer.as_ref().map(|p| p.scope).unwrap_or(0);
|
||||
let hand = viewer.and_then(|p| p.hand).unwrap_or_default();
|
||||
let current_selection = selected.get_clone();
|
||||
let hand_cards = hand
|
||||
.into_iter()
|
||||
@@ -300,6 +514,9 @@ fn table_view(
|
||||
}
|
||||
(right)
|
||||
div(class="seat-bottom") {
|
||||
div(class="seat-stats own-stats") {
|
||||
(my_captured) " captured · " (my_scope) " scope"
|
||||
}
|
||||
div(class="hand") { (hand_cards) }
|
||||
(hint)
|
||||
}
|
||||
@@ -343,8 +560,14 @@ fn capture_picker(
|
||||
}
|
||||
}
|
||||
|
||||
/// End-of-match overlay.
|
||||
/// End-of-match overlay (scopone's team score; other games render
|
||||
/// nothing until they ship their own play view).
|
||||
fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>) -> View {
|
||||
if let Some(g) = &game {
|
||||
if g.game_type != "scopone_scientifico" {
|
||||
return view! {};
|
||||
}
|
||||
}
|
||||
let result = over.or_else(|| {
|
||||
game.clone()
|
||||
.filter(|g| g.phase == "finished")
|
||||
|
||||
+129
-27
@@ -1,9 +1,72 @@
|
||||
//! Match history page.
|
||||
//!
|
||||
//! Rendering is game-generic: teams, scores and the winner are read from
|
||||
//! each match's game-specific `result` object, degrading gracefully when
|
||||
//! a game reports a different shape.
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use sycamore::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
use crate::model::MatchesPage;
|
||||
use crate::components::toast::toast;
|
||||
use crate::model::{GameTypeInfo, MatchSummary, MatchesPage};
|
||||
|
||||
/// Distinct team labels in seat order; players without a team render in
|
||||
/// a shared "Players" column.
|
||||
fn team_columns(m: &MatchSummary) -> Vec<Option<String>> {
|
||||
let mut teams = BTreeSet::new();
|
||||
for p in &m.players {
|
||||
teams.insert(p.team.clone());
|
||||
}
|
||||
let mut ordered: Vec<Option<String>> = teams.into_iter().collect();
|
||||
// Seated teams first (in first-seat order), then the team-less column.
|
||||
ordered.sort_by_key(|t| match t {
|
||||
Some(_) => (0, String::new()),
|
||||
None => (1, String::new()),
|
||||
});
|
||||
ordered
|
||||
}
|
||||
|
||||
fn team_names(m: &MatchSummary, team: &Option<String>) -> String {
|
||||
m.players
|
||||
.iter()
|
||||
.filter(|p| &p.team == team)
|
||||
.map(|p| p.display_name.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" & ")
|
||||
}
|
||||
|
||||
fn team_header(team: &Option<String>) -> String {
|
||||
match team {
|
||||
Some(t) => format!("Team {t}"),
|
||||
None => "Players".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// "11 – 7" when the result carries both team scores, "—" otherwise.
|
||||
fn score_text(m: &MatchSummary) -> String {
|
||||
match (m.result_i64("team_a_score"), m.result_i64("team_b_score")) {
|
||||
(Some(a), Some(b)) => format!("{a} – {b}"),
|
||||
_ => "—".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// "Team A" from `winner_team`, a plain `winner`, or "—".
|
||||
fn winner_text(m: &MatchSummary) -> String {
|
||||
if let Some(team) = m.result_str("winner_team") {
|
||||
return format!("Team {team}");
|
||||
}
|
||||
m.result_str("winner").unwrap_or_else(|| "—".to_string())
|
||||
}
|
||||
|
||||
fn game_name(game_types: &[GameTypeInfo], game_type: &str) -> String {
|
||||
game_types
|
||||
.iter()
|
||||
.find(|g| g.id == game_type)
|
||||
.map(|g| g.name.clone())
|
||||
.unwrap_or_else(|| game_type.to_string())
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn HistoryPage() -> View {
|
||||
@@ -12,6 +75,7 @@ pub fn HistoryPage() -> View {
|
||||
let cursor = create_signal(Option::<String>::None);
|
||||
// Accumulated rows across "load more" clicks.
|
||||
let rows = create_signal(Vec::<crate::model::MatchSummary>::new());
|
||||
let game_types = create_signal(Vec::<GameTypeInfo>::new());
|
||||
|
||||
let load = move |next: Option<String>| {
|
||||
spawn_local(async move {
|
||||
@@ -26,6 +90,12 @@ pub fn HistoryPage() -> View {
|
||||
});
|
||||
};
|
||||
|
||||
spawn_local(async move {
|
||||
if let Ok(types) = api::game_types().await {
|
||||
game_types.set(types);
|
||||
}
|
||||
});
|
||||
|
||||
let load2 = load;
|
||||
spawn_local(async move {
|
||||
match api::my_matches(None).await {
|
||||
@@ -45,54 +115,86 @@ pub fn HistoryPage() -> View {
|
||||
a(href="/leaderboard") { "Leaderboard" }
|
||||
}
|
||||
h1 { "My matches" }
|
||||
(move || error.get_clone().map(|e| view! { div(class="toast") { (e) } }))
|
||||
(toast(error))
|
||||
(move || match page.get_clone() {
|
||||
None => view! { p(class="status") { "Loading…" } },
|
||||
Some(_) if rows.get_clone().is_empty() => view! {
|
||||
p(class="status") { "No matches played yet." }
|
||||
},
|
||||
Some(_) => {
|
||||
let names = game_types.get_clone();
|
||||
let table_rows = rows
|
||||
.get_clone()
|
||||
.into_iter()
|
||||
.map(|m| {
|
||||
let team_a: String = m
|
||||
.players
|
||||
let finished = m
|
||||
.finished_at
|
||||
.replace('T', " ")
|
||||
.chars()
|
||||
.take(16)
|
||||
.collect::<String>();
|
||||
let game = game_name(&names, &m.game_type);
|
||||
let mut cells: Vec<View> = team_columns(&m)
|
||||
.iter()
|
||||
.filter(|p| p.team == "A")
|
||||
.map(|p| p.display_name.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" & ");
|
||||
let team_b: String = m
|
||||
.players
|
||||
.iter()
|
||||
.filter(|p| p.team == "B")
|
||||
.map(|p| p.display_name.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" & ");
|
||||
.map(|t| team_names(&m, t))
|
||||
.map(|c| view! { td { (c) } })
|
||||
.collect();
|
||||
let score = score_text(&m);
|
||||
let winner = winner_text(&m);
|
||||
let outcome = if m.you_won { "Won" } else { "Lost" };
|
||||
view! {
|
||||
tr {
|
||||
td { (m.finished_at.replace('T', " ").chars().take(16).collect::<String>()) }
|
||||
td { (team_a) }
|
||||
td { (team_b) }
|
||||
td { (m.team_a_score) " – " (m.team_b_score) }
|
||||
td { "Team " (m.winner_team) }
|
||||
td(class=if m.you_won { "won" } else { "lost" }) { (outcome) }
|
||||
}
|
||||
}
|
||||
let elo_delta = m
|
||||
.your_elo_delta
|
||||
.map(|d| {
|
||||
if d >= 0 {
|
||||
format!("+{d}")
|
||||
} else {
|
||||
d.to_string()
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "—".to_string());
|
||||
let cls = if m.you_won { "won" } else { "lost" };
|
||||
let mut row_cells = vec![
|
||||
view! { td { (finished) } },
|
||||
view! { td { (game) } },
|
||||
];
|
||||
row_cells.append(&mut cells);
|
||||
row_cells.push(view! { td { (score) } });
|
||||
row_cells.push(view! { td { (winner) } });
|
||||
row_cells.push(view! { td(class=cls) { (outcome) } });
|
||||
row_cells.push(view! { td(class=cls) { (elo_delta) } });
|
||||
view! { tr { (row_cells) } }
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
// Team columns are per-row (matches may mix games), so
|
||||
// the header shows the union across the loaded rows.
|
||||
let mut header_teams = BTreeSet::new();
|
||||
for m in rows.get_clone().iter() {
|
||||
for t in team_columns(m) {
|
||||
header_teams.insert(t);
|
||||
}
|
||||
}
|
||||
let mut header_teams: Vec<Option<String>> =
|
||||
header_teams.into_iter().collect();
|
||||
header_teams.sort_by_key(|t| match t {
|
||||
Some(_) => (0, String::new()),
|
||||
None => (1, String::new()),
|
||||
});
|
||||
let header_cells: Vec<View> = header_teams
|
||||
.iter()
|
||||
.map(team_header)
|
||||
.map(|h| view! { th { (h) } })
|
||||
.collect();
|
||||
view! {
|
||||
table(class="matches") {
|
||||
thead {
|
||||
tr {
|
||||
th { "Finished" }
|
||||
th { "Team A" }
|
||||
th { "Team B" }
|
||||
th { "Game" }
|
||||
(header_cells)
|
||||
th { "Score" }
|
||||
th { "Winner" }
|
||||
th { "You" }
|
||||
th { "Elo" }
|
||||
}
|
||||
}
|
||||
tbody { (table_rows) }
|
||||
|
||||
@@ -3,7 +3,8 @@ use wasm_bindgen_futures::spawn_local;
|
||||
use sycamore::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
use crate::model::LeaderboardPage;
|
||||
use crate::components::toast::toast;
|
||||
use crate::model::{fmt_points, LeaderboardPage};
|
||||
|
||||
#[component]
|
||||
pub fn LeaderboardPage() -> View {
|
||||
@@ -24,7 +25,7 @@ pub fn LeaderboardPage() -> View {
|
||||
a(href="/history") { "My matches" }
|
||||
}
|
||||
h1 { "Leaderboard" }
|
||||
(move || error.get_clone().map(|e| view! { div(class="toast") { (e) } }))
|
||||
(toast(error))
|
||||
(move || match page.get_clone() {
|
||||
None => view! { p(class="status") { "Loading…" } },
|
||||
Some(p) => {
|
||||
@@ -38,9 +39,10 @@ pub fn LeaderboardPage() -> View {
|
||||
tr {
|
||||
td { (i + 1) }
|
||||
td { (e.display_name.clone()) }
|
||||
td { (e.elo) }
|
||||
td { (e.wins) }
|
||||
td { (e.matches) }
|
||||
td { (e.points) }
|
||||
td { (fmt_points(e.points)) }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -51,6 +53,7 @@ pub fn LeaderboardPage() -> View {
|
||||
tr {
|
||||
th { "#" }
|
||||
th { "Player" }
|
||||
th { "Elo" }
|
||||
th { "Wins" }
|
||||
th { "Matches" }
|
||||
th { "Points" }
|
||||
|
||||
+243
-12
@@ -1,10 +1,17 @@
|
||||
//! Lobby page: login prompt, match creation and joining by code.
|
||||
//!
|
||||
//! Match creation is game-generic: the form is rendered from the selected
|
||||
//! game's `options_schema` (see [`crate::model::GameTypeInfo`]), so new
|
||||
//! games get a working creation UI without frontend changes.
|
||||
use std::collections::HashMap;
|
||||
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use sycamore::prelude::*;
|
||||
use sycamore_router::navigate;
|
||||
|
||||
use crate::api;
|
||||
use crate::model::{GameTypeInfo, User};
|
||||
use crate::components::toast::toast;
|
||||
use crate::model::{GameTypeInfo, OptionField, OptionKind, PlayerRating, User};
|
||||
|
||||
/// Used when the game-types fetch fails: match creation must still work.
|
||||
fn fallback_game_types() -> Vec<GameTypeInfo> {
|
||||
@@ -12,21 +19,197 @@ fn fallback_game_types() -> Vec<GameTypeInfo> {
|
||||
id: "scopone_scientifico".to_string(),
|
||||
name: "Scopone scientifico".to_string(),
|
||||
description: String::new(),
|
||||
min_players: 4,
|
||||
max_players: 4,
|
||||
options_schema: serde_json::Value::Null,
|
||||
}]
|
||||
}
|
||||
|
||||
fn json_to_string(value: &serde_json::Value) -> String {
|
||||
match value {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
serde_json::Value::Number(n) => n.to_string(),
|
||||
serde_json::Value::Bool(b) => b.to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// One creation option: renders the control matching the schema kind and
|
||||
/// writes the parsed value back into the shared `options` map.
|
||||
#[derive(Props)]
|
||||
struct OptionInputProps {
|
||||
field: OptionField,
|
||||
options: Signal<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn OptionInput(props: OptionInputProps) -> View {
|
||||
let field = props.field;
|
||||
let options: Signal<HashMap<String, serde_json::Value>> = props.options;
|
||||
let key = field.key.clone();
|
||||
let title = field.title.clone();
|
||||
match field.kind.clone() {
|
||||
OptionKind::Boolean => {
|
||||
let initial = options
|
||||
.get_clone()
|
||||
.get(&key)
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or_else(|| field.default.as_bool().unwrap_or(false));
|
||||
let flag = create_signal(initial);
|
||||
let write_key = key.clone();
|
||||
let id_attr = key.clone();
|
||||
let for_attr = key;
|
||||
create_effect(move || {
|
||||
let value = flag.get_clone();
|
||||
options.update(|m| {
|
||||
m.insert(write_key.clone(), serde_json::Value::Bool(value));
|
||||
});
|
||||
});
|
||||
view! {
|
||||
label(class="check", r#for=for_attr) {
|
||||
input(id=id_attr, r#type="checkbox", bind:checked=flag)
|
||||
" " (title)
|
||||
}
|
||||
}
|
||||
}
|
||||
OptionKind::Enum { values } => {
|
||||
let initial = options
|
||||
.get_clone()
|
||||
.get(&key)
|
||||
.map(json_to_string)
|
||||
.unwrap_or_else(|| json_to_string(&field.default));
|
||||
let value = create_signal(initial);
|
||||
let default = field.default.clone();
|
||||
let write_key = key.clone();
|
||||
create_effect(move || {
|
||||
let text = value.get_clone();
|
||||
let parsed = if text.is_empty() {
|
||||
default.clone()
|
||||
} else {
|
||||
serde_json::Value::String(text)
|
||||
};
|
||||
options.update(|m| {
|
||||
m.insert(write_key.clone(), parsed);
|
||||
});
|
||||
});
|
||||
let items = create_signal(values);
|
||||
let id_attr = key.clone();
|
||||
let for_attr = key;
|
||||
view! {
|
||||
label(r#for=for_attr) { (title) }
|
||||
select(id=id_attr, bind:value=value) {
|
||||
Keyed(
|
||||
list=items,
|
||||
view=|v| {
|
||||
let val = v.clone();
|
||||
let label = v;
|
||||
view! { option(value=val) { (label) } }
|
||||
},
|
||||
key=|v| v.clone(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
OptionKind::Integer { min, max } => {
|
||||
let initial = options
|
||||
.get_clone()
|
||||
.get(&key)
|
||||
.map(json_to_string)
|
||||
.unwrap_or_else(|| json_to_string(&field.default));
|
||||
let value = create_signal(initial);
|
||||
let default = field.default.clone();
|
||||
let write_key = key.clone();
|
||||
create_effect(move || {
|
||||
let text = value.get_clone();
|
||||
let parsed = text
|
||||
.parse::<i64>()
|
||||
.map(serde_json::Value::from)
|
||||
.unwrap_or_else(|_| default.clone());
|
||||
options.update(|m| {
|
||||
m.insert(write_key.clone(), parsed);
|
||||
});
|
||||
});
|
||||
let min_attr = min.map(|m| m.to_string()).unwrap_or_default();
|
||||
let max_attr = max.map(|m| m.to_string()).unwrap_or_default();
|
||||
let id_attr = key.clone();
|
||||
let for_attr = key;
|
||||
view! {
|
||||
label(r#for=for_attr) { (title) }
|
||||
input(
|
||||
id=id_attr,
|
||||
r#type="number",
|
||||
min=min_attr,
|
||||
max=max_attr,
|
||||
bind:value=value,
|
||||
)
|
||||
}
|
||||
}
|
||||
OptionKind::Text => {
|
||||
let initial = options
|
||||
.get_clone()
|
||||
.get(&key)
|
||||
.map(json_to_string)
|
||||
.unwrap_or_else(|| json_to_string(&field.default));
|
||||
let value = create_signal(initial);
|
||||
let write_key = key.clone();
|
||||
create_effect(move || {
|
||||
let text = value.get_clone();
|
||||
options.update(|m| {
|
||||
m.insert(write_key.clone(), serde_json::Value::String(text));
|
||||
});
|
||||
});
|
||||
let id_attr = key.clone();
|
||||
let for_attr = key;
|
||||
view! {
|
||||
label(r#for=for_attr) { (title) }
|
||||
input(id=id_attr, r#type="text", bind:value=value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn LobbyPage() -> View {
|
||||
// Outer None = still loading; Some(None) = logged out.
|
||||
let user = create_signal(Option::<Option<User>>::None);
|
||||
// The player's Elo ratings, one row per game type played.
|
||||
let ratings = create_signal(Vec::<PlayerRating>::new());
|
||||
let error = create_signal(Option::<String>::None);
|
||||
let code = create_signal(String::new());
|
||||
let game_types = create_signal(fallback_game_types());
|
||||
let selected_game = create_signal("scopone_scientifico".to_string());
|
||||
// Current creation-form values (option key -> JSON value), reset to
|
||||
// the schema defaults whenever the selection changes.
|
||||
let options = create_signal(HashMap::<String, serde_json::Value>::new());
|
||||
let fields = create_signal(Vec::<OptionField>::new());
|
||||
|
||||
create_effect(move || {
|
||||
let id = selected_game.get_clone();
|
||||
let game = game_types.get_clone().into_iter().find(|g| g.id == id);
|
||||
match game {
|
||||
Some(g) => {
|
||||
options.set(g.default_options().into_iter().collect());
|
||||
fields.set(g.option_fields());
|
||||
}
|
||||
None => {
|
||||
options.set(HashMap::new());
|
||||
fields.set(Vec::new());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
spawn_local(async move {
|
||||
match api::me().await {
|
||||
Ok(me) => user.set(Some(me)),
|
||||
Ok(me) => {
|
||||
if me.is_some() {
|
||||
spawn_local(async move {
|
||||
if let Ok(p) = api::my_ratings().await {
|
||||
ratings.set(p.results);
|
||||
}
|
||||
});
|
||||
}
|
||||
user.set(Some(me));
|
||||
}
|
||||
Err(e) => {
|
||||
error.set(Some(e));
|
||||
user.set(Some(None));
|
||||
@@ -44,10 +227,12 @@ pub fn LobbyPage() -> View {
|
||||
}
|
||||
});
|
||||
|
||||
let on_create = move |target: i32| {
|
||||
let on_create = move |_| {
|
||||
let game_type = selected_game.get_clone();
|
||||
let opts: serde_json::Map<String, serde_json::Value> =
|
||||
options.get_clone().into_iter().collect();
|
||||
spawn_local(async move {
|
||||
match api::create_game(&game_type, target).await {
|
||||
match api::create_game(&game_type, serde_json::Value::Object(opts)).await {
|
||||
Ok(game) => navigate(&format!("/game/{}", game.id)),
|
||||
Err(e) => error.set(Some(e)),
|
||||
}
|
||||
@@ -69,8 +254,8 @@ pub fn LobbyPage() -> View {
|
||||
|
||||
view! {
|
||||
div(class="lobby") {
|
||||
h1 { "Scopone scientifico" }
|
||||
(move || error.get_clone().map(|e| view! { div(class="toast") { (e) } }))
|
||||
h1 { "tavolo" }
|
||||
(toast(error))
|
||||
(move || match user.get_clone() {
|
||||
None => view! { p(class="status") { "Loading…" } },
|
||||
Some(None) => view! {
|
||||
@@ -85,7 +270,16 @@ pub fn LobbyPage() -> View {
|
||||
Some(Some(me)) => view! {
|
||||
div(class="lobby-grid") {
|
||||
nav(class="top-nav") {
|
||||
span(class="whoami") { "Signed in as " strong { (me.name.clone()) } }
|
||||
span(class="whoami") {
|
||||
"Signed in as " strong { (me.name.clone()) }
|
||||
(move || ratings
|
||||
.get_clone()
|
||||
.into_iter()
|
||||
.find(|r| r.game_type == selected_game.get_clone())
|
||||
.map(|r| view! {
|
||||
span(class="rating") { " · Elo " (r.rating) }
|
||||
}))
|
||||
}
|
||||
a(href="/history") { "My matches" }
|
||||
a(href="/leaderboard") { "Leaderboard" }
|
||||
a(href="/auth/logout", rel="external") { "Log out" }
|
||||
@@ -100,11 +294,48 @@ pub fn LobbyPage() -> View {
|
||||
key=|g| g.id.clone(),
|
||||
)
|
||||
}
|
||||
p { "First team to reach the target score wins." }
|
||||
div(class="target-buttons") {
|
||||
button(class="button", on:click=move |_| on_create(11)) { "Target 11" }
|
||||
button(class="button", on:click=move |_| on_create(16)) { "Target 16" }
|
||||
button(class="button", on:click=move |_| on_create(21)) { "Target 21" }
|
||||
(move || game_types
|
||||
.get_clone()
|
||||
.into_iter()
|
||||
.find(|g| g.id == selected_game.get_clone())
|
||||
.map(|g| {
|
||||
let players_note = match (g.min_players, g.max_players) {
|
||||
(0, 0) => None,
|
||||
(min, max) if min == max => {
|
||||
Some(format!("{min} players"))
|
||||
}
|
||||
(min, max) => {
|
||||
Some(format!("{min}–{max} players"))
|
||||
}
|
||||
};
|
||||
let description: View = if g.description.is_empty() {
|
||||
view! {}
|
||||
} else {
|
||||
let text = g.description.clone();
|
||||
view! {
|
||||
p(class="hint") { (text) }
|
||||
}
|
||||
};
|
||||
let note: View = match players_note {
|
||||
Some(note) => view! {
|
||||
p(class="hint") { (note) }
|
||||
},
|
||||
None => view! {},
|
||||
};
|
||||
view! {
|
||||
(description)
|
||||
(note)
|
||||
}
|
||||
}))
|
||||
Keyed(
|
||||
list=fields,
|
||||
view=move |f| view! {
|
||||
OptionInput(field=f.clone(), options=options)
|
||||
},
|
||||
key=|f| f.key.clone(),
|
||||
)
|
||||
button(class="button primary", on:click=on_create) {
|
||||
"Create match"
|
||||
}
|
||||
}
|
||||
div(class="panel") {
|
||||
|
||||
+12
-3
@@ -4,7 +4,7 @@ use std::rc::Rc;
|
||||
|
||||
use futures::channel::mpsc;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use gloo_net::websocket::{futures::WebSocket, Message};
|
||||
use gloo_net::websocket::{futures::WebSocket, Message, WebSocketError};
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
use crate::model::ServerMessage;
|
||||
@@ -53,10 +53,14 @@ impl GameSocket {
|
||||
/// Open the websocket for `game_id` and forward parsed server messages to
|
||||
/// `on_message`. Returns the socket handle, or `None` if the connection
|
||||
/// could not be created.
|
||||
///
|
||||
/// `on_close` fires exactly once when the connection ends; it receives the
|
||||
/// server close code when one was sent (e.g. 4401 unauthenticated, 4403 not
|
||||
/// seated, 4404 unknown game) or `None` for an abnormal network loss.
|
||||
pub fn connect(
|
||||
game_id: &str,
|
||||
on_message: impl Fn(ServerMessage) + 'static,
|
||||
on_close: impl Fn() + 'static,
|
||||
on_close: impl Fn(Option<u16>) + 'static,
|
||||
) -> Option<GameSocket> {
|
||||
let ws = WebSocket::open(&ws_url(game_id)).ok()?;
|
||||
let (mut write, mut read) = ws.split();
|
||||
@@ -72,6 +76,7 @@ pub fn connect(
|
||||
});
|
||||
|
||||
spawn_local(async move {
|
||||
let mut close_code = None;
|
||||
while let Some(msg) = read.next().await {
|
||||
match msg {
|
||||
Ok(Message::Text(text)) => {
|
||||
@@ -80,10 +85,14 @@ pub fn connect(
|
||||
}
|
||||
}
|
||||
Ok(Message::Bytes(_)) => {}
|
||||
Err(WebSocketError::ConnectionClose(e)) => {
|
||||
close_code = Some(e.code);
|
||||
break;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
on_close();
|
||||
on_close(close_code);
|
||||
});
|
||||
|
||||
Some(GameSocket {
|
||||
|
||||
@@ -49,6 +49,10 @@ body {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.whoami .rating {
|
||||
color: var(--muted, #888);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border-radius: 12px;
|
||||
@@ -123,6 +127,12 @@ body {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.join-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -304,6 +314,11 @@ table.matches td.lost {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* The viewer's own stats sit above the hand; no flex context here. */
|
||||
.seat-bottom .seat-stats {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.center {
|
||||
grid-area: center;
|
||||
display: flex;
|
||||
@@ -407,6 +422,27 @@ table.matches td.lost {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* ---------- connection banner ---------- */
|
||||
|
||||
.conn-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
background: rgba(232, 197, 71, 0.15);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
color: var(--accent);
|
||||
padding: 0.4rem 1rem;
|
||||
margin: 0.5rem auto 0;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.conn-banner a {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ---------- overlays ---------- */
|
||||
|
||||
.overlay {
|
||||
|
||||
Reference in New Issue
Block a user