Compare commits

..
Author SHA1 Message Date
woggioni 7b3b38ccbc Rewrite the backend in Rust with Axum
CI / Build and push docker image (push) Successful in 4m20s
Replace the Python/kaya server in server/ with a Rust Cargo workspace of
three crates mirroring the old layout: tavolo-platform (lobby API, live-game
store, websocket endpoint, deadline scheduler, stats/Elo, OIDC sessions),
tavolo-scopone (pure scopone scientifico rules plus the platform adapter)
and tavolo-app (composition root and the `tavolo` binary with
serve/migrate/backfill-elo).

The HTTP and WebSocket contracts, error messages, Redis key layout and
Postgres schema are unchanged, so the existing web/ frontend and in-flight
games keep working. Notable differences: sessions are JSON under
tavolo:session: (Python pickle sessions invalidate once), LOGGING_CONFIG is
ignored in favour of RUST_LOG, DATABASE_URL must be Postgres, sqlx migrations
replace aerich, and OpenTelemetry exports Rust process metrics instead of
CPython gc metrics (no Postgres/Redis client instrumentation).

Docker Compose and the Kubernetes manifest now run the single Rust image
(embedded migrations, /static and /assets served by the app); the release CI
keeps building server/Dockerfile. The full test suite (scopone rules,
platform store/deadlines/stats/websockets, OIDC login flow, scopone e2e) is
ported and runs with cargo test --workspace; Redis/Postgres integration tests
are gated on TEST_REDIS_URL/TEST_DATABASE_URL.
2026-10-02 13:54:55 +00:00
woggioni adc3d340b1 Upgrade kaya to 0.0.7 2026-10-01 15:39:32 +00:00
woggioni 915a294dc8 Support multiple OIDC providers
kaya-oidc 0.0.6 can run several providers in one app (OIDCProviders),
each with its own login route and namespaced session state. Adopt it:
providers are configured in indexed, contiguous OIDC_<n>_* blocks
(0-based) with OIDC_<n>_PROVIDER naming the provider in its login URL
and session keys. The old unindexed OIDC_ISSUER/OIDC_CLIENT_ID/... names
are no longer read; startup fails with a rename hint when they are set.
With no OIDC variables at all, the single dev mock provider named
"default" is used, as before.

The platform now authenticates through OIDCProviders: auth.current_user
returns the session's user (sessions stay single-identity, so at most one
provider is logged in) and require_auth/route handlers use it, while the
websocket path keeps scanning for any provider's user. New routes expose
the configuration to the SPA and keep old links working: GET
/api/auth/providers lists the login URLs, GET /auth/login redirects to
the first provider and GET /auth/logout to whichever provider the
session is logged in with. The lobby renders one login button per
provider from that endpoint.

Update the env examples, compose and k8s manifests to the indexed scheme,
document the multiple-provider setup and the migration, and cover it with
config, auth and route tests.
2026-10-01 12:38:29 +00:00
woggioni 3abbbc6b68 Upgrade kaya to 0.0.6
kaya-oidc 0.0.6 namespaces its session state per provider as
oidc:{provider}:user (the default provider is "default", so the key is
oidc:default:user). The unprefixed oidc_user key is no longer written:
left unadapted, every websocket upgrade would be rejected with 401 and
traced requests would silently lose their user.id attribute. Adopt it
with a session_user helper on tavolo.platform.auth that finds any
provider's user, used by both get_ws_user and telemetry's
session_tag_hook. Websocket and route handlers keep their signatures.

kaya-otel 0.0.6 only enriches websocket spans with standard URL, network
and peer attributes (plus error.type), which needs no application change.

Regenerate the lock: the eight kaya pins move to 0.0.6 and, the wheels'
declared metadata being unchanged, nothing else moves. tavolo-platform
now floors kaya-core>=0.0.6 (it reads the new session layout) and
tavolo-app floors kaya-otel>=0.0.6.

As kaya documents, the session-key move signs existing logged-in users
out once; they simply log in again.
2026-10-01 01:18:05 +00:00
woggioni b43e074728 Wake deadline consumers on enqueue instead of polling every second 2026-09-29 23:39:42 +00:00
woggioni e344ce2e30 Sort the player's hand by rank then suit 2026-09-29 16:57:48 +00:00
woggioni e9909cb317 Upgrade kaya to 0.0.5
kaya 0.0.5 surfaces a vanished websocket peer as
kaya.core.WebSocketDisconnect instead of leaking granian's
RSGIProtocolClosed: receive() reports a close message so iteration ends
normally, sends raise the new exception, and KayaApp.handle_websocket
swallows it as normal termination. Adopt it: drop the granian-specific
workaround from the platform websocket handler, catch
WebSocketDisconnect in the send helper, and model the new semantics in
the abrupt-disconnect test (with coverage that a send on a vanished
peer is ignored).

Regenerate the lock: the eight kaya pins move to 0.0.5 (declared
metadata is unchanged) and pip-compile also bumps PyJWT to 2.15.1, a
security release with no breaking changes for kaya-oidc's jwt.decode
usage. tavolo-platform now floors kaya-core>=0.0.5 (it imports
WebSocketDisconnect) and tavolo-app floors kaya-otel>=0.0.5.
2026-09-29 15:43:19 +00:00
woggioni 2910fc4600 Refuse websocket upgrades with HTTP statuses, not close codes
A close() before accept() carries the HTTP status of the refused
upgrade, not a WebSocket close code: RSGI (granian) uses the value
verbatim as the response status, so 4401 panicked on the invalid status
(InvalidStatusCode) and — granian being built with panic = "abort" —
aborted the worker and took the whole app down. ASGI servers answer 403
regardless, and browsers report a refused handshake as an abnormal close
(1006), so the 4401/4403/4404 codes never reached clients anyway.

Reject 401/404/403 to mirror GET /api/games/{id}, and classify a failed
first connection in the game page through the REST API so players still
see "session expired" / "game no longer available" instead of a retry
loop. Add a regression test asserting every pre-accept rejection uses a
status in the HTTP range.
2026-09-29 22:31:41 +08:00
woggioni 21911caf9e Poll deadlines with ZRANGE BYSCORE instead of the deprecated ZRANGEBYSCORE 2026-09-28 05:50:54 +08:00
woggioni 08f158ef28 Fetch match-history participants in one query per page
CI / Build and push docker image (push) Successful in 53s
2026-09-28 05:21:34 +08:00
woggioni 834f1f26a9 Tag traced requests with the session id and OIDC sub 2026-09-28 05:11:22 +08:00
woggioni bcd1371019 Cap urllib3.connectionpool at INFO in the default logging config
The root logger runs at DEBUG, so urllib3.connectionpool emitted a
record per pooled HTTP request. Keep its INFO+ output while dropping
the per-connection chatter.
2026-09-28 04:38:28 +08:00
woggioni 3cf7e2e257 Show match finish times in the browser timezone
CI / Build and push docker image (push) Successful in 2m18s
The history table truncated finished_at, which the API serializes in
UTC, so the Finished column always showed UTC wall time. Parse the
timestamp with the browser's Date and render its local components,
keeping the existing YYYY-MM-DD HH:MM layout.
2026-09-28 04:26:07 +08:00
woggioni 2bdf247cb3 Restore cursor value types before keyset filtering
Cursors are JSON, so a datetime sort key reached the next request as a
string. Tortoise's DatetimeField.to_db_value does not parse strings, so
asyncpg rejected the string where a timestamptz parameter was expected;
sqlite tolerated it, hiding the bug in tests. Coerce decoded cursor
values through their model field's to_python_value before building the
keyset filter.
2026-09-28 04:26:00 +08:00
woggioni a3e8ff3206 Trace Postgres and Redis queries with the contrib instrumentations
CI / Build and push docker image (push) Successful in 2m42s
Builds the TracerProvider in tavolo.telemetry (kaya-otel's is private) and
injects it into kaya-otel, then instruments asyncpg and redis through
opentelemetry-instrumentation-asyncpg/redis. Redis command values are
redacted and asyncpg spans carry the parameterized SQL only.
2026-09-28 03:38:59 +08:00
woggioni 98d4485375 Export app, process and log telemetry when OTEL_ENABLED is set
Adds tavolo.active_users and tavolo.ongoing_games gauges, process/CPython
metrics via opentelemetry-instrumentation-system-metrics and OTLP log
export. The game store tracks ongoing matches (Redis sorted set, in-memory
set) and the session stores expose live counts for the gauges; a poller
refreshes them since metric callbacks cannot await the stores.
2026-09-28 03:38:59 +08:00
135 changed files with 17155 additions and 9554 deletions
+1 -1
View File
@@ -14,6 +14,6 @@
.pytest_cache/
web/target/
web/dist/
server/tests/
server/target/
docker-compose.yml
opencode.json
+20 -14
View File
@@ -3,11 +3,11 @@
A platform for multiplayer card games. The first game is **scopone
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.
- **`server/`** — backend: Rust + [Axum](https://github.com/tokio-rs/axum),
OIDC login, live games in Redis, match statistics in Postgres. A Cargo
workspace of three crates: the game-independent `tavolo-platform`, the
scopone game `tavolo-scopone`, and the `tavolo-app` composition root
(the `tavolo` binary: `serve`, `migrate`, `backfill-elo`).
See [server/README.md](server/README.md).
- **`web/`** — frontend: Rust + [Sycamore](https://sycamore.dev) compiled to
WebAssembly, built with [Trunk](https://trunkrs.dev) and styled with
@@ -58,14 +58,16 @@ Backend (from `server/`):
```sh
cd server
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e './packages/tavolo-app[dev]'
.venv/bin/python -m unittest discover -s tests -t .
.venv/bin/python -m unittest discover -s packages/tavolo-platform/tests
.venv/bin/python -m unittest discover -s packages/tavolo-scopone/tests
cargo test --workspace # unit + integration suites
cargo run -p tavolo-app # serves on :8080 (Postgres required)
cargo run -p tavolo-app -- migrate # apply the embedded sqlx migrations
```
The Redis- and Postgres-backed tests run when `TEST_REDIS_URL` /
`TEST_DATABASE_URL` point at throwaway services (e.g. from
`docker compose up postgres redis` plus the port mappings) and are skipped
otherwise.
Frontend (from `web/`), with the backend running on :8080:
```sh
@@ -78,9 +80,13 @@ Trunk proxies `/api`, `/auth` and `/ws` to `127.0.0.1:8080` (see
run the backend with:
```sh
OIDC_POST_LOGIN_REDIRECT=http://localhost:8000/ \
OIDC_POST_LOGOUT_REDIRECT=http://localhost:8000/ \
.venv/bin/granian --host 127.0.0.1 --port 8080 tavolo.app:app
OIDC_0_PROVIDER=default \
OIDC_0_ISSUER=http://localhost:8180/tavolo \
OIDC_0_CLIENT_ID=tavolo \
OIDC_0_REDIRECT_URI=http://localhost:8080/auth/default/callback \
OIDC_0_POST_LOGIN_REDIRECT=http://localhost:8000/ \
OIDC_0_POST_LOGOUT_REDIRECT=http://localhost:8000/ \
cargo run -p tavolo-app
```
Card images are committed under `web/assets/cards/`; `web/fetch-cards.sh`
+31 -20
View File
@@ -13,15 +13,16 @@
#
# Reachability notes:
# - The OIDC issuer URL must resolve from the browser AND from the pods
# (kaya does discovery lazily on the first login, so startup succeeds
# even when the issuer is unreachable, but logins then fail). If the
# provider is internal-only, you need split DNS or a public issuer URL.
# (the server discovers the provider lazily on the first login, so
# startup succeeds even when the issuer is unreachable, but logins then
# fail). If the provider is internal-only, you need split DNS or a public
# issuer URL.
# - The Postgres NetworkPolicy (if any) must allow ingress from the
# tavolo namespace.
# - This file deploys a ClusterIP Service only. Until you add an Ingress,
# reach the app with:
# kubectl port-forward -n tavolo svc/tavolo 8080:80
# and set OIDC_REDIRECT_URI to match whatever URL the browser uses.
# and set OIDC_0_REDIRECT_URI to match whatever URL the browser uses.
---
apiVersion: v1
@@ -61,7 +62,8 @@ 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 (Starlette-compatible middleware). 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 "*"
@@ -71,22 +73,32 @@ data:
# 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. The image bundles the otel extra.
# OpenTelemetry (compiled into the image): traces (HTTP/WebSocket),
# process + tavolo metrics and logs via OTLP/HTTP, disabled unless
# OTEL_ENABLED is set.
# 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: /
OIDC_POST_LOGOUT_REDIRECT: /
# OTEL_METRIC_EXPORT_INTERVAL_MILLIS: "60000"
# OTEL_METRICS_POLL_INTERVAL_SECONDS: "15"
# OIDC (the provider lives in another namespace). Each provider is an
# indexed block: OIDC_<n>_PROVIDER names it in the login URL
# (/auth/<provider>/login) and indices must be contiguous from 0. Add
# OIDC_1_*, OIDC_2_*, ... blocks for more providers; add their secrets
# to tavolo-secrets as well.
OIDC_0_PROVIDER: default
OIDC_0_CLIENT_ID: tavolo
OIDC_0_POST_LOGIN_REDIRECT: /
OIDC_0_POST_LOGOUT_REDIRECT: /
# Issuer URL as seen by the browser AND the pods (see notes above).
OIDC_ISSUER: REPLACE_ME
OIDC_0_ISSUER: REPLACE_ME
# Public callback URL of this deployment, e.g.
# http://<host>:<port>/auth/callback (must be allowed at the provider).
OIDC_REDIRECT_URI: REPLACE_ME
# http://<host>:<port>/auth/default/callback (must be allowed at the
# provider). When unset it defaults to
# http://localhost:8080/auth/<provider>/callback.
OIDC_0_REDIRECT_URI: REPLACE_ME
---
apiVersion: v1
@@ -103,8 +115,8 @@ stringData:
# The only Postgres secret: the password for DATABASE_USER at
# DATABASE_HOST (both configured in the tavolo-config ConfigMap).
DATABASE_PASSWORD: REPLACE_ME
# Client secret for OIDC_CLIENT_ID at the provider.
OIDC_CLIENT_SECRET: REPLACE_ME
# Client secret for OIDC_0_CLIENT_ID at the provider.
OIDC_0_CLIENT_SECRET: REPLACE_ME
---
# Redis: sessions + live game state. Ephemeral by design (emptyDir): worst
@@ -190,7 +202,7 @@ spec:
---
# The app (backend + compiled SPA + WebSocket endpoint, one image).
# The "migrate" initContainer runs `aerich upgrade` before the app starts,
# The "migrate" initContainer runs `tavolo migrate` before the app starts,
# so this single file can be applied safely in any order. Migrations are
# idempotent, but if you ever scale above 1 replica, move this to a Job to
# avoid concurrent upgrades.
@@ -223,9 +235,8 @@ spec:
initContainers:
- name: migrate
image: gitea.woggioni.net/woggioni/tavolo:latest
# aerich reads [tool.aerich] from pyproject.toml in /app.
command: ["aerich", "upgrade"]
workingDir: /app
# sqlx migrations are embedded in the binary.
command: ["tavolo", "migrate"]
envFrom:
# Migrations need the non-secret DATABASE_* parts too.
- configMapRef:
+15 -15
View File
@@ -61,16 +61,14 @@ services:
timeout: 3s
retries: 10
# One-shot: applies pending aerich migrations before the app starts
# (build cache makes the second build of the same Dockerfile instant).
# One-shot: applies pending sqlx migrations before the app starts (the
# migrations are embedded in the image; build cache makes the second build
# of the same Dockerfile instant).
db-migrate:
build:
context: .
dockerfile: server/Dockerfile
# aerich reads [tool.aerich] from /app/pyproject.toml (default config
# file) and finds migrations in ./migrations relative to working_dir.
working_dir: /app
command: ["aerich", "upgrade"]
command: ["tavolo", "migrate"]
environment:
DATABASE_ENGINE: postgres
DATABASE_HOST: postgres
@@ -103,13 +101,15 @@ services:
DATABASE_USER: tavolo
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-password}
# By default the app and browsers reach the mock IdP under the same
# name (see README /etc/hosts note); override OIDC_ISSUER and
# OIDC_REDIRECT_URI to use a real provider or a different host port.
OIDC_ISSUER: ${OIDC_ISSUER:-http://mockoauth:8180/tavolo}
# name (see README /etc/hosts note); override the OIDC_0_* values to
# use a real provider or a different host port. Additional providers
# take OIDC_1_*, OIDC_2_*, ... blocks (indices are contiguous from 0).
OIDC_0_PROVIDER: ${OIDC_0_PROVIDER:-default}
OIDC_0_ISSUER: ${OIDC_0_ISSUER:-http://mockoauth:8180/tavolo}
# The mock OIDC server does not validate clients: any id/secret works.
OIDC_CLIENT_ID: tavolo
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-dev-secret}
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:8080/auth/callback}
OIDC_0_CLIENT_ID: tavolo
OIDC_0_CLIENT_SECRET: ${OIDC_0_CLIENT_SECRET:-dev-secret}
OIDC_0_REDIRECT_URI: ${OIDC_0_REDIRECT_URI:-http://localhost:8080/auth/default/callback}
REDIS_URL: redis://redis:6379/0
HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30}
TURN_TIMEOUT_SECONDS: ${TURN_TIMEOUT_SECONDS:-30}
@@ -122,9 +122,9 @@ services:
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.
# The image bundles the otel extra (see packages/tavolo-app/pyproject.toml
# and server/requirements.txt).
# OpenTelemetry (compiled into the image): traces (HTTP/WebSocket),
# metrics (process + tavolo gauges) and logs via OTLP/HTTP, disabled
# unless OTEL_ENABLED is set.
OTEL_ENABLED: ${OTEL_ENABLED:-}
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
+44 -10
View File
@@ -9,16 +9,33 @@ DATABASE_NAME=tavolo
DATABASE_USER=tavolo
DATABASE_PASSWORD=password
DATABASE_OPTIONS=
# Full-DSN override: when set, the parts above are ignored. Used by the
# test suite (sqlite://:memory:) and handy for managed-DB URLs.
# Full-DSN override: when set, the parts above are ignored (managed-DB
# URLs). The Rust server supports Postgres only.
#DATABASE_URL=postgres://tavolo:password@localhost:5432/tavolo
# OIDC (mock-oauth2-server in dev; it does not validate clients, so any
# client id/secret works. For a real IdP like Keycloak, use its values here.)
OIDC_ISSUER=http://localhost:8180/tavolo
OIDC_CLIENT_ID=tavolo
OIDC_CLIENT_SECRET=dev-secret
OIDC_REDIRECT_URI=http://localhost:8080/auth/callback
# OIDC providers. Each provider is an indexed block: OIDC_<n>_PROVIDER
# (required; names the provider in the login URL /auth/<provider>/login and
# in its session state), OIDC_<n>_ISSUER, OIDC_<n>_CLIENT_ID,
# OIDC_<n>_CLIENT_SECRET (optional: leave unset for public clients),
# OIDC_<n>_REDIRECT_URI (defaults to
# http://localhost:8080/auth/<provider>/callback), OIDC_<n>_POST_LOGIN_REDIRECT
# and OIDC_<n>_POST_LOGOUT_REDIRECT (default /). Indices must be contiguous
# from 0; add a second provider with an OIDC_1_* block (and so on).
#
# The values below target the dev mock-oauth2-server (it does not validate
# clients, so any client id/secret works). For a real IdP like Keycloak,
# use its values and register the redirect URI there.
OIDC_0_PROVIDER=default
OIDC_0_ISSUER=http://localhost:8180/tavolo
OIDC_0_CLIENT_ID=tavolo
OIDC_0_CLIENT_SECRET=dev-secret
OIDC_0_REDIRECT_URI=http://localhost:8080/auth/default/callback
# A second provider, e.g. Google:
#OIDC_1_PROVIDER=google
#OIDC_1_ISSUER=https://accounts.google.com
#OIDC_1_CLIENT_ID=...
#OIDC_1_CLIENT_SECRET=...
#OIDC_1_REDIRECT_URI=http://localhost:8080/auth/google/callback
# Redis (session and live-game storage). Unset to fall back to in-memory
# stores (only sensible for local development with a single worker).
@@ -35,9 +52,10 @@ HAND_ACK_TIMEOUT_SECONDS=30
# for them (covers disconnects and idle players).
TURN_TIMEOUT_SECONDS=30
# Path to a YAML logging configuration file (logging.config.dictConfig
# schema). Unset logs DEBUG to the console.
# Accepted for compatibility with the Python server but ignored: the Rust
# server configures logging through RUST_LOG (tracing env filter syntax).
#LOGGING_CONFIG=/path/to/logging.yaml
#RUST_LOG=tavolo=info,sqlx=warn,redis=warn
# CORS (via kaya-cors' CorsMixin; same semantics as Starlette's
# CORSMiddleware). Disabled unless CORS_ALLOW_ORIGINS or
@@ -61,6 +79,22 @@ TURN_TIMEOUT_SECONDS=30
# Seconds browsers may cache the preflight response (default 600):
#CORS_MAX_AGE=600
# OpenTelemetry (compiled in by default). Off unless OTEL_ENABLED is
# truthy; exports traces (HTTP/WebSocket) and process metrics, plus custom
# tavolo.active_users / tavolo.ongoing_games gauges and logs over OTLP/HTTP.
# The endpoint defaults to the standard OTEL_EXPORTER_OTLP_ENDPOINT
# (localhost:4318) when unset.
#OTEL_ENABLED=true
#OTEL_SERVICE_NAME=tavolo
#OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
#OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer token,X-Tenant=one
# Paths excluded from tracing and metrics (default /api/health).
#OTEL_EXCLUDED_PATHS=/api/health
# Metric export interval (ms, default 60000) and session/game count poll
# interval (seconds, default 15).
#OTEL_METRIC_EXPORT_INTERVAL_MILLIS=60000
#OTEL_METRICS_POLL_INTERVAL_SECONDS=15
# App server
APP_HOST=0.0.0.0
APP_PORT=8080
+3534
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
[workspace]
resolver = "2"
members = [
"crates/tavolo-platform",
"crates/tavolo-scopone",
"crates/tavolo-app",
]
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "MIT"
repository = "https://gitea.woggioni.net/woggioni/tavolo"
description = "Multiplayer card-game platform backend"
[workspace.dependencies]
async-trait = "0.1"
axum = { version = "0.8", features = ["ws", "macros"] }
base64 = "0.22"
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4", features = ["derive", "env"] }
futures-util = "0.3"
hex = "0.4"
jsonwebtoken = "9"
percent-encoding = "2"
rand = "0.8"
redis = { version = "0.32", features = ["tokio-comp", "connection-manager", "script"] }
regex = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
sha2 = "0.10"
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "chrono", "uuid", "json", "migrate", "macros"] }
thiserror = "2"
tokio = { version = "1", features = ["full"] }
tower = { version = "0.5", features = ["util"] }
tower-http = { version = "0.6", features = ["cors", "fs", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
url = "2"
uuid = { version = "1", features = ["v4", "serde"] }
[profile.release]
lto = "thin"
codegen-units = 1
+47 -66
View File
@@ -1,24 +1,28 @@
# syntax=docker/dockerfile:1
# Multi-stage build for the tavolo stack (kaya backend + Sycamore/WASM
# frontend). The Docker build context is the REPOSITORY ROOT (see
# docker-compose.yml) so this single image assembles both parts:
# Multi-stage build for the tavolo stack (Rust/Axum backend +
# Sycamore/WASM frontend). The Docker build context is the REPOSITORY ROOT
# (see docker-compose.yml) so this single image assembles both parts:
#
# web-builder rust + trunk -> compiles web/ into web/dist
# builder alpine python -> python venv with the backend + deps
# runtime alpine python + the venv + the compiled frontend
# web-builder rust + trunk -> compiles web/ into web/dist
# rust-builder rust -> compiles server/ into the tavolo binary
# runtime debian slim -> the binary + the compiled frontend
#
# apk and pip both use BuildKit cache mounts (type=cache): the package
# caches persist in the builder's cache across builds instead of being
# re-downloaded, and never land in the image layers.
# The backend embeds its sqlx migrations, serves /static and /assets itself
# and hosts the SPA shell, so the runtime image needs no Python, Granian or
# migration files.
#
# Build and run it with (from the repository root):
# docker compose up --build
# --- Web builder -------------------------------------------------------------
FROM rust:1-slim AS web-builder
FROM rust:1-slim-bookworm AS web-builder
ARG TRUNK_VERSION=0.21.14
# The build environment blocks plain HTTP: force the apt mirrors to HTTPS.
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources \
{ sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list 2>/dev/null || true; } \
&& { sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || true; } \
&& apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
@@ -36,79 +40,56 @@ COPY web/assets ./assets
COPY web/vendor ./vendor
COPY web/src ./src
# --public-url makes trunk emit asset URLs under /static, the prefix Granian
# serves in the runtime image (see GRANIAN_STATIC_PATH_* below). Dev builds
# (trunk serve) keep the default "/" public URL.
# --public-url makes trunk emit asset URLs under /static, the prefix the
# server mounts in the runtime image. Dev builds (trunk serve) keep the
# default "/" public URL.
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/web/target \
trunk build --release --public-url /static/
# --- Python builder ----------------------------------------------------------
FROM alpine:3.24 AS builder
RUN --mount=type=cache,target=/var/cache/apk \
apk add python3 py3-pip build-base python3-dev
# --- Rust builder ------------------------------------------------------------
FROM rust:1-slim-bookworm AS rust-builder
WORKDIR /build
COPY server/Cargo.toml server/Cargo.lock ./
COPY server/crates ./crates
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
# The target directory lives in a cache mount (so repeated builds reuse it)
# and the binary is copied out of the mount into the image layer.
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/build/target \
cargo build --release --locked \
&& cp target/release/tavolo /tavolo
# The three sibling distributions, installed from the local tree (PEP 420
# namespace packages sharing the `tavolo` namespace). --no-deps: every
# third-party dependency — including the tavolo-app `otel` extra — 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 ./packages/tavolo-app
# server/pyproject.toml only carries the [tool.aerich] section (the
# db-migrate compose service runs `aerich upgrade` with working_dir=/app,
# next to ./migrations).
COPY server/pyproject.toml ./
# 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 debian:bookworm-slim
# --- Runtime ---------------------------------------------------------------
FROM alpine:3.24
# The slim base ships no CA store; seed it from the builder so the HTTPS apt
# mirrors (and later, outbound TLS) can be verified.
COPY --from=web-builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
RUN --mount=type=cache,target=/var/cache/apk \
apk add python3 ca-certificates tzdata \
&& addgroup -S app && adduser -S -G app app
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
{ sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list 2>/dev/null || true; } \
&& { sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || true; } \
&& apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl tzdata \
&& rm -rf /var/lib/apt/lists/* \
&& addgroup --system app && adduser --system --ingroup app app
COPY --from=builder /opt/venv /opt/venv
COPY --from=builder /build/migrations /app/migrations
# aerich reads [tool.aerich] from pyproject.toml (its default config file);
# the db-migrate compose service runs `aerich upgrade` with working_dir=/app.
COPY --from=builder /build/pyproject.toml /app/pyproject.toml
# The compiled single-page application. Granian serves the assets directly
# in Rust — hashed js/wasm/css under /static/* and the card images under
# /assets/* (see the GRANIAN_STATIC_PATH_* env vars below; click splits
# multi-value env vars on whitespace for routes and ':' for paths). The
# Python app only serves the SPA shell (index.html) at / and for
# client-side routes (STATIC_DIR).
COPY --from=rust-builder /tavolo /usr/local/bin/tavolo
# The compiled single-page application: hashed js/wasm/css under /static and
# the card images under /assets (mounted by the server from STATIC_DIR).
COPY --from=web-builder /web/dist /app/web/dist
ENV PATH="/opt/venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
GRANIAN_HOST=0.0.0.0 \
GRANIAN_PORT=8080 \
GRANIAN_INTERFACE=rsgi \
GRANIAN_STATIC_PATH_ROUTE="/static /assets" \
GRANIAN_STATIC_PATH_MOUNT="/app/web/dist:/app/web/dist/assets" \
GRANIAN_STATIC_PATH_DIR_TO_FILE=index.html \
STATIC_DIR=/app/web/dist
ENV STATIC_DIR=/app/web/dist
USER app
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD wget -q -O- http://127.0.0.1:8080/api/health || exit 1
CMD curl -fsS http://127.0.0.1:8080/api/health || exit 1
CMD ["granian", "tavolo.app:app"]
CMD ["tavolo", "serve"]
+256 -188
View File
@@ -1,26 +1,25 @@
# tavolo (backend)
The backend for a multiplayer card-game platform, built on the
[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)):
The backend for a multiplayer card-game platform, written in Rust with
[Axum](https://github.com/tokio-rs/axum). It is a Cargo workspace of three
crates mirroring the original Python layout:
- **`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`** (`packages/tavolo-app/`) — the
composition root wiring the platform to the scopone game, with the
environment-driven settings and the SPA shell.
- **`crates/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.
- **`crates/tavolo-scopone/`** — scopone scientifico, the four-player,
fixed-partnership variant of the classic Italian card game: pure rules plus
the platform adapter.
- **`crates/tavolo-app/`** — the composition root: environment-driven
settings, OIDC login, sessions, CORS, OpenTelemetry, SPA hosting and the
`tavolo` binary (`serve` / `migrate` / `backfill-elo`).
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.
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. The HTTP/WebSocket
contract is byte-compatible with the SPA in `../web/`.
## Quick start (Docker Compose)
@@ -31,10 +30,10 @@ docker compose up --build
The stack starts Postgres, Redis, a mock OIDC provider
(navikt/mock-oauth2-server) with four ready-made players
(`alice`, `bob`, `carol`, `dave`), a one-shot database migration service,
and the app itself on `http://127.0.0.1:8080`. The same container also
serves the Sycamore/WASM frontend (built from `../web/` by the Docker
image), so the UI is available at that address.
(`alice`, `bob`, `carol`, `dave`), a one-shot database migration service
(`tavolo migrate`) and the app itself on `http://127.0.0.1:8080`. The same
container also serves the Sycamore/WASM frontend (built from `../web/` by the
Docker image), so the UI is available at that address.
Because the browser and the app both talk to the OIDC issuer at
`http://mockoauth:8180/tavolo`, add a host entry once:
@@ -43,7 +42,8 @@ Because the browser and the app both talk to the OIDC issuer at
echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
```
Then log in: `GET http://localhost:8080/auth/login`, type any username
Then log in: `GET http://localhost:8080/auth/login` (a provider-agnostic
alias that redirects to the first configured provider), type any username
(e.g. `alice`) in the interactive login, and you are redirected back with a
session cookie. That cookie authenticates both the REST API and the
WebSocket endpoint.
@@ -56,24 +56,28 @@ All configuration comes from environment variables (see `.env.example`):
| Variable | Default | Description |
|---|---|---|
| `DATABASE_ENGINE` | `postgres` | Database DSN scheme/driver |
| `DATABASE_ENGINE` | `postgres` | Database DSN scheme/driver (Postgres only) |
| `DATABASE_HOST` | `localhost` | Postgres host |
| `DATABASE_PORT` | unset | Postgres port; omitted from the DSN when empty (driver default, 5432 for Postgres) |
| `DATABASE_PORT` | unset | Postgres port; omitted from the DSN when empty (driver default, 5432) |
| `DATABASE_NAME` | `tavolo` | Postgres database name |
| `DATABASE_USER` | `tavolo` | Postgres user |
| `DATABASE_PASSWORD` | `password` | Postgres password |
| `DATABASE_OPTIONS` | unset | Extra DSN query parameters, e.g. `ssl=require` |
| `DATABASE_URL` | unset | Full-DSN override; when set, the `DATABASE_*` parts above are ignored (used for sqlite in tests and for managed-DB URLs) |
| `DATABASE_URL` | unset | Full-DSN override; when set, the `DATABASE_*` parts above are ignored (managed-DB URLs) |
| `REDIS_URL` | unset | Redis DSN for sessions + live games. Unset falls back to in-memory stores |
| `OIDC_ISSUER` | `http://localhost:8180/tavolo` | OIDC issuer URL |
| `OIDC_CLIENT_ID` | `tavolo` | OIDC client id |
| `OIDC_CLIENT_SECRET` | unset | OIDC client secret |
| `OIDC_REDIRECT_URI` | `http://localhost:8080/auth/callback` | Login callback URL |
| `OIDC_<n>_PROVIDER` | `default` ¹ | Provider name (required when OIDC is configured): used in the login URL `/auth/<provider>/login` and to namespace its session keys. Unique, `[A-Za-z0-9_-]+` |
| `OIDC_<n>_ISSUER` | `http://localhost:8180/tavolo` ¹ | OIDC issuer URL |
| `OIDC_<n>_CLIENT_ID` | `tavolo` ¹ | OIDC client id |
| `OIDC_<n>_CLIENT_SECRET` | unset | OIDC client secret; leave unset for public clients |
| `OIDC_<n>_REDIRECT_URI` | `http://localhost:8080/auth/<provider>/callback` | Login callback URL; must be registered at the provider |
| `OIDC_<n>_POST_LOGIN_REDIRECT` | `/` | Where the browser is sent after login |
| `OIDC_<n>_POST_LOGOUT_REDIRECT` | `/` | Where the browser is sent after logout |
| `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis |
| `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 |
| `DEADLINE_HEARTBEAT_MS` | `30000` | Backstop poll interval of the deadline consumer; enqueued deadlines are announced over pub/sub and wake consumers immediately, so this only bounds recovery from lost notifications or a dead worker |
| `LOGGING_CONFIG` | unset | Accepted for compatibility; **ignored** (the Rust server logs via `RUST_LOG`) |
| `RUST_LOG` | `debug` (app), INFO+ deps | tracing-subscriber env filter, e.g. `tavolo=info,sqlx=warn` |
| `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 |
@@ -81,47 +85,81 @@ All configuration comes from environment variables (see `.env.example`):
| `CORS_ALLOW_CREDENTIALS` | `false` | `1`/`true`/`yes`/`on` allow cookies/credentials on cross-origin requests |
| `CORS_EXPOSE_HEADERS` | unset | Comma-separated response headers exposed to the browser |
| `CORS_MAX_AGE` | `600` | Seconds browsers may cache the preflight response |
| `OTEL_ENABLED` | `false` | `1`/`true`/`yes`/`on` enable OpenTelemetry traces and metrics (the Docker image bundles the `otel` extra; non-image installs need `pip install tavolo-app[otel]`) |
| `OTEL_ENABLED` | `false` | `1`/`true`/`yes`/`on` enable OpenTelemetry traces, metrics and logs (compiled in by default) |
| `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) |
| `OTEL_METRIC_EXPORT_INTERVAL_MILLIS` | `60000` | How often the OTLP metric reader exports |
| `OTEL_METRICS_POLL_INTERVAL_SECONDS` | `15` | How often the session/game count gauges are refreshed |
| `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address |
| `STATIC_DIR` | `web/dist` | Directory holding the compiled frontend (the Docker image sets `/app/web/dist`) |
¹ When no `OIDC_<n>_*` variable is set at all, a single provider named
`default` pointing at the dev mock IdP is used. Indices must be contiguous
from 0 (`OIDC_0_*`, `OIDC_1_*`, ...).
### Multiple OIDC providers
Providers are configured in indexed blocks; each becomes its own login route
`/auth/<provider>/login` with its own callback URL and session namespace:
```sh
OIDC_0_PROVIDER=google
OIDC_0_ISSUER=https://accounts.google.com
OIDC_0_CLIENT_ID=...
OIDC_0_CLIENT_SECRET=...
OIDC_0_REDIRECT_URI=https://tavolo.example.com/auth/google/callback
OIDC_1_PROVIDER=github
OIDC_1_ISSUER=https://github.com/login/oauth
OIDC_1_CLIENT_ID=...
OIDC_1_CLIENT_SECRET=...
OIDC_1_REDIRECT_URI=https://tavolo.example.com/auth/github/callback
```
The login page reads `GET /api/auth/providers` and shows one button per
provider. `GET /auth/login` redirects to the first provider (useful for
bookmarks and scripts), and `GET /auth/logout` signs out whichever provider
the session is logged in with. Sessions hold a single identity: logging in
with another provider replaces the previous one.
## Logging
The application logs through the Python stdlib `logging` module, one
`getLogger(__name__)` per module: lifecycle and business events at INFO
(game created/joined, websocket connections, match results, auto-plays),
per-move and store detail at DEBUG.
The Rust server logs through `tracing`, configured with `RUST_LOG`
(`tracing-subscriber` env-filter syntax). The default filter is `debug` for
the application with noisy dependencies capped (`sqlx`, `redis`, `hyper`,
`reqwest` at WARN). `LOGGING_CONFIG` (a Python `logging.config.dictConfig`
YAML path) is accepted so existing deployments do not break, but its value is
ignored with a warning.
By default everything at DEBUG level goes to the console. Set
`LOGGING_CONFIG` to the path of a YAML file to take over the
configuration; the file follows the
[`logging.config.dictConfig`](https://docs.python.org/3/library/logging.config.html#logging-config-dictschema)
schema. Keep `disable_existing_loggers: false` — Granian configures its own
loggers before importing the app, and disabling them would silence the
server. Example for quieter production logs (WARNING for third parties,
INFO for the application):
Lifecycle and business events are logged at INFO (game created/joined,
websocket connections, match results, auto-plays); per-move and store detail
at DEBUG.
```yaml
version: 1
disable_existing_loggers: false
formatters:
default:
format: "{asctime} [{levelname}] ({processName:s}/{threadName:s}) - {name} - {message}"
style: "{"
handlers:
console:
class: logging.StreamHandler
formatter: default
root:
level: WARNING
handlers: [console]
loggers:
tavolo:
level: INFO
```
## OpenTelemetry
With `OTEL_ENABLED` set, the app exports over OTLP/HTTP:
- **Traces**: one span per HTTP request and one per WebSocket connection,
tagged with the request's session and user: `session.id` is a truncated
SHA-256 of the session id (the cookie is a credential, so it is never
exported verbatim) and `user.id` is the OIDC `sub`, present once logged in.
Database-client auto-instrumentation is not included.
- **Metrics**: the `http.server.request.duration` histogram, plus
`tavolo.active_users` (sessions in the session store),
`tavolo.ongoing_games` (live matches not finished yet), and process metrics:
`process.cpu.time`, `process.cpu.utilization`, `process.memory.usage`,
`process.memory.virtual`. The Python build's CPython gc metrics have no
Rust equivalent.
- **Logs**: tracing events forwarded to OTLP (OpenTelemetry's own records are
filtered out to avoid export loops).
The session/game gauges are polled every
`OTEL_METRICS_POLL_INTERVAL_SECONDS` (Redis-backed stores report
cluster-wide counts, in-memory stores the process's own). With multiple
replicas each process reports the same global counts: aggregate with `max`,
not `sum`.
## Data model
@@ -129,56 +167,64 @@ loggers:
- `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`).
game-specific `state` blob, which only the registered engine interprets.
Sliding TTL (`GAME_TTL_SECONDS`). The layout is identical to the original
Python implementation, so in-flight games survive the backend swap.
- `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 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).
timeouts (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).
- `tavolo:deadlines:events` — a pub/sub channel announcing newly enqueued
deadlines; it wakes every worker's consumer immediately, while the
heartbeat poll remains as a backstop for lost signals.
- `tavolo:games:ongoing` — sorted set of running games for the
`tavolo.ongoing_games` gauge.
### Postgres (statistics, via Tortoise ORM + aerich migrations)
Sessions live under `tavolo:session:<id>` as JSON (the Python server used
pickled values under `kaya:session:`; existing sessions are invalidated once
on cutover, users log in again).
- `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), 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).
### Postgres (statistics, via sqlx migrations)
- `match` — one row per finished match: the game played (`game_type`,
indexed so statistics can be 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, 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.
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 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.
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):
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, with Python-compatible
banker's rounding. Ratings update in the same transaction as the match
result. To recompute every rating from the recorded match history:
```sh
.venv/bin/python -m tavolo.platform.backfill_elo \
--database-url postgres://tavolo:tavolo@localhost:5432/tavolo
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo \
cargo run -p tavolo-app -- backfill-elo
# or, in the container image:
docker compose run --rm tavolo tavolo backfill-elo
```
## REST API
@@ -200,21 +246,23 @@ All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
## WebSocket protocol
Connect to `/ws/games/{game_id}` with your session cookie. Only seated
players are accepted; unauthenticated, unknown-game and non-player
connections are closed with codes `4401`, `4404` and `4403` respectively.
players are accepted; the upgrade is refused before the handshake completes
with an HTTP status mirroring `GET /api/games/{id}`: `401` unauthenticated,
`404` unknown game, `403` not seated. (Browsers report a refused handshake as
an abnormal close — there is no close code to read.)
Server → client messages are JSON objects with a `type`:
- `{"type": "state", "game": {...}}` — personalized game view. Your own
hand is included; other players expose only their card counts.
`your_turn: true` is present when it is your move.
- `{"type": "state", "game": {...}}` — personalized game view. Your own hand
is included; other players expose only their card counts. `your_turn: true`
is present when it is your move.
- `{"type": "game_over", "scores": {"A": 11, "B": 7}, "winner": "A"}`
- `{"type": "error", "code": "illegal_move", "message": "..."}`
Client → server messages 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:
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"]}
@@ -228,9 +276,9 @@ as its payload. For scopone scientifico:
settebello).
- `capture` lists the table cards to take. When a capture is legal it is
mandatory to provide one; when no capture exists it must be omitted.
- `ack` acknowledges the hand-end scoring summary (see below). The next
hand is dealt once all four players have acknowledged, or automatically
after `HAND_ACK_TIMEOUT_SECONDS`.
- `ack` acknowledges the hand-end scoring summary (see below). The next hand
is dealt once all four players have acknowledged, or automatically after
`HAND_ACK_TIMEOUT_SECONDS`.
- `state` asks for a fresh snapshot.
After every accepted move the new state is broadcast to all four players.
@@ -238,23 +286,22 @@ After every accepted move the new state is broadcast to all four players.
### Turn timeout
The state carries a `turn_deadline` while a hand is being played. If the
player on turn does not move before it, the server plays a random legal
card for them (picking one of the legal captures at random when a capture
is required), so a disconnected or idle player cannot stall the match. The
player on turn does not move before it, the server plays a random legal card
for them (picking one of the legal captures at random when a capture is
required), so a disconnected or idle player cannot stall the match. The
timeout is `TURN_TIMEOUT_SECONDS` (default 30); the auto-played move is
broadcast like any other. Deadlines fire from the shared `tavolo:deadlines`
queue (see above), not from timers tied to client connections, so the
match keeps progressing even with every player disconnected.
queue, not from timers tied to client connections, so the match keeps
progressing even with every player disconnected.
### Hand-end summary
When a hand finishes but the match continues, the game enters the
`hand_end` phase instead of dealing immediately: the state carries
`last_hand` (a full scoring breakdown with an `award` map naming the team
that won each category), the `acknowledged` seats and a
`hand_end_deadline`. The frontend renders this as a screen every player
must dismiss. A play attempted in this phase is rejected with an
`illegal_move` error.
that won each category), the `acknowledged` seats and a `hand_end_deadline`.
The frontend renders this as a screen every player must dismiss. A play
attempted in this phase is rejected with an `illegal_move` error.
## Rules implemented
@@ -283,83 +330,104 @@ must dismiss. A play attempted in this phase is rejected with an
## Development
Backend (from `server/`):
```sh
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e './packages/tavolo-app[dev]'
# app integration suite:
.venv/bin/python -m unittest discover -s tests -t .
# 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 (see packages/tavolo-app/README.md for the app modules):
.venv/bin/python -m mypy -p tavolo.platform
.venv/bin/python -m mypy -p tavolo.scopone
cargo test --workspace # unit + integration suites
cargo run -p tavolo-app # = tavolo serve (needs the env below)
cargo run -p tavolo-app -- migrate
```
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. The platform
suite additionally runs against a `DummyEngine` toy game, so the
game-independent machinery is covered without importing scopone.
For a local run without Docker:
```sh
DATABASE_URL=postgres://tavolo:password@localhost:5432/tavolo \
REDIS_URL=redis://localhost:6379/0 \
cargo run -p tavolo-app
```
Tests run fully in-process: in-memory stores, a `DummyEngine` toy game for
the platform suite, a mock OIDC provider for the login flow, and ephemeral
TCP servers for WebSocket coverage. The Redis- and Postgres-backed tests are
gated on `TEST_REDIS_URL` / `TEST_DATABASE_URL` and skipped when unset.
Frontend (from `web/`), with the backend running on :8080:
```sh
cd web
trunk serve # SPA on http://localhost:8000, /api /auth /ws proxied
```
For the login redirect to land back on the dev server, run the backend with:
```sh
OIDC_0_PROVIDER=default \
OIDC_0_ISSUER=http://localhost:8180/tavolo \
OIDC_0_CLIENT_ID=tavolo \
OIDC_0_REDIRECT_URI=http://localhost:8080/auth/default/callback \
OIDC_0_POST_LOGIN_REDIRECT=http://localhost:8000/ \
OIDC_0_POST_LOGOUT_REDIRECT=http://localhost:8000/ \
cargo run -p tavolo-app
```
Card images are committed under `web/assets/cards/`; `web/fetch-cards.sh`
re-downloads them if needed.
### 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
`packages/tavolo-platform/src/tavolo/platform/models.py`:
```sh
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo .venv/bin/aerich migrate
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo .venv/bin/aerich upgrade
```
(`aerich init-db` produces sqlite-flavored DDL when pointed at sqlite;
adjust `UUID`/`TIMESTAMPTZ`/`BOOL`/`JSONB` for Postgres like the existing
baseline.)
The Postgres schema is owned by embedded sqlx migrations in
`crates/tavolo-app/migrations/`, applied by `tavolo migrate` (the
`db-migrate` compose service and the Kubernetes init container). The
migration is idempotent (`CREATE TABLE IF NOT EXISTS`) and matches the
original aerich-created schema, so it can be applied to an existing database
without touching data; the `aerich` bookkeeping table is left alone. To
change the schema, add a new numbered `.sql` file next to the baseline and
run `cargo test -p tavolo-app` (the migration is embedded at compile time).
## Layout
```
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
packages/tavolo-app/ # deployable app: composition root (own pyproject)
src/tavolo/
├── 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
pyproject.toml # tooling shim: [tool.aerich] + [tool.mypy] for server/ CWD
migrations/ # aerich migrations for the platform models
tests/ # app integration suite (platform + scopone wired together)
crates/tavolo-platform/ # game-independent platform (own crate + tests)
src/
├── engine.rs # the platform<->game contract
├── registry.rs # game id -> engine lookup
├── errors.rs # GameError + platform Error
├── store.rs # Redis / in-memory live-session store (+ deadline queue)
├── deadlines.rs # connection-independent timeout scheduler
├── session.rs # HTTP sessions (in-memory / Redis) + middleware
├── oidc.rs # OIDC discovery, PKCE, JWKS validation, logout
├── auth.rs # session -> user helpers
├── stats.rs # match persistence + history/leaderboard queries + Elo
├── models.rs # sqlx row models
├── elo.rs # chess-style Elo math (1500 start, K=32)
├── pagination.rs # keyset (cursor) pagination
├── http.rs # JSON request/response helpers
├── platform.rs # the collaborators shared by all endpoints
├── routes/ # health / me / auth / games / stats / openapi
└── ws.rs # WebSocket live-play endpoint (generic envelope)
crates/tavolo-scopone/ # scopone scientifico game (own crate + tests)
src/{state,engine,plugin,errors}.rs
crates/tavolo-app/ # deployable app: composition root (own crate)
src/{main,app,config,cors,logging,static_files,telemetry}.rs
migrations/ # embedded sqlx migrations
dev/mockoauth/ # dev OIDC provider image (compose)
Dockerfile # web-builder + cargo + slim runtime
.env.example
```
## Differences from the original Python server
The Python implementation that lived in this directory was replaced by the
Rust one; it remains available in git history. Behavioral compatibility is
intentional and tested (same JSON payloads, status codes, error messages,
Redis layout and Postgres schema), with these documented differences:
| Area | Behavior |
|---|---|
| Sessions | Stored as JSON under `tavolo:session:` (Python: pickle under `kaya:session:`). Existing sessions are invalidated once on cutover; users log in again. |
| Logging | `LOGGING_CONFIG` is accepted but ignored; use `RUST_LOG`. |
| Database | Postgres only (`DATABASE_URL=sqlite://...` is rejected). |
| OpenTelemetry | Rust process metrics replace the CPython gc metrics; no Postgres/Redis client auto-instrumentation. |
| Deadline members | Compact JSON instead of Python's spaced `json.dumps`; stale duplicates are harmless (engines revalidate tokens). |
| CORS | Hand-written middleware replicating kaya-cors / Starlette `CORSMiddleware` semantics, including `400 Disallowed CORS ...` preflight rejections. |
| CLI | `tavolo migrate` / `tavolo backfill-elo` replace `aerich upgrade` / `python -m tavolo.platform.backfill_elo`. |
-16
View File
@@ -1,16 +0,0 @@
"""Shared test fixtures.
Tests run against an in-memory sqlite database (overriding ``DATABASE_URL``)
so they need no running Postgres, and with ``REDIS_URL`` unset so sessions
and live games use their in-memory stores. The environment is set before
:mod:`tavolo.app` is imported by the test modules.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "sqlite://:memory:")
os.environ.setdefault("OIDC_ISSUER", "http://localhost:8180/tavolo")
os.environ.setdefault("OIDC_CLIENT_ID", "tavolo")
os.environ.setdefault("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback")
os.environ.pop("REDIS_URL", None)
+63
View File
@@ -0,0 +1,63 @@
[package]
name = "tavolo-app"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "The deployable tavolo application: composition root and HTTP/WebSocket server"
[[bin]]
name = "tavolo"
path = "src/main.rs"
[dependencies]
async-trait.workspace = true
axum.workspace = true
base64.workspace = true
chrono.workspace = true
clap.workspace = true
futures-util.workspace = true
hex.workspace = true
jsonwebtoken.workspace = true
opentelemetry = { version = "0.33", optional = true, default-features = false, features = ["trace", "metrics", "logs"] }
opentelemetry-appender-tracing = { version = "0.33", optional = true, default-features = false }
opentelemetry-otlp = { version = "0.33", optional = true, default-features = false, features = ["http-proto", "reqwest-client", "trace", "metrics", "logs"] }
opentelemetry_sdk = { version = "0.33", optional = true, default-features = false, features = ["trace", "metrics", "logs", "rt-tokio"] }
percent-encoding.workspace = true
rand.workspace = true
redis.workspace = true
regex.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
sqlx.workspace = true
sysinfo = { version = "0.39", optional = true }
tavolo-platform = { path = "../tavolo-platform" }
tavolo-scopone = { path = "../tavolo-scopone" }
thiserror.workspace = true
tokio.workspace = true
tower.workspace = true
tower-http.workspace = true
tracing.workspace = true
tracing-opentelemetry = { version = "0.34", optional = true, default-features = false }
tracing-subscriber.workspace = true
url.workspace = true
uuid.workspace = true
[dev-dependencies]
axum = { workspace = true, features = ["ws", "macros"] }
futures-util.workspace = true
http-body-util = "0.1"
tokio-tungstenite = "0.24"
tower = { workspace = true, features = ["util"] }
[features]
default = ["otel"]
otel = [
"dep:opentelemetry",
"dep:opentelemetry_sdk",
"dep:opentelemetry-otlp",
"dep:opentelemetry-appender-tracing",
"dep:tracing-opentelemetry",
"dep:sysinfo",
]
@@ -0,0 +1,41 @@
-- Final tavolo schema, expressed idempotently so the migration can be applied
-- to a database created by the Python aerich migrations as well as to a fresh
-- one. The `aerich` bookkeeping table (when present) is left untouched.
CREATE TABLE IF NOT EXISTS "match" (
"id" UUID NOT NULL PRIMARY KEY,
"game_type" VARCHAR(32) NOT NULL,
"started_at" TIMESTAMPTZ NOT NULL,
"finished_at" TIMESTAMPTZ NOT NULL,
"result" JSONB NOT NULL DEFAULT '{}'
);
CREATE INDEX IF NOT EXISTS "idx_match_game_type" ON "match" ("game_type");
CREATE TABLE IF NOT EXISTS "match_player" (
"id" UUID NOT NULL PRIMARY KEY,
"user_sub" VARCHAR(255) NOT NULL,
"display_name" VARCHAR(200) NOT NULL,
"seat" SMALLINT NOT NULL,
"team" VARCHAR(32),
"won" BOOL NOT NULL,
"match_id" UUID NOT NULL REFERENCES "match" ("id") ON DELETE CASCADE,
"score" DOUBLE PRECISION NOT NULL DEFAULT 0,
"elo_delta" SMALLINT,
"details" JSONB NOT NULL DEFAULT '{}',
CONSTRAINT "uid_match_player_match_user" UNIQUE ("match_id", "user_sub")
);
CREATE INDEX IF NOT EXISTS "idx_match_player_user_sub" ON "match_player" ("user_sub");
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_rating_user_game" UNIQUE ("user_sub", "game_type")
);
CREATE INDEX IF NOT EXISTS "idx_player_rating_game_rating" ON "player_rating" ("game_type", "rating");
+191
View File
@@ -0,0 +1,191 @@
//! Application composition root: registry, stores, platform, router.
//!
//! Mirrors `tavolo.app` from the Python implementation:
//!
//! * the [`ScoponeEngine`] is registered with the configured timeouts;
//! * sessions and live games use Redis when `REDIS_URL` is set, in-memory
//! stores otherwise;
//! * finished matches are persisted to Postgres through
//! [`PostgresStatsRepo`];
//! * the platform routes are merged with the SPA shell hosting and wrapped in
//! the session middleware and the optional CORS layer.
use std::sync::Arc;
use std::time::Duration;
use axum::Router;
use tavolo_platform::deadlines::DeadlineScheduler;
use tavolo_platform::oidc::OidcProviders;
use tavolo_platform::platform::{Platform, SharedPlatform};
use tavolo_platform::registry::GameRegistry;
use tavolo_platform::routes;
use tavolo_platform::session::{
session_middleware, InMemorySessionStore, RedisSessionStore, SharedSessionStore,
};
use tavolo_platform::stats::{InMemoryStatsRepo, PostgresStatsRepo, SharedStatsRepo};
use tavolo_platform::store::{InMemoryGameStore, RedisGameStore, SharedGameStore};
use tavolo_scopone::ScoponeEngine;
use tower_http::services::ServeDir;
use crate::config::Settings;
use crate::cors::CorsConfig;
use crate::static_files::{mount_dirs, SpaFallback};
pub struct App {
pub router: Router,
pub platform: SharedPlatform,
pub session_store: SharedSessionStore,
}
/// Build the game registry (the first registered game is the default).
pub fn build_registry(settings: &Settings) -> Result<Arc<GameRegistry>, String> {
let mut registry = GameRegistry::new();
registry
.register(Arc::new(ScoponeEngine::new(
settings.turn_timeout_seconds,
settings.hand_ack_timeout_seconds,
)))
.map_err(|err| err.to_string())?;
Ok(Arc::new(registry))
}
/// Build the session and live-game stores from `REDIS_URL`.
pub async fn build_stores(
settings: &Settings,
registry: Arc<GameRegistry>,
) -> Result<(SharedSessionStore, SharedGameStore), String> {
if let Some(url) = &settings.redis_url {
let session_store: SharedSessionStore = Arc::new(
RedisSessionStore::new(url)
.await
.map_err(|err| format!("could not connect to Redis: {err}"))?,
);
let game_store: SharedGameStore = Arc::new(
RedisGameStore::new(url, registry, settings.game_ttl_seconds)
.await
.map_err(|err| format!("could not connect to Redis: {err}"))?,
);
Ok((session_store, game_store))
} else {
let session_store: SharedSessionStore = Arc::new(InMemorySessionStore::new());
let game_store: SharedGameStore = Arc::new(InMemoryGameStore::new(registry));
Ok((session_store, game_store))
}
}
/// Build the [`Platform`] bundle and start the deadline scheduler.
pub fn build_platform(
settings: &Settings,
registry: Arc<GameRegistry>,
game_store: SharedGameStore,
stats: SharedStatsRepo,
) -> Result<SharedPlatform, String> {
let oidc = Arc::new(
OidcProviders::new(settings.oidc_providers.clone()).map_err(|err| err.to_string())?,
);
let scheduler = DeadlineScheduler::new(
game_store.clone(),
registry.clone(),
stats.clone(),
settings.deadline_heartbeat_ms,
1000,
);
scheduler.start();
Ok(Arc::new(Platform {
registry,
game_store,
scheduler,
oidc,
stats,
}))
}
/// Build the axum router: platform routes, static mounts, SPA fallback,
/// session middleware and optional CORS.
pub fn build_router(
platform: SharedPlatform,
session_store: SharedSessionStore,
settings: &Settings,
) -> Router {
let (root, assets) = mount_dirs(&settings.static_dir);
let provider_names: Vec<String> = settings
.oidc_providers
.iter()
.map(|provider| provider.provider.clone())
.collect();
let mut router = Router::new()
.merge(routes::router(&provider_names).with_state(platform))
.nest_service("/static", ServeDir::new(root))
.nest_service("/assets", ServeDir::new(assets))
.fallback_service(SpaFallback::new(&settings.static_dir));
// Added before the session layer, so request spans see the loaded
// session while the session middleware stays the outer layer.
let excluded = Arc::new(settings.otel_excluded_paths.clone());
router = router.layer(axum::middleware::from_fn(move |request, next| {
let excluded = Arc::clone(&excluded);
crate::telemetry::trace_middleware(excluded, request, next)
}));
router = router.layer(axum::middleware::from_fn_with_state(
session_store,
session_middleware,
));
if let Some(config) = CorsConfig::new(
settings.cors_allow_origins.as_deref().unwrap_or(&[]),
settings.cors_allow_methods.as_deref(),
settings.cors_allow_headers.as_deref(),
settings.cors_allow_credentials,
settings.cors_allow_origin_regex.as_deref(),
settings.cors_expose_headers.as_deref().unwrap_or(&[]),
settings.cors_max_age,
) {
router = router.layer(axum::middleware::from_fn_with_state(
Arc::new(config),
crate::cors::cors_middleware,
));
}
router
}
/// Build everything in-memory: useful for tests and ephemeral dev without
/// Postgres or Redis.
pub fn in_memory_platform(
settings: &Settings,
) -> Result<(SharedPlatform, SharedSessionStore), String> {
let registry = build_registry(settings)?;
let session_store: SharedSessionStore = Arc::new(InMemorySessionStore::new());
let game_store: SharedGameStore = Arc::new(InMemoryGameStore::new(registry.clone()));
let stats: SharedStatsRepo = Arc::new(InMemoryStatsRepo::new());
let platform = build_platform(settings, registry, game_store, stats)?;
Ok((platform, session_store))
}
/// Connect to Postgres.
pub async fn connect_database(settings: &Settings) -> Result<sqlx::PgPool, String> {
if settings.database_url.starts_with("sqlite") {
return Err(
"the Rust server supports Postgres only; sqlite DATABASE_URLs are not supported"
.to_string(),
);
}
sqlx::postgres::PgPoolOptions::new()
.max_connections(10)
.acquire_timeout(Duration::from_secs(10))
.connect(&settings.database_url)
.await
.map_err(|err| format!("could not connect to the database: {err}"))
}
/// Build the production application (requires Postgres; Redis optional).
pub async fn build(settings: &Settings) -> Result<App, String> {
let registry = build_registry(settings)?;
let (session_store, game_store) = build_stores(settings, registry.clone()).await?;
let pool = connect_database(settings).await?;
let stats: SharedStatsRepo = Arc::new(PostgresStatsRepo::new(pool));
let platform = build_platform(settings, registry, game_store, stats)?;
let router = build_router(platform.clone(), session_store.clone(), settings);
Ok(App {
router,
platform,
session_store,
})
}
+308
View File
@@ -0,0 +1,308 @@
//! Environment-driven configuration for the tavolo application.
//!
//! Mirrors `tavolo.config` from the Python implementation: read environment
//! variables into a plain struct, with the same defaults and validation.
use std::collections::BTreeSet;
use regex::Regex;
use tavolo_platform::oidc::OidcProviderConfig;
const OIDC_VARIABLES: [&str; 7] = [
"PROVIDER",
"ISSUER",
"CLIENT_ID",
"CLIENT_SECRET",
"REDIRECT_URI",
"POST_LOGIN_REDIRECT",
"POST_LOGOUT_REDIRECT",
];
/// The pre-0.0.6 unindexed names, detected only to point the migration out.
const LEGACY_OIDC_VARIABLES: [&str; 6] = [
"OIDC_ISSUER",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_REDIRECT_URI",
"OIDC_POST_LOGIN_REDIRECT",
"OIDC_POST_LOGOUT_REDIRECT",
];
const DEFAULT_OIDC_ISSUER: &str = "http://localhost:8180/tavolo";
const DEFAULT_OIDC_CLIENT_ID: &str = "tavolo";
fn env(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|value| !value.is_empty())
}
fn env_or(name: &str, default: &str) -> String {
env(name).unwrap_or_else(|| default.to_string())
}
fn env_list(name: &str) -> Option<Vec<String>> {
let value = std::env::var(name).ok()?;
if value.trim().is_empty() {
return None;
}
Some(
value
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
.map(str::to_string)
.collect(),
)
}
fn env_bool(name: &str, default: bool) -> bool {
match std::env::var(name) {
Ok(value) if !value.is_empty() => {
matches!(
value.trim().to_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}
_ => default,
}
}
fn parse_oidc_providers() -> Result<Vec<OidcProviderConfig>, String> {
let legacy: Vec<&str> = LEGACY_OIDC_VARIABLES
.iter()
.copied()
.filter(|name| env(name).is_some())
.collect();
if !legacy.is_empty() {
let renamed: Vec<String> = legacy
.iter()
.map(|name| format!("{name} -> OIDC_0_{}", &name["OIDC_".len()..]))
.collect();
return Err(format!(
"the OIDC variables are now indexed per provider ({}; add OIDC_0_PROVIDER)",
renamed.join(", ")
));
}
let pattern = Regex::new(r"^OIDC_(\d+)_([A-Z0-9_]+)$").expect("valid regex");
let mut indices: BTreeSet<usize> = BTreeSet::new();
for (name, _) in std::env::vars() {
let Some(captures) = pattern.captures(&name) else {
continue;
};
let variable = captures.get(2).expect("group 2").as_str();
if !OIDC_VARIABLES.contains(&variable) {
return Err(format!(
"unknown OIDC environment variable {name:?}; expected one of {}",
OIDC_VARIABLES
.iter()
.map(|variable| format!("OIDC_<n>_{variable}"))
.collect::<Vec<_>>()
.join(", ")
));
}
indices.insert(
captures
.get(1)
.expect("group 1")
.as_str()
.parse()
.map_err(|_| format!("invalid OIDC provider index in {name:?}"))?,
);
}
if indices.is_empty() {
return Ok(vec![OidcProviderConfig {
provider: "default".into(),
issuer: DEFAULT_OIDC_ISSUER.into(),
client_id: DEFAULT_OIDC_CLIENT_ID.into(),
client_secret: None,
redirect_uri: "http://localhost:8080/auth/default/callback".into(),
post_login_redirect: "/".into(),
post_logout_redirect: "/".into(),
}]);
}
let max = *indices.iter().next_back().expect("non-empty");
let missing: Vec<usize> = (0..=max).filter(|index| !indices.contains(index)).collect();
if !missing.is_empty() {
return Err(format!(
"OIDC provider indices must be contiguous from 0; missing {}",
missing
.iter()
.map(|index| format!("OIDC_{index}_*"))
.collect::<Vec<_>>()
.join(", ")
));
}
let provider_name = Regex::new(r"^[A-Za-z0-9_-]+$").expect("valid regex");
let mut seen = std::collections::HashSet::new();
let mut providers = Vec::new();
for index in indices {
let prefix = format!("OIDC_{index}_");
let required = |variable: &str| -> Result<String, String> {
env(&format!("{prefix}{variable}"))
.ok_or_else(|| format!("Missing required environment variable: {prefix}{variable}"))
};
let provider = required("PROVIDER")?;
if !provider_name.is_match(&provider) {
return Err(format!(
"{prefix}PROVIDER must be a non-empty string of letters, digits, underscores or hyphens (got {provider:?})"
));
}
if !seen.insert(provider.clone()) {
return Err(format!("duplicate OIDC provider name {provider:?}"));
}
providers.push(OidcProviderConfig {
issuer: required("ISSUER")?,
client_id: required("CLIENT_ID")?,
client_secret: env(&format!("{prefix}CLIENT_SECRET")),
redirect_uri: env_or(
&format!("{prefix}REDIRECT_URI"),
&format!("http://localhost:8080/auth/{provider}/callback"),
),
post_login_redirect: env_or(&format!("{prefix}POST_LOGIN_REDIRECT"), "/"),
post_logout_redirect: env_or(&format!("{prefix}POST_LOGOUT_REDIRECT"), "/"),
provider,
});
}
Ok(providers)
}
/// Percent-encode a DSN component like Python's `quote(value, safe="")`,
/// which leaves the RFC 3986 unreserved characters alone.
fn quote(value: &str) -> String {
const QUOTE_SET: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
.remove(b'_')
.remove(b'-')
.remove(b'.')
.remove(b'~');
percent_encoding::utf8_percent_encode(value, QUOTE_SET).to_string()
}
fn database_url_from_parts(
engine: &str,
user: &str,
password: Option<&str>,
host: &str,
port: &str,
name: &str,
options: &str,
) -> String {
let mut netloc = quote(user);
if let Some(password) = password {
if !password.is_empty() {
netloc.push(':');
netloc.push_str(&quote(password));
}
}
netloc.push('@');
netloc.push_str(host);
if !port.is_empty() {
netloc.push(':');
netloc.push_str(port);
}
let mut url = format!("{engine}://{netloc}/{name}");
let options = options.trim_start_matches('?');
if !options.is_empty() {
url.push('?');
url.push_str(options);
}
url
}
#[derive(Debug, Clone)]
pub struct Settings {
pub database_url: String,
pub oidc_providers: Vec<OidcProviderConfig>,
pub app_host: String,
pub app_port: u16,
pub redis_url: Option<String>,
pub game_ttl_seconds: i64,
pub static_dir: String,
pub hand_ack_timeout_seconds: i64,
pub turn_timeout_seconds: i64,
pub deadline_heartbeat_ms: u64,
/// Accepted for compatibility; Rust logging is configured via RUST_LOG.
pub logging_config: Option<String>,
pub cors_allow_origins: Option<Vec<String>>,
pub cors_allow_origin_regex: Option<String>,
pub cors_allow_methods: Option<Vec<String>>,
pub cors_allow_headers: Option<Vec<String>>,
pub cors_allow_credentials: bool,
pub cors_expose_headers: Option<Vec<String>>,
pub cors_max_age: i64,
pub otel_enabled: bool,
pub otel_service_name: String,
pub otel_exporter_endpoint: Option<String>,
pub otel_exporter_headers: Option<Vec<String>>,
pub otel_excluded_paths: Vec<String>,
pub otel_metric_export_interval_millis: u64,
pub otel_metrics_poll_interval_seconds: u64,
}
impl Settings {
pub fn from_env() -> Result<Self, String> {
let database_url = env("DATABASE_URL").unwrap_or_else(|| {
database_url_from_parts(
&env_or("DATABASE_ENGINE", "postgres"),
&env_or("DATABASE_USER", "tavolo"),
Some(&env_or("DATABASE_PASSWORD", "password")),
&env_or("DATABASE_HOST", "localhost"),
&std::env::var("DATABASE_PORT").unwrap_or_default(),
&env_or("DATABASE_NAME", "tavolo"),
&std::env::var("DATABASE_OPTIONS").unwrap_or_default(),
)
});
let app_port = env_or("APP_PORT", "8080")
.parse::<u16>()
.map_err(|_| "APP_PORT must be a valid port number".to_string())?;
let otel_headers = env_list("OTEL_EXPORTER_OTLP_HEADERS");
Ok(Self {
database_url,
oidc_providers: parse_oidc_providers()?,
app_host: env_or("APP_HOST", "0.0.0.0"),
app_port,
redis_url: env("REDIS_URL"),
game_ttl_seconds: env_or("GAME_TTL_SECONDS", "86400")
.parse()
.map_err(|_| "GAME_TTL_SECONDS must be an integer".to_string())?,
static_dir: env_or("STATIC_DIR", "web/dist"),
hand_ack_timeout_seconds: env_or("HAND_ACK_TIMEOUT_SECONDS", "30")
.parse()
.map_err(|_| "HAND_ACK_TIMEOUT_SECONDS must be an integer".to_string())?,
turn_timeout_seconds: env_or("TURN_TIMEOUT_SECONDS", "30")
.parse()
.map_err(|_| "TURN_TIMEOUT_SECONDS must be an integer".to_string())?,
deadline_heartbeat_ms: env_or("DEADLINE_HEARTBEAT_MS", "30000")
.parse()
.map_err(|_| "DEADLINE_HEARTBEAT_MS must be an integer".to_string())?,
logging_config: env("LOGGING_CONFIG"),
cors_allow_origins: env_list("CORS_ALLOW_ORIGINS"),
cors_allow_origin_regex: env("CORS_ALLOW_ORIGIN_REGEX"),
cors_allow_methods: env_list("CORS_ALLOW_METHODS"),
cors_allow_headers: env_list("CORS_ALLOW_HEADERS"),
cors_allow_credentials: env_bool("CORS_ALLOW_CREDENTIALS", false),
cors_expose_headers: env_list("CORS_EXPOSE_HEADERS"),
cors_max_age: env_or("CORS_MAX_AGE", "600")
.parse()
.map_err(|_| "CORS_MAX_AGE must be an integer".to_string())?,
otel_enabled: env_bool("OTEL_ENABLED", false),
otel_service_name: env_or("OTEL_SERVICE_NAME", "tavolo"),
otel_exporter_endpoint: env("OTEL_EXPORTER_OTLP_ENDPOINT"),
otel_exporter_headers: otel_headers,
otel_excluded_paths: env_list("OTEL_EXCLUDED_PATHS")
.unwrap_or_else(|| vec!["/api/health".to_string()]),
otel_metric_export_interval_millis: env_or(
"OTEL_METRIC_EXPORT_INTERVAL_MILLIS",
"60000",
)
.parse()
.map_err(|_| "OTEL_METRIC_EXPORT_INTERVAL_MILLIS must be an integer".to_string())?,
otel_metrics_poll_interval_seconds: env_or("OTEL_METRICS_POLL_INTERVAL_SECONDS", "15")
.parse()
.map_err(|_| "OTEL_METRICS_POLL_INTERVAL_SECONDS must be an integer".to_string())?,
})
}
}
+354
View File
@@ -0,0 +1,354 @@
//! CORS middleware replicating kaya-cors' `CorsMixin` (modeled after
//! Starlette's `CORSMiddleware`).
//!
//! * requests without an `Origin` header pass through untouched;
//! * preflight requests (`OPTIONS` carrying `Origin` and
//! `Access-Control-Request-Method`) are answered directly: `200 OK` when
//! the origin, method and headers are allowed, `400` with a
//! `Disallowed CORS ...` body otherwise;
//! * simple cross-origin requests get `Access-Control-Allow-Origin` (and the
//! configured credentials/expose headers) merged into the response without
//! overwriting headers the handler already set.
use std::collections::HashSet;
use std::sync::Arc;
use axum::extract::{Request, State};
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode};
use axum::middleware::Next;
use axum::response::Response;
use regex::Regex;
const ALL_METHODS: [&str; 8] = [
"DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT", "QUERY",
];
const SAFELISTED_HEADERS: [&str; 4] = [
"Accept",
"Accept-Language",
"Content-Language",
"Content-Type",
];
pub struct CorsConfig {
allow_all_origins: bool,
allow_credentials: bool,
allow_origins: Vec<String>,
allow_origin_regex: Option<Regex>,
allow_methods: Vec<String>,
allow_all_headers: bool,
/// Lowercased set checked against requested headers.
allow_headers_lower: HashSet<String>,
preflight_explicit_allow_origin: bool,
simple_headers: Vec<(HeaderName, HeaderValue)>,
preflight_headers: Vec<(HeaderName, HeaderValue)>,
}
impl CorsConfig {
pub fn new(
allow_origins: &[String],
allow_methods: Option<&[String]>,
allow_headers: Option<&[String]>,
allow_credentials: bool,
allow_origin_regex: Option<&str>,
expose_headers: &[String],
max_age: i64,
) -> Option<Self> {
if allow_origins.is_empty() && allow_origin_regex.is_none() {
return None;
}
let allow_all_origins = allow_origins.iter().any(|origin| origin == "*");
let allow_all_headers = allow_headers
.map(|headers| headers.iter().any(|header| header == "*"))
.unwrap_or(false);
let methods: Vec<String> = match allow_methods {
Some(methods) if methods.iter().any(|method| method == "*") => ALL_METHODS
.iter()
.map(|method| method.to_string())
.collect(),
Some(methods) => methods.to_vec(),
None => vec!["GET".to_string()],
};
let configured = allow_headers.unwrap_or_default();
let mut sorted_allow_headers: Vec<String> = SAFELISTED_HEADERS
.iter()
.map(|header| header.to_string())
.chain(configured.iter().cloned())
.collect::<HashSet<_>>()
.into_iter()
.collect();
sorted_allow_headers.sort();
let allow_headers_lower: HashSet<String> = sorted_allow_headers
.iter()
.map(|header| header.to_lowercase())
.collect();
let preflight_explicit_allow_origin = !allow_all_origins || allow_credentials;
let simple_headers =
build_simple_headers(allow_all_origins, allow_credentials, expose_headers);
let preflight_headers = build_preflight_headers(
&methods,
&sorted_allow_headers,
allow_all_headers,
allow_credentials,
max_age,
preflight_explicit_allow_origin,
);
Some(Self {
allow_all_origins,
allow_credentials,
allow_origins: allow_origins.to_vec(),
allow_origin_regex: allow_origin_regex.and_then(|pattern| Regex::new(pattern).ok()),
allow_methods: methods,
allow_all_headers,
allow_headers_lower,
preflight_explicit_allow_origin,
simple_headers,
preflight_headers,
})
}
fn is_allowed_origin(&self, origin: &str) -> bool {
if self.allow_all_origins {
return true;
}
if let Some(regex) = &self.allow_origin_regex {
if regex.is_match(origin) {
return true;
}
}
self.allow_origins.iter().any(|allowed| allowed == origin)
}
fn simple_response_headers(&self, origin: &str) -> Vec<(HeaderName, HeaderValue)> {
let mut headers = self.simple_headers.clone();
if self.allow_all_origins && self.allow_credentials {
// Credentials require the specific origin instead of '*'.
upsert(
&mut headers,
header::ACCESS_CONTROL_ALLOW_ORIGIN,
HeaderValue::from_str(origin).unwrap_or(HeaderValue::from_static("*")),
);
upsert(
&mut headers,
header::VARY,
HeaderValue::from_static("Origin"),
);
} else if !self.allow_all_origins && self.is_allowed_origin(origin) {
// Specific origins are mirrored back in the response.
upsert(
&mut headers,
header::ACCESS_CONTROL_ALLOW_ORIGIN,
HeaderValue::from_str(origin).unwrap_or(HeaderValue::from_static("*")),
);
upsert(
&mut headers,
header::VARY,
HeaderValue::from_static("Origin"),
);
}
headers
}
fn preflight_response(&self, request_headers: &HeaderMap, origin: &str) -> Response {
let mut headers = self.preflight_headers.clone();
let mut failures: Vec<&'static str> = Vec::new();
if self.is_allowed_origin(origin) {
if self.preflight_explicit_allow_origin {
if let Ok(value) = HeaderValue::from_str(origin) {
headers.push((header::ACCESS_CONTROL_ALLOW_ORIGIN, value));
}
}
} else {
failures.push("origin");
}
let requested_method = request_headers
.get("access-control-request-method")
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
if !self
.allow_methods
.iter()
.any(|method| method == requested_method)
{
failures.push("method");
}
let requested_headers = request_headers
.get("access-control-request-headers")
.and_then(|value| value.to_str().ok());
if self.allow_all_headers && requested_headers.is_some() {
if let Ok(value) = HeaderValue::from_str(requested_headers.unwrap_or_default()) {
headers.push((header::ACCESS_CONTROL_ALLOW_HEADERS, value));
}
} else if let Some(requested_headers) = requested_headers {
for header in requested_headers.split(',') {
if !self
.allow_headers_lower
.contains(header.trim().to_lowercase().as_str())
{
failures.push("headers");
break;
}
}
}
let (status, body) = if failures.is_empty() {
(StatusCode::OK, "OK".to_string())
} else {
(
StatusCode::BAD_REQUEST,
format!("Disallowed CORS {}", failures.join(", ")),
)
};
let mut response = Response::new(axum::body::Body::from(body));
*response.status_mut() = status;
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
);
for (name, value) in headers {
if !response.headers().contains_key(&name) {
response.headers_mut().insert(name, value);
}
}
response
}
}
fn upsert(headers: &mut Vec<(HeaderName, HeaderValue)>, name: HeaderName, value: HeaderValue) {
if let Some(existing) = headers.iter_mut().find(|(existing, _)| *existing == name) {
existing.1 = value;
} else {
headers.push((name, value));
}
}
fn build_simple_headers(
allow_all_origins: bool,
allow_credentials: bool,
expose_headers: &[String],
) -> Vec<(HeaderName, HeaderValue)> {
let mut headers = Vec::new();
if allow_all_origins {
headers.push((
header::ACCESS_CONTROL_ALLOW_ORIGIN,
HeaderValue::from_static("*"),
));
}
if allow_credentials {
headers.push((
header::ACCESS_CONTROL_ALLOW_CREDENTIALS,
HeaderValue::from_static("true"),
));
}
if !expose_headers.is_empty() {
if let Ok(value) = HeaderValue::from_str(&expose_headers.join(", ")) {
headers.push((header::ACCESS_CONTROL_EXPOSE_HEADERS, value));
}
}
headers
}
fn build_preflight_headers(
methods: &[String],
sorted_allow_headers: &[String],
allow_all_headers: bool,
allow_credentials: bool,
max_age: i64,
explicit_allow_origin: bool,
) -> Vec<(HeaderName, HeaderValue)> {
let mut headers = Vec::new();
if explicit_allow_origin {
headers.push((header::VARY, HeaderValue::from_static("Origin")));
} else {
headers.push((
header::ACCESS_CONTROL_ALLOW_ORIGIN,
HeaderValue::from_static("*"),
));
}
if let Ok(value) = HeaderValue::from_str(&methods.join(", ")) {
headers.push((header::ACCESS_CONTROL_ALLOW_METHODS, value));
}
if let Ok(value) = HeaderValue::from_str(&max_age.to_string()) {
headers.push((header::ACCESS_CONTROL_MAX_AGE, value));
}
if !sorted_allow_headers.is_empty() && !allow_all_headers {
if let Ok(value) = HeaderValue::from_str(&sorted_allow_headers.join(", ")) {
headers.push((header::ACCESS_CONTROL_ALLOW_HEADERS, value));
}
}
if allow_credentials {
headers.push((
header::ACCESS_CONTROL_ALLOW_CREDENTIALS,
HeaderValue::from_static("true"),
));
}
headers
}
/// Merge CORS headers into a response: headers already set by the handler are
/// never overwritten; a `Vary` value is appended to an existing header when
/// not already present.
fn merge_headers(response: &mut Response, headers: Vec<(HeaderName, HeaderValue)>) {
for (name, value) in headers {
if name == header::VARY {
let existing = response
.headers()
.get(header::VARY)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string();
let present = existing
.split(',')
.any(|part| part.trim().eq_ignore_ascii_case("origin"));
if present {
continue;
}
if existing.is_empty() {
response.headers_mut().insert(header::VARY, value);
} else {
if let Ok(combined) = HeaderValue::from_str(&format!(
"{existing}, {}",
value.to_str().unwrap_or("Origin")
)) {
response.headers_mut().insert(header::VARY, combined);
}
}
continue;
}
if !response.headers().contains_key(&name) {
response.headers_mut().insert(name, value);
}
}
}
pub async fn cors_middleware(
State(config): State<Arc<CorsConfig>>,
request: Request,
next: Next,
) -> Response {
let origin = request
.headers()
.get(header::ORIGIN)
.and_then(|value| value.to_str().ok())
.map(str::to_string);
let Some(origin) = origin else {
return next.run(request).await;
};
if request.method() == Method::OPTIONS
&& request
.headers()
.contains_key("access-control-request-method")
{
return config.preflight_response(request.headers(), &origin);
}
let headers = config.simple_response_headers(&origin);
let mut response = next.run(request).await;
merge_headers(&mut response, headers);
response
}
+6
View File
@@ -0,0 +1,6 @@
pub mod app;
pub mod config;
pub mod cors;
pub mod logging;
pub mod static_files;
pub mod telemetry;
+58
View File
@@ -0,0 +1,58 @@
//! Logging setup for the tavolo application.
//!
//! The Python server configured logging through `LOGGING_CONFIG` (a
//! `logging.config.dictConfig` YAML file). The Rust server deliberately does
//! not emulate that schema: logging is configured through `RUST_LOG`
//! (tracing-subscriber env filters). `LOGGING_CONFIG` is still accepted so
//! existing deployments do not break, but its value is ignored with a
//! warning.
//!
//! When telemetry is enabled the same subscriber also carries the
//! OpenTelemetry trace layer and the OTLP log bridge.
use crate::config::Settings;
use crate::telemetry::Telemetry;
/// Default filter mirrors the Python default (DEBUG for the application) while
/// keeping noisy dependencies at INFO+.
const DEFAULT_FILTER: &str =
"debug,sqlx=warn,redis=warn,hyper=warn,reqwest=warn,tower_http=info,tungstenite=warn";
pub fn configure_logging(settings: &Settings, telemetry: Option<&Telemetry>) {
use tracing_subscriber::layer::{Layer, SubscriberExt};
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::EnvFilter;
if settings.logging_config.is_some() {
eprintln!(
"LOGGING_CONFIG is set but the Rust server configures logging via RUST_LOG; \
the YAML file is ignored"
);
}
let filter =
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(DEFAULT_FILTER));
let subscriber = tracing_subscriber::registry()
.with(filter)
.with(tracing_subscriber::fmt::layer().with_target(true));
#[cfg(feature = "otel")]
if let Some(telemetry) = telemetry {
use tracing_subscriber::filter::FilterFn;
let telemetry_layer = tracing_opentelemetry::layer().with_tracer(telemetry.tracer());
// Drop OpenTelemetry's own records to avoid export loops.
let log_bridge = opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge::new(
telemetry.logger_provider(),
)
.with_filter(FilterFn::new(|metadata| {
!metadata.target().starts_with("opentelemetry")
}));
let subscriber = subscriber.with(telemetry_layer).with(log_bridge);
let _ = subscriber.try_init();
return;
}
#[cfg(not(feature = "otel"))]
let _ = telemetry;
let _ = subscriber.try_init();
}
+160
View File
@@ -0,0 +1,160 @@
//! The `tavolo` binary: `serve` (default), `migrate` and `backfill-elo`.
//!
//! `migrate` replaces the Python `aerich upgrade` migration service: it
//! applies the embedded sqlx migrations (idempotent against databases already
//! at the aerich schema head). `backfill-elo` replaces
//! `python -m tavolo.platform.backfill_elo`.
use std::net::SocketAddr;
use clap::{Parser, Subcommand};
use tavolo_app::app;
use tavolo_app::config::Settings;
use tavolo_app::logging::configure_logging;
use tavolo_platform::stats::{PostgresStatsRepo, StatsRepo};
#[derive(Parser)]
#[command(
name = "tavolo",
version,
about = "Multiplayer card-game platform backend"
)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand)]
enum Command {
/// Serve the HTTP + WebSocket application (default).
Serve,
/// Apply the database migrations and exit.
Migrate,
/// Recompute every Elo rating from the recorded match history.
BackfillElo {
/// Database URL; defaults to the configured DATABASE_* parts.
#[arg(long, env = "DATABASE_URL")]
database_url: Option<String>,
},
}
fn main() {
let cli = Cli::parse();
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime");
let result = match cli.command.unwrap_or(Command::Serve) {
Command::Serve => runtime.block_on(serve()),
Command::Migrate => runtime.block_on(migrate()),
Command::BackfillElo { database_url } => {
runtime.block_on(backfill_elo(database_url.as_deref()))
}
};
if let Err(message) = result {
eprintln!("error: {message}");
std::process::exit(1);
}
}
async fn serve() -> Result<(), String> {
use std::sync::Arc;
use tavolo_platform::stats::{PostgresStatsRepo, SharedStatsRepo};
let settings = Settings::from_env()?;
let registry = app::build_registry(&settings)?;
let (session_store, game_store) = app::build_stores(&settings, registry.clone()).await?;
let telemetry =
tavolo_app::telemetry::init(&settings, session_store.clone(), game_store.clone())?;
configure_logging(&settings, telemetry.as_ref());
tracing::info!(
"registered games: {}",
registry
.all()
.iter()
.map(|engine| engine.id())
.collect::<Vec<_>>()
.join(", ")
);
if settings.redis_url.is_some() {
tracing::info!(
"using Redis stores (sessions + live games, game TTL {}s)",
settings.game_ttl_seconds
);
} else {
tracing::info!("REDIS_URL unset: using in-memory stores (sessions + live games)");
}
if telemetry.is_some() {
tracing::info!(
"OpenTelemetry enabled: service={} endpoint={} (traces, metrics, logs)",
settings.otel_service_name,
settings
.otel_exporter_endpoint
.as_deref()
.unwrap_or("(OTLP default)"),
);
}
let pool = app::connect_database(&settings).await?;
let stats: SharedStatsRepo = Arc::new(PostgresStatsRepo::new(pool));
let platform = app::build_platform(&settings, registry, game_store, stats)?;
let router = app::build_router(platform, session_store, &settings);
let address: SocketAddr = format!("{}:{}", settings.app_host, settings.app_port)
.parse()
.map_err(|_| {
format!(
"invalid bind address {}:{}",
settings.app_host, settings.app_port
)
})?;
let listener = tokio::net::TcpListener::bind(address)
.await
.map_err(|err| format!("could not bind {address}: {err}"))?;
tracing::info!("listening on http://{address}");
axum::serve(listener, router)
.with_graceful_shutdown(shutdown_signal())
.await
.map_err(|err| format!("server error: {err}"))
}
async fn shutdown_signal() {
let _ = tokio::signal::ctrl_c().await;
tracing::info!("shutting down");
}
fn database_url(override_url: Option<&str>) -> Result<String, String> {
match override_url {
Some(url) => Ok(url.to_string()),
None => Ok(Settings::from_env()?.database_url),
}
}
async fn migrate() -> Result<(), String> {
let settings = Settings::from_env()?;
configure_logging(&settings, None);
let url = database_url(None)?;
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect(&url)
.await
.map_err(|err| format!("could not connect to the database: {err}"))?;
sqlx::migrate!("./migrations")
.run(&pool)
.await
.map_err(|err| format!("migration failed: {err}"))?;
println!("migrations applied");
Ok(())
}
async fn backfill_elo(override_url: Option<&str>) -> Result<(), String> {
let url = database_url(override_url)?;
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect(&url)
.await
.map_err(|err| format!("could not connect to the database: {err}"))?;
let repo = PostgresStatsRepo::new(pool);
let replayed = repo
.backfill_elo()
.await
.map_err(|err| format!("backfill failed: {err}"))?;
println!("Recomputed ratings from {replayed} matches.");
Ok(())
}
@@ -0,0 +1,74 @@
//! SPA shell hosting and static asset mounts.
//!
//! Static assets (wasm, js, css, card images) are served by
//! [`tower_http::services::ServeDir`] under `/static` (mapped to the frontend
//! build root) and `/assets` (mapped to `<root>/assets`), mirroring the
//! Granian static mounts of the Python deployment. Every path no API or auth
//! route claimed falls back to `index.html`, so client-side routes
//! (`/game/<id>`) work on direct loads and refreshes.
use std::convert::Infallible;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::task::{Context, Poll};
use axum::body::Body;
use axum::extract::Request;
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response};
use tower::Service;
/// Fallback service serving the SPA shell, or 404 when the frontend build is
/// missing.
#[derive(Clone)]
pub struct SpaFallback {
index: Arc<PathBuf>,
}
impl SpaFallback {
pub fn new(static_dir: &str) -> Self {
Self {
index: Arc::new(Path::new(static_dir).join("index.html")),
}
}
}
impl Service<Request> for SpaFallback {
type Response = Response;
type Error = Infallible;
type Future = std::pin::Pin<
Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>,
>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, _request: Request) -> Self::Future {
let index = Arc::clone(&self.index);
Box::pin(async move {
match tokio::fs::read(index.as_ref()).await {
Ok(body) => Ok((
StatusCode::OK,
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
body,
)
.into_response()),
Err(_) => Ok(StatusCode::NOT_FOUND.into_response()),
}
})
}
}
/// The `/static` and `/assets` mount directories for a build root.
pub fn mount_dirs(static_dir: &str) -> (PathBuf, PathBuf) {
let root = PathBuf::from(static_dir);
let assets = root.join("assets");
(root, assets)
}
/// Build a 404 response body-less response (kept for symmetry with the SPA
/// fallback).
pub fn not_found() -> Response {
Body::empty().into_response()
}
+389
View File
@@ -0,0 +1,389 @@
//! OpenTelemetry wiring: OTLP traces, metrics and logs.
//!
//! Mirrors `tavolo.telemetry` from the Python implementation within what the
//! Rust ecosystem offers:
//!
//! * **traces** — HTTP request spans (tagged with a hashed `session.id` and
//! the OIDC `user.id`) and one span per WebSocket connection, exported over
//! OTLP/HTTP;
//! * **metrics** — `tavolo.active_users` (sessions in the session store),
//! `tavolo.ongoing_games` (live matches not finished yet) and Rust process
//! metrics (`process.cpu.time`, `process.cpu.utilization`,
//! `process.memory.usage`, `process.memory.virtual`) in place of the
//! CPython-specific gauges;
//! * **logs** — tracing events forwarded to OTLP (OpenTelemetry's own records
//! are filtered out upstream).
//!
//! Database client instrumentation (asyncpg/redis spans in Python) has no
//! direct Rust equivalent and is omitted.
use std::sync::Arc;
use crate::config::Settings;
use tavolo_platform::session::SharedSessionStore;
use tavolo_platform::store::SharedGameStore;
#[cfg(feature = "otel")]
mod inner {
use std::collections::HashMap;
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use opentelemetry_otlp::{
LogExporter, MetricExporter, SpanExporter, WithExportConfig, WithHttpConfig,
};
use opentelemetry_sdk::logs::SdkLoggerProvider;
use opentelemetry_sdk::metrics::{PeriodicReader, SdkMeterProvider};
use opentelemetry_sdk::trace::SdkTracerProvider;
use opentelemetry_sdk::Resource;
use super::{Settings, SharedGameStore, SharedSessionStore};
pub(super) static HTTP_DURATION: OnceLock<opentelemetry::metrics::Histogram<f64>> =
OnceLock::new();
#[derive(Clone, Copy, Default)]
pub(super) struct ProcessSnapshot {
pub cpu_seconds: f64,
pub cpu_utilization: f64,
pub memory_usage: u64,
pub memory_virtual: u64,
}
pub(super) struct OtelInner {
pub tracer_provider: SdkTracerProvider,
pub meter_provider: SdkMeterProvider,
pub logger_provider: SdkLoggerProvider,
pub poll_task: tokio::task::JoinHandle<()>,
}
impl Drop for OtelInner {
fn drop(&mut self) {
self.poll_task.abort();
let _ = self.logger_provider.shutdown();
let _ = self.meter_provider.shutdown();
let _ = self.tracer_provider.shutdown();
}
}
fn signal_endpoint(base: Option<&str>, signal: &str) -> Option<String> {
base.map(|base| format!("{base}/{signal}"))
}
fn parse_headers(raw: Option<&[String]>) -> HashMap<String, String> {
raw.unwrap_or(&[])
.iter()
.filter_map(|pair| pair.split_once('='))
.map(|(key, value)| (key.to_string(), value.trim().to_string()))
.collect()
}
fn refresh_process(snapshot: &Mutex<ProcessSnapshot>, pid: sysinfo::Pid) {
use sysinfo::ProcessesToUpdate;
let mut system = sysinfo::System::new();
system.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
if let Some(process) = system.process(pid) {
let mut snapshot = snapshot.lock().expect("process snapshot");
snapshot.cpu_seconds = process.accumulated_cpu_time() as f64 / 1000.0;
snapshot.cpu_utilization = process.cpu_usage() as f64;
snapshot.memory_usage = process.memory();
snapshot.memory_virtual = process.virtual_memory();
}
}
pub(super) fn init(
settings: &Settings,
session_store: SharedSessionStore,
game_store: SharedGameStore,
) -> Result<Option<super::Telemetry>, String> {
let endpoint = settings.otel_exporter_endpoint.clone();
let headers = parse_headers(settings.otel_exporter_headers.as_deref());
let resource = Resource::builder()
.with_service_name(settings.otel_service_name.clone())
.build();
let span_exporter = SpanExporter::builder()
.with_http()
.with_endpoint(signal_endpoint(endpoint.as_deref(), "v1/traces").unwrap_or_default())
.with_headers(headers.clone())
.build()
.map_err(|err| format!("could not build the OTLP span exporter: {err}"))?;
let tracer_provider = SdkTracerProvider::builder()
.with_batch_exporter(span_exporter)
.with_resource(resource.clone())
.build();
let metric_exporter = MetricExporter::builder()
.with_http()
.with_endpoint(signal_endpoint(endpoint.as_deref(), "v1/metrics").unwrap_or_default())
.with_headers(headers.clone())
.build()
.map_err(|err| format!("could not build the OTLP metric exporter: {err}"))?;
let reader = PeriodicReader::builder(metric_exporter)
.with_interval(Duration::from_millis(
settings.otel_metric_export_interval_millis.max(1),
))
.build();
let meter_provider = SdkMeterProvider::builder()
.with_reader(reader)
.with_resource(resource.clone())
.build();
let log_exporter = LogExporter::builder()
.with_http()
.with_endpoint(signal_endpoint(endpoint.as_deref(), "v1/logs").unwrap_or_default())
.with_headers(headers)
.build()
.map_err(|err| format!("could not build the OTLP log exporter: {err}"))?;
let logger_provider = SdkLoggerProvider::builder()
.with_batch_exporter(log_exporter)
.with_resource(resource)
.build();
opentelemetry::global::set_meter_provider(meter_provider.clone());
let meter = opentelemetry::metrics::MeterProvider::meter(&meter_provider, "tavolo");
let active_users = Arc::new(AtomicI64::new(0));
let ongoing_games = Arc::new(AtomicI64::new(0));
let process = Arc::new(Mutex::new(ProcessSnapshot::default()));
let pid = sysinfo::get_current_pid()
.map_err(|err| format!("could not determine the current process id: {err}"))?;
refresh_process(&process, pid);
let _ = meter
.u64_observable_gauge("tavolo.active_users")
.with_description("Sessions currently held in the session store.")
.with_unit("{user}")
.with_callback({
let active_users = active_users.clone();
move |observer| {
observer.observe(active_users.load(Ordering::Relaxed).max(0) as u64, &[])
}
})
.build();
let _ = meter
.u64_observable_gauge("tavolo.ongoing_games")
.with_description("Live games that have not finished yet.")
.with_unit("{game}")
.with_callback({
let ongoing_games = ongoing_games.clone();
move |observer| {
observer.observe(ongoing_games.load(Ordering::Relaxed).max(0) as u64, &[])
}
})
.build();
let _ = meter
.f64_observable_gauge("process.cpu.utilization")
.with_description("Process CPU utilization since the last poll, in percent.")
.with_callback({
let process = process.clone();
move |observer| {
let snapshot = process.lock().expect("process snapshot");
observer.observe(snapshot.cpu_utilization / 100.0, &[])
}
})
.build();
let _ = meter
.u64_observable_gauge("process.memory.usage")
.with_description("Process resident memory, in bytes.")
.with_unit("By")
.with_callback({
let process = process.clone();
move |observer| {
let snapshot = process.lock().expect("process snapshot");
observer.observe(snapshot.memory_usage, &[])
}
})
.build();
let _ = meter
.u64_observable_gauge("process.memory.virtual")
.with_description("Process virtual memory, in bytes.")
.with_unit("By")
.with_callback({
let process = process.clone();
move |observer| {
let snapshot = process.lock().expect("process snapshot");
observer.observe(snapshot.memory_virtual, &[])
}
})
.build();
let _ = meter
.f64_observable_counter("process.cpu.time")
.with_description("Process CPU time, in seconds.")
.with_unit("s")
.with_callback({
let process = process.clone();
move |observer| {
let snapshot = process.lock().expect("process snapshot");
observer.observe(snapshot.cpu_seconds, &[]);
}
})
.build();
let _ = HTTP_DURATION.set(
meter
.f64_histogram("http.server.request.duration")
.with_description("HTTP server request duration.")
.with_unit("s")
.build(),
);
let poll_interval = Duration::from_secs(settings.otel_metrics_poll_interval_seconds.max(1));
let poll_task = tokio::spawn({
let session_store = session_store.clone();
let game_store = game_store.clone();
let active_users = active_users.clone();
let ongoing_games = ongoing_games.clone();
let process = process.clone();
async move {
loop {
match session_store.count().await {
Ok(count) => active_users.store(count as i64, Ordering::Relaxed),
Err(err) => tracing::warn!("active_users refresh failed: {err}"),
}
match game_store.count_ongoing().await {
Ok(count) => ongoing_games.store(count as i64, Ordering::Relaxed),
Err(err) => tracing::warn!("ongoing_games refresh failed: {err}"),
}
let process = process.clone();
let _ =
tokio::task::spawn_blocking(move || refresh_process(&process, pid)).await;
tokio::time::sleep(poll_interval).await;
}
}
});
Ok(Some(super::Telemetry {
inner: OtelInner {
tracer_provider,
meter_provider,
logger_provider,
poll_task,
},
}))
}
}
/// The live telemetry pipeline; dropping it shuts the providers down.
pub struct Telemetry {
#[cfg(feature = "otel")]
inner: inner::OtelInner,
}
#[cfg(feature = "otel")]
impl Telemetry {
pub fn tracer(&self) -> opentelemetry_sdk::trace::SdkTracer {
use opentelemetry::trace::TracerProvider as _;
self.inner.tracer_provider.tracer("tavolo")
}
pub fn logger_provider(&self) -> &opentelemetry_sdk::logs::SdkLoggerProvider {
&self.inner.logger_provider
}
}
/// Initialize the telemetry pipeline when `OTEL_ENABLED` is set.
pub fn init(
settings: &Settings,
session_store: SharedSessionStore,
game_store: SharedGameStore,
) -> Result<Option<Telemetry>, String> {
if !settings.otel_enabled {
return Ok(None);
}
#[cfg(feature = "otel")]
{
inner::init(settings, session_store, game_store)
}
#[cfg(not(feature = "otel"))]
{
let _ = (session_store, game_store);
Err("OTEL_ENABLED is set but the binary was built without the 'otel' feature".to_string())
}
}
/// Tag a span with the hashed session id and the authenticated user.
pub fn session_attributes(
session: &tavolo_platform::session::SessionData,
) -> (Option<String>, Option<String>) {
let session_id = session.id().map(hash_session_id);
let user = tavolo_platform::auth::session_user(session).map(|user| user.sub);
(session_id, user)
}
/// Truncated SHA-256 of the session id (the cookie is a credential, so it is
/// never exported verbatim).
pub fn hash_session_id(session_id: &str) -> String {
tavolo_platform::auth::session_tag(session_id)
}
/// An HTTP span per request, tagged with session/user; excludes the paths in
/// `OTEL_EXCLUDED_PATHS`. Also records the HTTP server duration histogram.
pub async fn trace_middleware(
excluded: Arc<Vec<String>>,
request: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
use tracing::Instrument;
let path = request.uri().path().to_string();
if excluded.contains(&path) {
return next.run(request).await;
}
let method = request.method().clone();
let span = tracing::info_span!(
"http.server.request",
"http.request.method" = %method,
"url.path" = %path,
"http.response.status_code" = tracing::field::Empty,
"session.id" = tracing::field::Empty,
"user.id" = tracing::field::Empty,
);
let session = request
.extensions()
.get::<tavolo_platform::session::SessionHandle>()
.cloned();
async move {
let started = std::time::Instant::now();
let response = next.run(request).await;
let status = response.status().as_u16();
tracing::Span::current().record("http.response.status_code", status);
// Recorded after the handler ran so logins/mutations are visible;
// the session middleware mints the cookie (and its id on first use)
// after this layer returns.
if let Some(session) = session {
let guard = session.lock().await;
let (session_id, user) = session_attributes(&guard);
if let Some(session_id) = session_id {
tracing::Span::current().record("session.id", session_id);
}
if let Some(user) = user {
tracing::Span::current().record("user.id", user);
}
}
record_http_duration(started.elapsed(), &method, status);
response
}
.instrument(span)
.await
}
fn record_http_duration(duration: std::time::Duration, method: &axum::http::Method, status: u16) {
#[cfg(feature = "otel")]
{
if let Some(histogram) = inner::HTTP_DURATION.get() {
histogram.record(
duration.as_secs_f64(),
&[
opentelemetry::KeyValue::new("http.request.method", method.to_string()),
opentelemetry::KeyValue::new("http.response.status_code", status as i64),
],
);
}
}
#[cfg(not(feature = "otel"))]
{
let _ = (duration, method, status);
}
}
+319
View File
@@ -0,0 +1,319 @@
//! End-to-end OIDC login flow against a mock provider.
use std::net::SocketAddr;
use std::sync::Arc;
use axum::extract::{Query, State};
use axum::routing::{get, post};
use axum::{Json, Router};
use chrono::Utc;
use serde_json::{json, Value};
use tokio::sync::Mutex;
use tower::ServiceExt;
use tavolo_app::app;
use tavolo_app::config::Settings;
use tavolo_platform::oidc::OidcProviderConfig;
const RSA_PEM: &str = r#"-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCMKcE4AgsStq5u
l3Rtg9p2XNN5l5N5z1orqDJmRHAJvSNqhr2BHxu5Q1m3o8prvE+lHRqe1lgJXKi5
8aITv6hDKkS9gmp+tvVQ/VJnnKSbgaSJSMEf5P5GoLR43SrsTEtbxlVO03kwCkiH
EoOihK0h4GgyIE/3UWkgsJ8eJKi+czBzBffX8+qfR29vJGEk1mhpHIBnHI+19pLV
q0GvMcqKA4hnTZwB43/8EI6lZb59r1ZJLvz87AKd/VnXW4zki16PHntkrrRfRl+U
Q5k+ErSYp0SXtyh9mONzxGDQ+ra7gIx8itTnSAbYTwi846UzSTltCCYqTwkvi4nm
aCmKn3mdAgMBAAECggEARUGKegC0dPo0JWwLuONKSmriiXWmhq04qlKxGIwfagh9
W/EY6Ri0a63GQnGZnvNMzVKcqThTBsk0i7BD/Asa+pkDnzo2IceIXozVL2IcicQc
x0LePkK5i69zjTcHMvwdzkqpM0pV2rFyFd/FkEOLgq4t/fquWI9u6RqsfMnzYSqx
XxS4JsC6dEgNK69lH2OTWvmpgpujrwWAiJVNlKY0Ehh0R8Wruy7iPjo6NLWH8sQq
XlCxIoeWCcPgCbtQRmLgNJPdFB8zSTYGFqGJ5lfYq/7qkcQsAEwIUJBF+EMU+yK5
y8jxiGF+8wDg4bYovNxs0uxU/HltHPGDfe1Yn2OyNwKBgQDA7GNsj4hxDME0rXnI
8SMnSjOVQRsmNTa6Je3XKXuTxzqXwYBndILmTYOmejYnQJ7oToAn1Jiyk9yo+Mzd
2sxKU9iHMVABuLiFmbUm269WB2Zh7fQ7ErhIVGTNsPsHv2iuSuOktGQw6Cd+9pBH
0H8HCf9dWOzQ01lqdKw60UfH2wKBgQC5/VnnCtQ4n+aAzSYLYgZf97XTTKN2+gGq
z2caPga/3yA+1dPx18JjVqlEuAxazXQPxgAL2ZnGHQfLR+M0lInHhljrALKHTzMl
MsmLTq+J8DChlGIPJIKc0Uk4vLSJLMkaHBS9gM9z49zdGx2ltI4gGUJIKk4z5gT0
iqRFXM1Z5wKBgAuOna4lMxFCqFhVhBQrjeWOpmRskAnL6klgeE0JaSPDLZlluCF6
c1UEk7xnWdMkJeDYwwifzb306aUdsngf4VkaiMAgjiBsRnGKcbWwN2K58sIvFRBs
4KlElP1VetRN/SFjzxtQyye64JLUhnQm41Uynh+jchxYbOTZtG+Zlek9AoGADPsP
sT5iPWl7jvXCd/zpcP065Xpt1jnwr9k12vEFS5LpDzmhRznFyWUc/zzRL+0y8n7h
NfKD9v3Ds35a7yqGyzQnNI+h5iuy6B+5TU26lqf63OwcQ+5KZ2N79sfE2eYUSww2
rls1H2OB4LSpxwzo9niM1biOKLUfF2otuOGXO3UCgYBIDAvpabTpw6z4IBQGwHKP
WOTFrtI/y0Zqf3iNusqb8RdCIokDHvT+fKdvI7VXzaASWt2GajMHArWB6uu/1Xeh
nF9SaK1F+mKyGv9a5m5We9VzRNXmaUaQBRLK3XjBr1Co4/UduYRDdqZo/E/5g8gr
BMZbIcEMr49N2ns4yHzJ3w==
-----END PRIVATE KEY-----
"#;
const JWK: &str = r#"{"kty":"RSA","use":"sig","alg":"RS256","kid":"test-key","n":"jCnBOAILEraubpd0bYPadlzTeZeTec9aK6gyZkRwCb0jaoa9gR8buUNZt6PKa7xPpR0antZYCVyoufGiE7-oQypEvYJqfrb1UP1SZ5ykm4GkiUjBH-T-RqC0eN0q7ExLW8ZVTtN5MApIhxKDooStIeBoMiBP91FpILCfHiSovnMwcwX31_Pqn0dvbyRhJNZoaRyAZxyPtfaS1atBrzHKigOIZ02cAeN__BCOpWW-fa9WSS78_OwCnf1Z11uM5Itejx57ZK60X0ZflEOZPhK0mKdEl7cofZjjc8Rg0Pq2u4CMfIrU50gG2E8IvOOlM0k5bQgmKk8JL4uJ5mgpip95nQ","e":"AQAB"}"#;
#[derive(Clone, Default)]
struct IdpState {
issuer: String,
nonce: Arc<Mutex<String>>,
}
struct Idp {
state: IdpState,
addr: SocketAddr,
}
async fn start_idp() -> Idp {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind idp");
let addr = listener.local_addr().expect("idp addr");
let base = format!("http://{addr}");
let state = IdpState {
issuer: base.clone(),
..Default::default()
};
let router = Router::new()
.route(
"/.well-known/openid-configuration",
get({
let base = base.clone();
move || async move {
Json(json!({
"issuer": base,
"authorization_endpoint": format!("{base}/authorize"),
"token_endpoint": format!("{base}/token"),
"userinfo_endpoint": format!("{base}/userinfo"),
"jwks_uri": format!("{base}/jwks"),
}))
}
}),
)
.route(
"/authorize",
get(
|State(state): State<IdpState>,
Query(params): Query<std::collections::HashMap<String, String>>| async move {
if let Some(nonce) = params.get("nonce") {
*state.nonce.lock().await = nonce.clone();
}
let state_param = params.get("state").cloned().unwrap_or_default();
let redirect_uri = params
.get("redirect_uri")
.cloned()
.unwrap_or_else(|| "/".into());
let location = format!("{redirect_uri}?code=test-code&state={state_param}");
(
axum::http::StatusCode::FOUND,
[(axum::http::header::LOCATION, location)],
)
},
),
)
.route(
"/token",
post(|State(state): State<IdpState>| async move {
let nonce = state.nonce.lock().await.clone();
let now = Utc::now().timestamp();
let claims = json!({
"iss": state.issuer,
"aud": "tavolo",
"sub": "alice",
"exp": now + 600,
"iat": now,
"nonce": nonce,
});
let token = jsonwebtoken::encode(
&jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256),
&claims,
&jsonwebtoken::EncodingKey::from_rsa_pem(RSA_PEM.as_bytes()).unwrap(),
)
.unwrap();
Json(json!({
"access_token": "access-token",
"token_type": "Bearer",
"id_token": token,
}))
}),
)
.route(
"/userinfo",
get(|| async { Json(json!({"sub": "alice", "preferred_username": "Alice"})) }),
)
.route(
"/jwks",
get(|| async { Json(json!({"keys": [serde_json::from_str::<Value>(JWK).unwrap()]})) }),
)
.with_state(state.clone());
tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
Idp { state, addr }
}
fn test_settings(issuer: &str, redirect_uri: &str) -> Settings {
Settings {
database_url: "postgres://unused".into(),
oidc_providers: vec![OidcProviderConfig {
provider: "default".into(),
issuer: issuer.into(),
client_id: "tavolo".into(),
client_secret: Some("dev-secret".into()),
redirect_uri: redirect_uri.into(),
post_login_redirect: "/".into(),
post_logout_redirect: "/".into(),
}],
app_host: "127.0.0.1".into(),
app_port: 0,
redis_url: None,
game_ttl_seconds: 86400,
static_dir: "/nonexistent-web-dist".into(),
hand_ack_timeout_seconds: 30,
turn_timeout_seconds: 30,
deadline_heartbeat_ms: 50,
logging_config: None,
cors_allow_origins: None,
cors_allow_origin_regex: None,
cors_allow_methods: None,
cors_allow_headers: None,
cors_allow_credentials: false,
cors_expose_headers: None,
cors_max_age: 600,
otel_enabled: false,
otel_service_name: "tavolo".into(),
otel_exporter_endpoint: None,
otel_exporter_headers: None,
otel_excluded_paths: vec!["/api/health".into()],
otel_metric_export_interval_millis: 60000,
otel_metrics_poll_interval_seconds: 15,
}
}
fn cookie_header(response: &axum::response::Response) -> String {
let value = response
.headers()
.get(axum::http::header::SET_COOKIE)
.expect("set-cookie")
.to_str()
.expect("cookie header");
value.split(';').next().expect("cookie pair").to_string()
}
#[tokio::test]
async fn oidc_login_callback_and_logout_flow() {
// The mock IdP must know its own issuer before we can start it, so bind
// first and rebuild the settings from the recorded address.
let idp = start_idp().await;
let issuer = format!("http://{}", idp.addr);
let callback = "http://localhost:8080/auth/default/callback".to_string();
let settings = test_settings(&issuer, &callback);
let (platform, session_store) = app::in_memory_platform(&settings).expect("platform");
let router = app::build_router(platform, session_store.clone(), &settings);
// 1. Start the login: a 302 to the provider with state/nonce/PKCE and a
// fresh session cookie.
let response = router
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/auth/default/login")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 302);
let location = response.headers()["location"].to_str().unwrap().to_string();
let login_cookie = cookie_header(&response);
let url = url::Url::parse(&location).expect("authorization url");
let params: std::collections::HashMap<String, String> = url
.query_pairs()
.map(|(key, value)| (key.into_owned(), value.into_owned()))
.collect();
assert_eq!(params["client_id"], "tavolo");
assert_eq!(params["code_challenge_method"], "S256");
assert_eq!(params["scope"], "openid email profile");
assert_eq!(
params["redirect_uri"],
"http://localhost:8080/auth/default/callback"
);
let state = params["state"].clone();
// Simulate the browser visiting the provider: the mock signs the nonce
// the client generated.
*idp.state.nonce.lock().await = params["nonce"].clone();
// 2. The provider redirects back with the code and state.
let response = router
.clone()
.oneshot(
axum::http::Request::builder()
.uri(format!(
"/auth/default/callback?code=test-code&state={state}"
))
.header("cookie", &login_cookie)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 302);
assert_eq!(response.headers()["location"], "/");
let session_cookie = cookie_header(&response);
assert_ne!(
session_cookie, login_cookie,
"session id must rotate on login"
);
// A wrong state is rejected without touching the provider.
let response = router
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/auth/default/callback?code=test-code&state=wrong")
.header("cookie", &login_cookie)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 400);
// 3. The session is authenticated and /api/me reflects the userinfo.
let response = router
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/api/me")
.header("cookie", &session_cookie)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 200);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let me: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(me, json!({"sub": "alice", "name": "Alice"}));
// 4. Logout clears the session and sends an expired cookie.
let response = router
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/auth/default/logout")
.header("cookie", &session_cookie)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 302);
let clear_cookie = response
.headers()
.get(axum::http::header::SET_COOKIE)
.unwrap()
.to_str()
.unwrap();
assert!(
clear_cookie.contains("Max-Age=0"),
"cookie not cleared: {clear_cookie}"
);
}
@@ -0,0 +1,380 @@
//! Configuration parsing and CORS behavior of the app composition root.
use std::sync::Mutex;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
use tavolo_app::app;
use tavolo_app::config::Settings;
use tavolo_platform::oidc::OidcProviderConfig;
static ENV_LOCK: Mutex<()> = Mutex::new(());
const ENV_VARS: &[&str] = &[
"DATABASE_URL",
"DATABASE_ENGINE",
"DATABASE_HOST",
"DATABASE_PORT",
"DATABASE_NAME",
"DATABASE_USER",
"DATABASE_PASSWORD",
"DATABASE_OPTIONS",
"APP_HOST",
"APP_PORT",
"REDIS_URL",
"GAME_TTL_SECONDS",
"STATIC_DIR",
"HAND_ACK_TIMEOUT_SECONDS",
"TURN_TIMEOUT_SECONDS",
"DEADLINE_HEARTBEAT_MS",
"LOGGING_CONFIG",
"CORS_ALLOW_ORIGINS",
"CORS_ALLOW_ORIGIN_REGEX",
"CORS_ALLOW_METHODS",
"CORS_ALLOW_HEADERS",
"CORS_ALLOW_CREDENTIALS",
"CORS_EXPOSE_HEADERS",
"CORS_MAX_AGE",
"OTEL_ENABLED",
"OTEL_SERVICE_NAME",
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_HEADERS",
"OTEL_EXCLUDED_PATHS",
"OTEL_METRIC_EXPORT_INTERVAL_MILLIS",
"OTEL_METRICS_POLL_INTERVAL_SECONDS",
"OIDC_ISSUER",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_REDIRECT_URI",
"OIDC_POST_LOGIN_REDIRECT",
"OIDC_POST_LOGOUT_REDIRECT",
];
fn clear_oidc_env() {
for (name, _) in std::env::vars() {
if name.starts_with("OIDC_") {
std::env::remove_var(&name);
}
}
for name in ENV_VARS {
std::env::remove_var(name);
}
}
fn with_env<T>(vars: &[(&str, &str)], f: impl FnOnce() -> T) -> T {
let _guard = ENV_LOCK.lock().expect("env lock");
clear_oidc_env();
for (name, value) in vars {
std::env::set_var(name, value);
}
let result = f();
clear_oidc_env();
result
}
#[test]
fn defaults_match_the_python_settings() {
let settings = with_env(&[], Settings::from_env).expect("settings");
assert_eq!(settings.app_host, "0.0.0.0");
assert_eq!(settings.app_port, 8080);
assert_eq!(settings.game_ttl_seconds, 86400);
assert_eq!(settings.static_dir, "web/dist");
assert_eq!(settings.oidc_providers.len(), 1);
let provider = &settings.oidc_providers[0];
assert_eq!(provider.provider, "default");
assert_eq!(provider.issuer, "http://localhost:8180/tavolo");
assert_eq!(provider.client_id, "tavolo");
assert_eq!(
provider.redirect_uri,
"http://localhost:8080/auth/default/callback"
);
assert_eq!(
settings.database_url,
"postgres://tavolo:password@localhost/tavolo"
);
assert!(!settings.otel_enabled);
assert_eq!(settings.otel_excluded_paths, vec!["/api/health"]);
}
#[test]
fn indexed_oidc_providers_are_parsed_in_order() {
let settings = with_env(
&[
("OIDC_0_PROVIDER", "google"),
("OIDC_0_ISSUER", "https://accounts.google.com"),
("OIDC_0_CLIENT_ID", "gid"),
("OIDC_0_CLIENT_SECRET", "gsecret"),
("OIDC_1_PROVIDER", "github"),
("OIDC_1_ISSUER", "https://github.com/login/oauth"),
("OIDC_1_CLIENT_ID", "ghid"),
],
Settings::from_env,
)
.expect("settings");
assert_eq!(settings.oidc_providers.len(), 2);
assert_eq!(settings.oidc_providers[0].provider, "google");
assert_eq!(
settings.oidc_providers[0].redirect_uri,
"http://localhost:8080/auth/google/callback"
);
assert_eq!(
settings.oidc_providers[0].client_secret.as_deref(),
Some("gsecret")
);
assert_eq!(settings.oidc_providers[1].provider, "github");
assert!(settings.oidc_providers[1].client_secret.is_none());
}
#[test]
fn invalid_oidc_configurations_are_rejected() {
let error = with_env(
&[
("OIDC_0_PROVIDER", "a"),
("OIDC_0_ISSUER", "https://x"),
("OIDC_0_CLIENT_ID", "x"),
("OIDC_2_PROVIDER", "b"),
("OIDC_2_ISSUER", "https://y"),
("OIDC_2_CLIENT_ID", "y"),
],
Settings::from_env,
)
.expect_err("missing index must fail");
assert!(error.contains("contiguous"), "{error}");
let error = with_env(&[("OIDC_ISSUER", "https://legacy")], Settings::from_env)
.expect_err("legacy variables must fail");
assert!(error.contains("indexed per provider"), "{error}");
let error = with_env(
&[
("OIDC_0_PROVIDER", "bad name!"),
("OIDC_0_ISSUER", "https://x"),
("OIDC_0_CLIENT_ID", "x"),
],
Settings::from_env,
)
.expect_err("invalid provider names must fail");
assert!(error.contains("letters, digits"), "{error}");
let error = with_env(
&[
("OIDC_0_PROVIDER", "a"),
("OIDC_0_ISSUER", "https://x"),
("OIDC_0_CLIENT_ID", "x"),
("OIDC_0_WAT", "?"),
],
Settings::from_env,
)
.expect_err("unknown variables must fail");
assert!(
error.contains("unknown OIDC environment variable"),
"{error}"
);
}
#[test]
fn database_dsn_quotes_credentials() {
let settings = with_env(
&[
("DATABASE_USER", "ta:v@lo"),
("DATABASE_PASSWORD", "p@ss word"),
("DATABASE_HOST", "db.example"),
("DATABASE_PORT", "5433"),
("DATABASE_NAME", "tavolo"),
("DATABASE_OPTIONS", "ssl=require"),
],
Settings::from_env,
)
.expect("settings");
assert_eq!(
settings.database_url,
"postgres://ta%3Av%40lo:p%40ss%20word@db.example:5433/tavolo?ssl=require"
);
}
#[tokio::test]
async fn cors_is_disabled_without_configuration() {
let settings = base_settings();
let (platform, sessions) = app::in_memory_platform(&settings).expect("platform");
let router = app::build_router(platform, sessions, &settings);
let response = router
.clone()
.oneshot(
Request::builder()
.uri("/api/health")
.header("origin", "https://cards.example")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(response
.headers()
.get("access-control-allow-origin")
.is_none());
}
#[tokio::test]
async fn cors_allows_configured_origins_and_regexes() {
let mut settings = base_settings();
settings.cors_allow_origins = Some(vec!["https://cards.example".into()]);
settings.cors_allow_origin_regex = Some(r"https://preview-[a-z0-9-]+\.example\.com".into());
settings.cors_allow_methods = Some(vec!["GET".into(), "POST".into()]);
settings.cors_max_age = 3600;
let (platform, sessions) = app::in_memory_platform(&settings).expect("platform");
let router = app::build_router(platform, sessions, &settings);
// Allowed origin: echoed. Disallowed origin: no header, request still runs.
let response = router
.clone()
.oneshot(
Request::builder()
.uri("/api/health")
.header("origin", "https://cards.example")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.headers()["access-control-allow-origin"],
"https://cards.example"
);
let response = router
.clone()
.oneshot(
Request::builder()
.uri("/api/health")
.header("origin", "https://preview-abc.example.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.headers()["access-control-allow-origin"],
"https://preview-abc.example.com"
);
let response = router
.clone()
.oneshot(
Request::builder()
.uri("/api/health")
.header("origin", "https://mallory.example")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(response
.headers()
.get("access-control-allow-origin")
.is_none());
// Preflight.
let response = router
.clone()
.oneshot(
Request::builder()
.method("OPTIONS")
.uri("/api/health")
.header("origin", "https://cards.example")
.header("access-control-request-method", "POST")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let methods = response.headers()["access-control-allow-methods"]
.to_str()
.unwrap()
.to_string();
assert!(methods.contains("POST"), "{methods}");
assert_eq!(response.headers()["access-control-max-age"], "3600");
let response = router
.clone()
.oneshot(
Request::builder()
.method("OPTIONS")
.uri("/api/health")
.header("origin", "https://mallory.example")
.header("access-control-request-method", "GET")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn cors_credentials_echo_the_origin_for_wildcards() {
let mut settings = base_settings();
settings.cors_allow_origins = Some(vec!["*".into()]);
settings.cors_allow_credentials = true;
let (platform, sessions) = app::in_memory_platform(&settings).expect("platform");
let router = app::build_router(platform, sessions, &settings);
let response = router
.clone()
.oneshot(
Request::builder()
.uri("/api/health")
.header("origin", "https://cards.example")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.headers()["access-control-allow-origin"],
"https://cards.example"
);
assert_eq!(
response.headers()["access-control-allow-credentials"],
"true"
);
}
fn base_settings() -> Settings {
Settings {
database_url: "postgres://unused".into(),
oidc_providers: vec![OidcProviderConfig {
provider: "default".into(),
issuer: "http://localhost:8180/tavolo".into(),
client_id: "tavolo".into(),
client_secret: None,
redirect_uri: "http://localhost:8080/auth/default/callback".into(),
post_login_redirect: "/".into(),
post_logout_redirect: "/".into(),
}],
app_host: "127.0.0.1".into(),
app_port: 0,
redis_url: None,
game_ttl_seconds: 86400,
static_dir: "/nonexistent-web-dist".into(),
hand_ack_timeout_seconds: 30,
turn_timeout_seconds: 30,
deadline_heartbeat_ms: 50,
logging_config: None,
cors_allow_origins: None,
cors_allow_origin_regex: None,
cors_allow_methods: None,
cors_allow_headers: None,
cors_allow_credentials: false,
cors_expose_headers: None,
cors_max_age: 600,
otel_enabled: false,
otel_service_name: "tavolo".into(),
otel_exporter_endpoint: None,
otel_exporter_headers: None,
otel_excluded_paths: vec!["/api/health".into()],
otel_metric_export_interval_millis: 60000,
otel_metrics_poll_interval_seconds: 15,
}
}
@@ -0,0 +1,320 @@
//! End-to-end scopone flow: lobby, four players, live play over the
//! WebSocket and the turn-timeout auto-play, plus SPA/static hosting.
use std::net::SocketAddr;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use futures_util::{SinkExt, StreamExt};
use serde_json::{json, Map, Value};
use tower::ServiceExt;
use tavolo_app::app;
use tavolo_app::config::Settings;
use tavolo_platform::oidc::OidcProviderConfig;
use tavolo_platform::session::SharedSessionStore;
const PLAYERS: [&str; 4] = ["alice", "bob", "carol", "dave"];
fn settings(static_dir: &str, turn_timeout: i64) -> Settings {
Settings {
database_url: "postgres://unused".into(),
oidc_providers: vec![OidcProviderConfig {
provider: "default".into(),
issuer: "http://localhost:8180/tavolo".into(),
client_id: "tavolo".into(),
client_secret: None,
redirect_uri: "http://localhost:8080/auth/default/callback".into(),
post_login_redirect: "/".into(),
post_logout_redirect: "/".into(),
}],
app_host: "127.0.0.1".into(),
app_port: 0,
redis_url: None,
game_ttl_seconds: 86400,
static_dir: static_dir.into(),
hand_ack_timeout_seconds: 30,
turn_timeout_seconds: turn_timeout,
deadline_heartbeat_ms: 50,
logging_config: None,
cors_allow_origins: None,
cors_allow_origin_regex: None,
cors_allow_methods: None,
cors_allow_headers: None,
cors_allow_credentials: false,
cors_expose_headers: None,
cors_max_age: 600,
otel_enabled: false,
otel_service_name: "tavolo".into(),
otel_exporter_endpoint: None,
otel_exporter_headers: None,
otel_excluded_paths: vec!["/api/health".into()],
otel_metric_export_interval_millis: 60000,
otel_metrics_poll_interval_seconds: 15,
}
}
async fn login(store: &SharedSessionStore, sub: &str, name: &str) -> String {
let id = format!("session-{sub}");
let mut data = Map::new();
data.insert(
"oidc:default:user".into(),
json!({"sub": sub, "preferred_username": name}),
);
store.save(&id, &data, Some(3600)).await.expect("session");
format!("session_id={id}")
}
async fn body_json(response: axum::response::Response) -> Value {
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
serde_json::from_slice(&bytes).expect("json")
}
fn post(uri: &str, cookie: &str, body: &Value) -> Request<Body> {
Request::builder()
.method("POST")
.uri(uri)
.header("cookie", cookie)
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(body).unwrap()))
.unwrap()
}
fn get(uri: &str, cookie: Option<&str>) -> Request<Body> {
let mut builder = Request::builder().uri(uri);
if let Some(cookie) = cookie {
builder = builder.header("cookie", cookie);
}
builder.body(Body::empty()).unwrap()
}
async fn start_server(router: axum::Router) -> SocketAddr {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let addr = listener.local_addr().expect("addr");
tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
addr
}
fn ws_request(addr: SocketAddr, game_id: &str, cookie: &str) -> Request<()> {
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
let mut request = format!("ws://{addr}/ws/games/{game_id}")
.into_client_request()
.expect("request");
request.headers_mut().insert(
"cookie",
axum::http::HeaderValue::from_str(cookie).expect("cookie"),
);
request
}
async fn read_json(
socket: &mut tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
) -> Value {
let message = socket.next().await.expect("message").expect("ok");
match message {
tokio_tungstenite::tungstenite::Message::Text(text) => {
serde_json::from_str(&text).expect("json")
}
other => panic!("expected text, got {other:?}"),
}
}
#[tokio::test]
async fn scopone_lobby_play_and_turn_timeout() {
let temp = std::env::temp_dir().join(format!("tavolo-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(temp.join("assets")).expect("temp dir");
let test_settings = settings(temp.to_str().unwrap(), 1);
let (platform, sessions) = app::in_memory_platform(&test_settings).expect("platform");
let router = app::build_router(platform, sessions.clone(), &test_settings);
// Create the game as alice and join with the other three players.
let alice = login(&sessions, "alice", "Alice").await;
let created = router
.clone()
.oneshot(post(
"/api/games",
&alice,
&json!({"options": {"target_score": 11}}),
))
.await
.unwrap();
assert_eq!(created.status(), StatusCode::CREATED);
let lobby = body_json(created).await;
let game_id = lobby["id"].as_str().unwrap().to_string();
let code = lobby["join_code"].as_str().unwrap().to_string();
assert_eq!(lobby["seats_open"], 3);
assert_eq!(lobby["players"][0]["team"], "A");
for (index, player) in PLAYERS.iter().enumerate().skip(1) {
let cookie = login(&sessions, player, &player.to_uppercase()).await;
let response = router
.clone()
.oneshot(post("/api/games/join", &cookie, &json!({"code": code})))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK, "player {player}");
let body = body_json(response).await;
assert_eq!(body["players"].as_array().unwrap().len(), index + 1);
if index == 3 {
assert_eq!(body["phase"], "playing");
assert_eq!(body["hand_number"], 1);
assert_eq!(body["dealer"], 0);
assert_eq!(body["turn"], 1);
assert_eq!(body["players"][index]["hand"].as_array().unwrap().len(), 10);
}
}
// Bob is on turn (seat 1): his hand is visible to him and hidden from
// alice's personalized view.
let bob = login(&sessions, "bob", "Bob").await;
let bob_view = body_json(
router
.clone()
.oneshot(get(&format!("/api/games/{game_id}"), Some(&bob)))
.await
.unwrap(),
)
.await;
assert_eq!(bob_view["turn"], 1);
assert_eq!(bob_view["your_turn"], true);
assert_eq!(bob_view["players"][1]["hand"].as_array().unwrap().len(), 10);
assert!(bob_view["players"][0].get("hand").is_none());
assert!(bob_view["legal_moves"].is_object());
let alice_view = body_json(
router
.clone()
.oneshot(get(&format!("/api/games/{game_id}"), Some(&alice)))
.await
.unwrap(),
)
.await;
assert_eq!(
alice_view["players"][0]["hand"].as_array().unwrap().len(),
10
);
assert!(alice_view["players"][1].get("hand").is_none());
let addr = start_server(router.clone()).await;
let (mut ws, _) = tokio_tungstenite::connect_async(ws_request(addr, &game_id, &bob))
.await
.expect("bob connects");
let state = read_json(&mut ws).await;
assert_eq!(state["type"], "state");
// The table starts empty and the turn belongs to bob (seat 1).
assert_eq!(state["game"]["turn"], 1);
assert_eq!(state["game"]["table"].as_array().unwrap().len(), 0);
// Bob is on turn on the websocket view.
assert_eq!(state["game"]["your_turn"], true);
let hand = state["game"]["players"][1]["hand"]
.as_array()
.unwrap()
.clone();
let card = hand[0].as_str().unwrap().to_string();
let legal = state["game"]["legal_moves"]
.as_object()
.and_then(|moves| moves.get(&card))
.cloned();
let mut message = json!({"action": "play", "card": card});
if let Some(legal) = legal {
if let Some(option) = legal.as_array().and_then(|options| options.first()) {
message["capture"] = option.clone();
}
}
ws.send(tokio_tungstenite::tungstenite::Message::Text(
message.to_string(),
))
.await
.expect("send play");
let update = read_json(&mut ws).await;
assert_eq!(update["type"], "state");
assert_eq!(update["game"]["turn"], 2);
assert_eq!(update["game"]["last_move"]["seat"], 1);
assert_eq!(update["game"]["last_move"]["card"], card);
// Carol (seat 2) is on turn now with a 1s timeout: the server auto-plays.
let before = update["game"]["players"][2]["cards_left"].as_u64().unwrap();
tokio::time::sleep(std::time::Duration::from_millis(1800)).await;
let after = body_json(
router
.clone()
.oneshot(get(&format!("/api/games/{game_id}"), Some(&alice)))
.await
.unwrap(),
)
.await;
assert_eq!(after["turn"], 3);
assert_eq!(
after["players"][2]["cards_left"].as_u64().unwrap(),
before - 1
);
assert_eq!(after["last_move"]["seat"], 2);
}
#[tokio::test]
async fn spa_shell_and_static_mounts() {
let temp = std::env::temp_dir().join(format!("tavolo-static-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(temp.join("assets")).expect("temp dir");
std::fs::write(temp.join("index.html"), "<html>app shell</html>").expect("index");
std::fs::write(temp.join("assets/card.png"), b"png-bytes").expect("asset");
let test_settings = settings(temp.to_str().unwrap(), 30);
let (platform, sessions) = app::in_memory_platform(&test_settings).expect("platform");
let router = app::build_router(platform, sessions, &test_settings);
let response = router.clone().oneshot(get("/", None)).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers()["content-type"],
"text/html; charset=utf-8"
);
let response = router
.clone()
.oneshot(get("/game/abc-123", None))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK, "SPA fallback");
let response = router
.clone()
.oneshot(get("/static/index.html", None))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let response = router
.clone()
.oneshot(get("/assets/card.png", None))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(&bytes[..], b"png-bytes");
// Unknown API paths also fall back to the SPA shell, like kaya's glob;
// an unknown OIDC provider path (no route registered) does too.
let response = router
.clone()
.oneshot(get("/api/does-not-exist", None))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let response = router
.clone()
.oneshot(get("/auth/nope/login", None))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
std::fs::remove_dir_all(temp).ok();
}
+36
View File
@@ -0,0 +1,36 @@
[package]
name = "tavolo-platform"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Game-independent half of the tavolo platform: lobby, live play, history, ratings"
[dependencies]
async-trait.workspace = true
axum.workspace = true
base64.workspace = true
chrono.workspace = true
futures-util.workspace = true
jsonwebtoken.workspace = true
rand.workspace = true
redis.workspace = true
regex.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
sqlx.workspace = true
thiserror.workspace = true
tokio.workspace = true
tower.workspace = true
tracing.workspace = true
url.workspace = true
uuid.workspace = true
[dev-dependencies]
axum = { workspace = true }
futures-util = { workspace = true }
http-body-util = "0.1"
tokio = { workspace = true }
tokio-tungstenite = "0.24"
tower = { workspace = true }
+79
View File
@@ -0,0 +1,79 @@
//! Authentication helpers on top of the session store.
//!
//! 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.
//!
//! kaya-oidc namespaces its session state per provider as
//! `oidc:{provider}:user`, so the lookup scans for any provider's user.
use serde_json::{Map, Value};
use crate::session::{SessionData, SessionHandle};
const OIDC_PREFIX: &str = "oidc:";
const USER_SUFFIX: &str = ":user";
/// Read-only view of the OIDC userinfo / ID token claims.
#[derive(Debug, Clone)]
pub struct User {
pub sub: String,
pub claims: Map<String, Value>,
}
/// Return the authenticated user stored in a session, if any.
pub fn session_user(session: &SessionData) -> Option<User> {
for key in session.keys() {
if key.starts_with(OIDC_PREFIX) && key.ends_with(USER_SUFFIX) {
if let Some(Value::Object(claims)) = session.get(key) {
let sub = claims.get("sub").and_then(Value::as_str)?.to_string();
return Some(User {
sub,
claims: claims.clone(),
});
}
}
}
None
}
/// Return the authenticated user of a request, if any.
pub async fn current_user(session: &SessionHandle) -> Option<User> {
let session = session.lock().await;
session_user(&session)
}
/// Best-effort human-readable name for a user.
pub fn display_name(user: &User) -> String {
for key in ["name", "preferred_username", "email"] {
if let Some(Value::String(value)) = user.claims.get(key) {
if !value.is_empty() {
return value.clone();
}
}
}
user.sub.clone()
}
/// Truncated SHA-256 of a session id; the cookie is a credential, so it is
/// never exported verbatim (used for span attributes).
pub fn session_tag(session_id: &str) -> String {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(session_id.as_bytes());
let hex = format!("{digest:x}");
hex[..16].to_string()
}
/// The names of every provider with a user in this session.
pub fn logged_in_providers(session: &SessionData) -> Vec<String> {
let mut providers = Vec::new();
for key in session.keys() {
if key.starts_with(OIDC_PREFIX) && key.ends_with(USER_SUFFIX) {
let provider = &key[OIDC_PREFIX.len()..key.len() - USER_SUFFIX.len()];
if !provider.is_empty() {
providers.push(provider.to_string());
}
}
}
providers
}
@@ -0,0 +1,64 @@
//! ISO-8601 datetime helpers matching Python's `datetime.isoformat()` /
//! `datetime.fromisoformat()` behavior for UTC timestamps.
use chrono::{DateTime, NaiveDateTime, Utc};
/// Format a UTC timestamp the way Python's `datetime.isoformat()` does for an
/// aware UTC datetime: microseconds are included only when non-zero, and the
/// UTC offset is rendered as `+00:00` (never `Z`).
pub fn to_iso(dt: DateTime<Utc>) -> String {
if dt.timestamp_subsec_micros() == 0 {
dt.format("%Y-%m-%dT%H:%M:%S+00:00").to_string()
} else {
dt.format("%Y-%m-%dT%H:%M:%S%.6f+00:00").to_string()
}
}
/// Parse an ISO-8601 timestamp, tolerating both `Z` and explicit offsets.
///
/// Timestamps without an offset are interpreted as UTC (Python would treat
/// them as local time, but every timestamp this platform produces carries an
/// offset).
pub fn from_iso(value: &str) -> Option<DateTime<Utc>> {
if let Ok(parsed) = DateTime::parse_from_rfc3339(value) {
return Some(parsed.with_timezone(&Utc));
}
if let Ok(naive) = NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S%.f") {
return Some(DateTime::from_naive_utc_and_offset(naive, Utc));
}
None
}
/// Parse a JSON value carrying an ISO timestamp.
pub fn from_json(value: &serde_json::Value) -> Option<DateTime<Utc>> {
value.as_str().and_then(from_iso)
}
/// Serde `serialize_with` helper emitting Python-compatible ISO-8601.
pub fn serialize_iso<S>(dt: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&to_iso(*dt))
}
/// The current UTC instant, with microsecond precision.
pub fn now() -> DateTime<Utc> {
Utc::now()
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn round_trips_with_and_without_microseconds() {
let base = Utc.with_ymd_and_hms(2026, 9, 19, 6, 59, 17).unwrap();
assert_eq!(to_iso(base), "2026-09-19T06:59:17+00:00");
let dt = base + chrono::Duration::microseconds(123456);
assert_eq!(to_iso(dt), "2026-09-19T06:59:17.123456+00:00");
assert_eq!(from_iso("2026-09-19T06:59:17+00:00"), Some(base));
assert_eq!(from_iso("2026-09-19T06:59:17.123456Z"), Some(dt));
}
}
@@ -0,0 +1,250 @@
//! 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
//! [`GameEngine::next_deadline`](crate::engine::GameEngine::next_deadline)
//! computes the deadline the new state implies, and the scheduler enqueues
//! it in the store's shared deadline queue (a Redis sorted set in
//! production); enqueueing is idempotent;
//! * enqueueing a deadline announces it on a shared pub/sub channel so every
//! worker's consumer wakes immediately; the consumer also wakes on a slow
//! heartbeat, the backstop recovering lost notifications. Due entries are
//! handed to the engine's
//! [`GameEngine::fire_deadline`](crate::engine::GameEngine::fire_deadline)
//! under the per-game lock; the engine revalidates the token against the
//! live state, 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. Entries whose game has expired are dropped the
//! first time they fire.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use serde_json::json;
use tokio::sync::Notify;
use crate::engine::GameSession;
use crate::errors::{Error, Result};
use crate::registry::GameRegistry;
use crate::stats::SharedStatsRepo;
use crate::store::SharedGameStore;
pub struct DeadlineScheduler {
store: SharedGameStore,
registry: Arc<GameRegistry>,
stats: SharedStatsRepo,
heartbeat: Duration,
retry_delay: Duration,
wake: Notify,
started: AtomicBool,
}
impl DeadlineScheduler {
pub fn new(
store: SharedGameStore,
registry: Arc<GameRegistry>,
stats: SharedStatsRepo,
heartbeat_ms: u64,
retry_ms: u64,
) -> Arc<Self> {
Arc::new(Self {
store,
registry,
stats,
heartbeat: Duration::from_millis(heartbeat_ms),
retry_delay: Duration::from_millis(retry_ms),
wake: Notify::new(),
started: AtomicBool::new(false),
})
}
/// Start the consumer and listener tasks (idempotent).
pub fn start(self: &Arc<Self>) {
if self.started.swap(true, Ordering::SeqCst) {
return;
}
tokio::spawn(run_consumer(Arc::clone(self)));
tokio::spawn(run_listener(Arc::clone(self)));
tracing::debug!("deadline consumer started");
}
/// 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 changes
/// nothing.
pub async fn sync_deadline(self: &Arc<Self>, session: &GameSession) -> Result<()> {
let engine = self.registry.require(&session.game_type)?.clone();
let Some(deadline) = engine.next_deadline(session)? else {
return Ok(());
};
self.start();
let entry = json!({
"game_id": session.id,
"kind": deadline.kind,
"token": deadline.token,
});
let member = serde_json::to_string(&entry)?;
let due_at = deadline.due_at.timestamp() as f64
+ deadline.due_at.timestamp_subsec_micros() as f64 / 1e6;
self.store.add_deadline(&member, due_at).await?;
if let Err(err) = self.store.notify_deadline().await {
tracing::warn!(
"deadline enqueue notification failed; relying on the poll backstop: {err}"
);
}
self.wake.notify_waiters();
Ok(())
}
/// 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`).
pub async fn finalize_mutation(self: &Arc<Self>, session: &mut GameSession) -> Result<()> {
let engine = self.registry.require(&session.game_type)?.clone();
if engine.is_finished(session) {
if session.finished_at.is_none() {
session.finished_at = Some(crate::datetime::now());
}
if !session.stats_saved {
self.stats.save_match(session, engine.as_ref()).await?;
session.stats_saved = true;
}
tracing::info!("game {} ({}) finished", session.id, session.game_type);
}
self.store.save(session).await?;
self.store.publish(&session.id).await?;
self.sync_deadline(session).await?;
Ok(())
}
/// 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; if handling fails
/// unexpectedly (e.g. the lock cannot be acquired), the entry is left in
/// the queue so another consumer retries it.
pub async fn process_due(self: &Arc<Self>, member: &str) -> Result<()> {
let Ok(entry) = serde_json::from_str::<serde_json::Value>(member) else {
tracing::warn!("deadline consumer: dropping malformed entry {member:?}");
self.store.remove_deadline(member).await?;
return Ok(());
};
let game_id = entry.get("game_id").and_then(serde_json::Value::as_str);
let kind = entry.get("kind").and_then(serde_json::Value::as_str);
let token = entry
.get("token")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let (Some(game_id), Some(kind)) = (game_id, kind) else {
self.store.remove_deadline(member).await?;
return Ok(());
};
let lock = self.store.acquire_lock(game_id).await?;
let outcome = async {
let Some(mut session) = self.store.load(game_id).await? else {
return Ok(());
};
let engine = self.registry.require(&session.game_type)?.clone();
match engine.fire_deadline(&mut session, kind, token) {
Ok(()) => self.finalize_mutation(&mut session).await,
Err(err) if err.as_game().is_some() => {
// Stale or inapplicable entry: nothing to do.
Ok(())
}
Err(err) => Err(err),
}
}
.await;
lock.release().await?;
outcome?;
self.store.remove_deadline(member).await?;
Ok(())
}
}
async fn run_consumer(scheduler: Arc<DeadlineScheduler>) {
loop {
// Clear before polling so an enqueue racing the poll re-wakes us.
let mut delay = scheduler.heartbeat;
let mut failed = false;
match scheduler.store.due_deadlines(now_secs(), 32).await {
Ok(members) => {
for member in members {
match scheduler.process_due(&member).await {
Ok(()) => {}
Err(err) => {
tracing::error!(
"deadline consumer: failed to process {member:?}: {err}"
);
failed = true;
}
}
}
}
Err(err) => {
tracing::error!("deadline consumer: poll failed; retrying: {err}");
failed = true;
}
}
match scheduler.store.next_deadline().await {
Ok(Some(next_due)) => {
let until = (next_due - now_secs()).max(0.0);
delay = delay.min(Duration::from_secs_f64(until));
}
Ok(None) => {}
Err(err) => {
tracing::error!("deadline consumer: next_deadline failed: {err}");
failed = true;
}
}
if failed {
// A failed entry is still due: back off briefly instead of
// polling it again immediately.
delay = scheduler.retry_delay;
}
tokio::select! {
_ = scheduler.wake.notified() => {}
_ = tokio::time::sleep(delay) => {}
}
}
}
async fn run_listener(scheduler: Arc<DeadlineScheduler>) {
loop {
match scheduler.store.subscribe_deadlines().await {
Ok(mut events) => {
use futures_util::StreamExt;
while let Some(()) = events.next().await {
scheduler.wake.notify_waiters();
}
}
Err(err) => {
tracing::error!("deadline listener failed; reconnecting: {err}");
}
}
tokio::time::sleep(scheduler.retry_delay).await;
}
}
fn now_secs() -> f64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or_default()
}
/// Convenience constructor returning an error when the registry is empty.
pub fn scheduler_error() -> Error {
Error::Internal("deadline scheduler misconfigured".into())
}
+65
View File
@@ -0,0 +1,65 @@
//! Chess-style Elo ratings, generalized to two-team matches.
//!
//! Every player starts at [`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. Rounding uses Python's banker's rounding
//! (`round()` half-to-even), which Rust exposes as `f64::round_ties_even`.
pub const INITIAL_RATING: i64 = 1500;
pub const K_FACTOR: f64 = 32.0;
/// Expected score (0..1) of a side rated `rating` against `opponent_rating`.
pub fn expected_score(rating: f64, opponent_rating: f64) -> f64 {
1.0 / (1.0 + 10.0_f64.powf((opponent_rating - rating) / 400.0))
}
/// A team's rating is the mean of its members' ratings.
pub fn team_rating(ratings: &[f64]) -> Result<f64, &'static str> {
if ratings.is_empty() {
return Err("a team needs at least one rating");
}
Ok(ratings.iter().sum::<f64>() / ratings.len() as f64)
}
/// 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).
pub fn match_delta(team_a_ratings: &[f64], team_b_ratings: &[f64], winner_team: usize) -> i64 {
let rating_a = team_rating(team_a_ratings).unwrap_or_default();
let rating_b = team_rating(team_b_ratings).unwrap_or_default();
let expected = expected_score(rating_a, rating_b);
let score = if winner_team == 0 { 1.0 } else { 0.0 };
(K_FACTOR * (score - expected)).round_ties_even() as i64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn equal_ratings_move_16_points() {
assert_eq!(match_delta(&[1500.0], &[1500.0], 0), 16);
assert_eq!(match_delta(&[1500.0], &[1500.0], 1), -16);
}
#[test]
fn team_rating_is_the_mean() {
assert_eq!(team_rating(&[1400.0, 1600.0]).unwrap(), 1500.0);
assert!(team_rating(&[]).is_err());
}
#[test]
fn banker_rounding_matches_python() {
// Python: round(32 * (1 - 0.5)) == 16; ties-to-even for exact halves.
assert_eq!((16.5_f64).round_ties_even(), 16.0);
assert_eq!((17.5_f64).round_ties_even(), 18.0);
}
}
+335
View File
@@ -0,0 +1,335 @@
//! 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 [`GameEngine`] and plugs into
//! the platform through [`crate::registry::GameRegistry`]; the platform
//! never imports a concrete game module.
//!
//! The central type is [`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 and
//! deserializes that blob so the store stays game-agnostic.
use std::any::Any;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::errors::{GameError, Result};
/// One occupied chair in a game, owned by the platform.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Seat {
pub user_sub: String,
pub display_name: String,
/// Game-defined team label ("A"/"B", ...); `None` for games without
/// fixed teams.
pub team: Option<String>,
}
impl Seat {
pub fn new(user_sub: impl Into<String>, display_name: impl Into<String>) -> Self {
Self {
user_sub: user_sub.into(),
display_name: display_name.into(),
team: None,
}
}
pub fn to_json(&self) -> Value {
serde_json::json!({
"user_sub": self.user_sub,
"display_name": self.display_name,
"team": self.team,
})
}
pub fn from_json(data: &Value) -> Result<Self> {
Ok(Self {
user_sub: data
.get("user_sub")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
display_name: data
.get("display_name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
team: data.get("team").and_then(Value::as_str).map(str::to_string),
})
}
}
/// 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.
pub struct GameSession {
pub id: String,
pub game_type: String,
pub join_code: String,
pub creator_sub: String,
pub players: Vec<Seat>,
pub created_at: Option<DateTime<Utc>>,
pub finished_at: Option<DateTime<Utc>>,
/// Set once the finished match has been copied to Postgres.
pub stats_saved: bool,
pub state: Option<Box<dyn Any + Send + Sync>>,
}
impl GameSession {
/// A fresh lobby session created by [`GameEngine::create`].
pub fn new(
id: impl Into<String>,
game_type: impl Into<String>,
join_code: impl Into<String>,
) -> Self {
Self {
id: id.into(),
game_type: game_type.into(),
join_code: join_code.into(),
creator_sub: String::new(),
players: Vec::new(),
created_at: None,
finished_at: None,
stats_saved: false,
state: None,
}
}
/// Whether `sub` occupies a seat in this game.
pub fn seated(&self, sub: &str) -> bool {
self.players.iter().any(|seat| seat.user_sub == sub)
}
/// The seat occupied by `sub`, if any.
pub fn seat_of(&self, sub: &str) -> Option<&Seat> {
self.players.iter().find(|seat| seat.user_sub == sub)
}
/// The seat index occupied by `sub`, if any.
pub fn seat_index_of(&self, sub: &str) -> Option<usize> {
self.players.iter().position(|seat| seat.user_sub == sub)
}
}
/// 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 (returning an error) if it no
/// longer matches, which makes duplicate or overtaken deliveries harmless.
#[derive(Debug, Clone, PartialEq)]
pub struct Deadline {
pub kind: String,
pub due_at: DateTime<Utc>,
pub token: String,
}
/// The outcome of a finished match for one participant.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlayerResult {
pub user_sub: String,
pub seat: i64,
pub won: bool,
pub team: Option<String>,
/// Points scored, aggregated by the leaderboard.
pub score: f64,
/// Game-specific extras persisted alongside the participation.
pub details: Value,
}
/// 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.
#[derive(Debug, Clone)]
pub struct MatchResult {
pub teams: Vec<Vec<String>>,
pub winner_team: usize,
pub players: Vec<PlayerResult>,
pub summary: Value,
}
impl MatchResult {
pub fn new(
teams: Vec<Vec<String>>,
winner_team: usize,
players: Vec<PlayerResult>,
summary: Value,
) -> Self {
Self {
teams,
winner_team,
players,
summary,
}
}
}
/// 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 returning [`GameError`] values.
pub trait GameEngine: Send + Sync {
/// Unique id carried by sessions and persisted matches.
fn id(&self) -> &'static str;
/// Human-readable name, for the lobby UI.
fn name(&self) -> &'static str;
fn description(&self) -> &'static str;
fn min_players(&self) -> usize;
fn max_players(&self) -> usize;
/// JSON-schema fragment describing the game-specific creation options
/// accepted by [`GameEngine::create`] (exposed via `GET /api/game-types`).
fn options_schema(&self) -> Value;
/// 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` and initialize `session.state`.
fn create(&self, session: &mut GameSession, options: &Map<String, Value>) -> Result<()>;
/// Seat a player, starting the match when the lobby fills up.
///
/// Must append a [`Seat`] to `session.players` and mirror whatever
/// per-player state the game keeps in `session.state`.
fn join(&self, session: &mut GameSession, user_sub: &str, display_name: &str) -> Result<()>;
/// Apply one player action received over the websocket.
///
/// `payload` is the client message minus its `action` field. Unknown
/// actions and rule violations return [`GameError`].
fn handle_action(
&self,
session: &mut GameSession,
user_sub: &str,
action: &str,
payload: &Map<String, Value>,
) -> Result<()>;
/// 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.
fn view_for(&self, session: &GameSession, user_sub: &str) -> Value;
/// Game-specific fields of the lobby payload (options echo, phase).
fn lobby_view(&self, session: &GameSession) -> Value;
/// Whether the game is still waiting for players.
fn in_lobby(&self, session: &GameSession) -> bool;
/// Whether the match is over and a result can be extracted.
fn is_finished(&self, session: &GameSession) -> bool;
/// The outcome of the match. Only called when finished.
fn result(&self, session: &GameSession) -> Result<MatchResult>;
/// Serialize the opaque game state to plain JSON.
fn state_to_json(&self, state: &(dyn Any + Send + Sync)) -> Result<Value>;
/// Rebuild the opaque game state from its JSON form.
fn state_from_json(&self, data: &Value) -> Result<Box<dyn Any + Send + Sync>>;
/// 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
/// [`GameEngine::fire_deadline`] revalidation.
fn next_deadline(&self, session: &GameSession) -> Result<Option<Deadline>>;
/// Apply the timeout action for a due deadline.
///
/// Must recompute the current deadline and return a [`GameError`] without
/// acting when `kind`/`token` no longer match the live state.
fn fire_deadline(&self, session: &mut GameSession, kind: &str, token: &str) -> Result<()>;
/// Extra fields merged into the `game_over` websocket message.
fn game_over_view(&self, _session: &GameSession, _user_sub: &str) -> Value {
serde_json::json!({})
}
}
/// Build the JSON envelope for a session: platform fields plus the engine's
/// state blob (mirrors `tavolo.platform.store.session_to_json`).
pub fn session_to_json(session: &GameSession, engine: &dyn GameEngine) -> Result<Value> {
let state = match session.state.as_ref() {
Some(state) => engine.state_to_json(state.as_ref())?,
None => Value::Object(Map::new()),
};
Ok(serde_json::json!({
"id": session.id,
"game_type": session.game_type,
"join_code": session.join_code,
"creator_sub": session.creator_sub,
"players": session.players.iter().map(Seat::to_json).collect::<Vec<_>>(),
"created_at": session.created_at.map(crate::datetime::to_iso),
"finished_at": session.finished_at.map(crate::datetime::to_iso),
"stats_saved": session.stats_saved,
"state": state,
}))
}
/// Rebuild a session from its JSON envelope, delegating the state blob to
/// the engine registered for its `game_type` (mirrors
/// `tavolo.platform.store.session_from_json`).
pub fn session_from_json(data: &Value, engine: &dyn GameEngine) -> Result<GameSession> {
let game_type = data
.get("game_type")
.and_then(Value::as_str)
.ok_or_else(|| GameError::illegal_move("session is missing game_type"))?
.to_string();
if game_type != engine.id() {
return Err(
GameError::unknown_game_type(format!("unknown game_type: {game_type:?}")).into(),
);
}
let players = data
.get("players")
.and_then(Value::as_array)
.map(|list| list.iter().map(Seat::from_json).collect::<Result<Vec<_>>>())
.transpose()?
.unwrap_or_default();
let state = match data.get("state") {
Some(Value::Object(map)) if !map.is_empty() => {
Some(engine.state_from_json(data.get("state").unwrap())?)
}
_ => Some(engine.state_from_json(&Value::Object(Map::new()))?),
};
Ok(GameSession {
id: data
.get("id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
game_type,
join_code: data
.get("join_code")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
creator_sub: data
.get("creator_sub")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
players,
created_at: data.get("created_at").and_then(crate::datetime::from_json),
finished_at: data.get("finished_at").and_then(crate::datetime::from_json),
stats_saved: data
.get("stats_saved")
.and_then(Value::as_bool)
.unwrap_or(false),
state,
})
}
+101
View File
@@ -0,0 +1,101 @@
//! Typed errors shared between the platform and game implementations.
//!
//! Game engines return [`GameError`] on rule/validation failures; the
//! platform translates them into 4xx HTTP responses or `error` WebSocket
//! messages, using [`GameError::code`] as the machine-readable error code.
//! Engines themselves stay transport-agnostic.
//!
//! Mirrors `tavolo.platform.errors` from the Python implementation.
use thiserror::Error;
/// Base error for every rule/validation failure a game can raise.
#[derive(Debug, Clone, Error)]
#[error("{message}")]
pub struct GameError {
/// Machine-readable code carried by WebSocket `error` messages.
pub code: &'static str,
/// Human-readable failure reason.
pub message: String,
}
impl GameError {
pub fn new(code: &'static str, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
/// The requested action violates the rules of the game.
pub fn illegal_move(message: impl Into<String>) -> Self {
Self::new("illegal_move", message)
}
/// A player attempted to act out of turn.
pub fn not_your_turn(message: impl Into<String>) -> Self {
Self::new("illegal_move", message)
}
/// An action was attempted before the game left the lobby.
pub fn game_not_started(message: impl Into<String>) -> Self {
Self::new("illegal_move", message)
}
/// An action was attempted after the match ended.
pub fn game_finished(message: impl Into<String>) -> Self {
Self::new("illegal_move", message)
}
/// A game already has its full complement of players.
pub fn lobby_full(message: impl Into<String>) -> Self {
Self::new("illegal_move", message)
}
/// A player tried to join a game they are already seated in.
pub fn already_joined(message: impl Into<String>) -> Self {
Self::new("illegal_move", message)
}
/// No live game exists for the given id or join code.
pub fn game_not_found(message: impl Into<String>) -> Self {
Self::new("illegal_move", message)
}
/// A request named a `game_type` no registered engine provides.
pub fn unknown_game_type(message: impl Into<String>) -> Self {
Self::new("unknown_game_type", message)
}
}
/// Errors raised by the platform outside game rules (storage, transport,
/// serialization).
#[derive(Debug, Error)]
pub enum Error {
#[error(transparent)]
Game(#[from] GameError),
#[error("store error: {0}")]
Store(String),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
Redis(#[from] redis::RedisError),
#[error(transparent)]
Sql(#[from] sqlx::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("{0}")]
Internal(String),
}
impl Error {
/// The game error behind this error, when there is one.
pub fn as_game(&self) -> Option<&GameError> {
match self {
Error::Game(err) => Some(err),
_ => None,
}
}
}
pub type Result<T, E = Error> = std::result::Result<T, E>;
+138
View File
@@ -0,0 +1,138 @@
//! JSON request/response helpers for the HTTP handlers.
//!
//! Mirrors `tavolo.platform.http` from the Python implementation: the error
//! messages produced here are part of the public API contract.
use std::collections::HashMap;
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response};
use serde_json::{json, Value};
/// Raised when the request body is not valid JSON or not a JSON object.
#[derive(Debug, Clone, thiserror::Error)]
#[error("{0}")]
pub struct JsonRequestError(pub String);
/// Drain and parse a request body as a JSON object.
pub fn read_json(body: &[u8]) -> Result<Value, JsonRequestError> {
if body.is_empty() {
return Err(JsonRequestError("empty body".into()));
}
let parsed: Value =
serde_json::from_slice(body).map_err(|_| JsonRequestError("invalid JSON".into()))?;
if !parsed.is_object() {
return Err(JsonRequestError("JSON body must be an object".into()));
}
Ok(parsed)
}
/// Like [`read_json`] but treats an empty body as `{}`.
pub fn read_json_optional(body: &[u8]) -> Result<Value, JsonRequestError> {
match read_json(body) {
Ok(value) => Ok(value),
Err(JsonRequestError(message)) if message == "empty body" => Ok(json!({})),
Err(err) => Err(err),
}
}
/// Parse a raw query string into a mapping of param name to all values.
pub fn extract_query_params(query: Option<&str>) -> HashMap<String, Vec<String>> {
let mut params: HashMap<String, Vec<String>> = HashMap::new();
if let Some(query) = query {
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
params
.entry(key.into_owned())
.or_default()
.push(value.into_owned());
}
}
params
}
/// Send `payload` as a JSON response.
pub fn send_json(status: StatusCode, payload: &Value) -> Response {
let body = serde_json::to_vec(payload).unwrap_or_else(|_| b"null".to_vec());
(status, [(header::CONTENT_TYPE, "application/json")], body).into_response()
}
/// Send a JSON error envelope.
pub fn send_error(status: StatusCode, message: impl Into<String>) -> Response {
let message = message.into();
send_json(status, &json!({ "error": message }))
}
/// Send a plain body with an explicit content type.
pub fn send_text(status: StatusCode, body: impl Into<String>, content_type: &str) -> Response {
(
status,
[(header::CONTENT_TYPE, content_type.to_string())],
body.into(),
)
.into_response()
}
/// Send an empty response with a `Location` header.
pub fn redirect(location: impl Into<String>) -> Response {
(StatusCode::FOUND, [(header::LOCATION, location.into())]).into_response()
}
/// The 401 JSON envelope used by the authentication gate.
pub fn unauthorized() -> Response {
send_json(
StatusCode::UNAUTHORIZED,
&json!({ "error": "unauthenticated" }),
)
}
/// Render a JSON value the way Python's `repr()` would, so error messages
/// like `unknown game_type: 'foo'` match the Python server.
pub fn py_repr(value: &Value) -> String {
match value {
Value::Null => "None".to_string(),
Value::Bool(true) => "True".to_string(),
Value::Bool(false) => "False".to_string(),
Value::Number(number) => {
if let Some(int) = number.as_i64() {
int.to_string()
} else if let Some(float) = number.as_f64() {
if float.fract() == 0.0 && float.is_finite() {
format!("{float:.1}")
} else {
float.to_string()
}
} else {
number.to_string()
}
}
Value::String(text) => py_repr_str(text),
Value::Array(items) => {
let rendered: Vec<String> = items.iter().map(py_repr).collect();
format!("[{}]", rendered.join(", "))
}
Value::Object(map) => {
let rendered: Vec<String> = map
.iter()
.map(|(key, value)| format!("{}: {}", py_repr_str(key), py_repr(value)))
.collect();
format!("{{{}}}", rendered.join(", "))
}
}
}
fn py_repr_str(text: &str) -> String {
let mut out = String::with_capacity(text.len() + 2);
out.push('\'');
for ch in text.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'\'' => out.push_str("\\'"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
_ => out.push(ch),
}
}
out.push('\'');
out
}
+34
View File
@@ -0,0 +1,34 @@
#![allow(clippy::result_large_err)]
//! The game-independent half of tavolo: lobby, live play, history, ratings.
//!
//! This crate 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 [`engine::GameEngine`] and
//! registering it in a [`registry::GameRegistry`].
pub mod auth;
pub mod datetime;
pub mod deadlines;
pub mod elo;
pub mod engine;
pub mod errors;
pub mod http;
pub mod models;
pub mod oidc;
pub mod pagination;
pub mod platform;
pub mod registry;
pub mod routes;
pub mod session;
pub mod stats;
pub mod store;
pub mod ws;
pub use engine::{
session_from_json, session_to_json, Deadline, GameEngine, GameSession, MatchResult,
PlayerResult, Seat,
};
pub use errors::{Error, GameError, Result};
pub use platform::{Platform, SharedPlatform};
pub use registry::GameRegistry;
@@ -0,0 +1,43 @@
//! Database row models: match statistics persisted in Postgres.
//!
//! The schema is unchanged from the Python implementation
//! (`server/migrations/`):
//!
//! * [`MatchRow`] — one row per finished match; game-specific data lives in
//! the JSON `result` column produced by the engine.
//! * [`MatchPlayerRow`] — one row per participant.
//! * [`PlayerRatingRow`] — current Elo rating per `(user_sub, game_type)`.
use chrono::{DateTime, Utc};
use serde_json::Value;
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct MatchRow {
pub id: uuid::Uuid,
pub game_type: String,
pub started_at: DateTime<Utc>,
pub finished_at: DateTime<Utc>,
pub result: Value,
}
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct MatchPlayerRow {
pub id: uuid::Uuid,
pub match_id: uuid::Uuid,
pub user_sub: String,
pub display_name: String,
pub seat: i16,
pub team: Option<String>,
pub won: bool,
pub score: f64,
pub elo_delta: Option<i16>,
pub details: Value,
}
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct PlayerRatingRow {
pub user_sub: String,
pub game_type: String,
pub rating: i32,
pub matches_played: i32,
}
+551
View File
@@ -0,0 +1,551 @@
//! OpenID Connect login flows (discovery, PKCE, ID-token validation).
//!
//! Mirrors `kaya.oidc`'s `OIDCProviders` / `OIDCMixin`: each configured
//! provider gets `/auth/<provider>/login`, `/auth/<provider>/callback` and
//! `/auth/<provider>/logout` routes, namespaces its session state as
//! `oidc:<provider>:...`, and shares one session store. The configured
//! strategy is single-identity: a successful login evicts every other
//! provider's identity.
use std::collections::HashMap;
use std::sync::Arc;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use rand::RngCore;
use regex::Regex;
use serde_json::{json, Map, Value};
use sha2::{Digest, Sha256};
use tokio::sync::Mutex;
use crate::errors::{Error, Result};
use crate::session::SessionHandle;
pub const BASE_PATH: &str = "/auth";
const SCOPES: &str = "openid email profile";
const IDENTITY_FIELDS: [&str; 4] = ["user", "id_token", "access_token", "refresh_token"];
/// Configuration for one OpenID Connect provider.
#[derive(Debug, Clone)]
pub struct OidcProviderConfig {
pub provider: String,
pub issuer: String,
pub client_id: String,
pub client_secret: Option<String>,
pub redirect_uri: String,
pub post_login_redirect: String,
pub post_logout_redirect: String,
}
/// A flow error surfaced to the browser as a plain-text 400 (like kaya's
/// callback error responses).
#[derive(Debug, Clone, thiserror::Error)]
pub enum OidcFlowError {
#[error("{0}")]
BadRequest(String),
#[error("{0}")]
Internal(String),
}
impl OidcFlowError {
pub fn status(&self) -> axum::http::StatusCode {
match self {
OidcFlowError::BadRequest(_) => axum::http::StatusCode::BAD_REQUEST,
OidcFlowError::Internal(_) => axum::http::StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
fn random_token(bytes: usize) -> String {
let mut buf = vec![0u8; bytes];
rand::thread_rng().fill_bytes(&mut buf);
URL_SAFE_NO_PAD.encode(buf)
}
struct OidcClient {
config: OidcProviderConfig,
http: reqwest::Client,
metadata: Mutex<Option<Value>>,
jwks: Mutex<Option<Value>>,
}
impl OidcClient {
fn new(config: OidcProviderConfig) -> Self {
Self {
config,
http: reqwest::Client::new(),
metadata: Mutex::new(None),
jwks: Mutex::new(None),
}
}
async fn metadata(&self) -> Result<Value, OidcFlowError> {
let mut cache = self.metadata.lock().await;
if let Some(metadata) = cache.as_ref() {
return Ok(metadata.clone());
}
let url = format!(
"{}/.well-known/openid-configuration",
self.config.issuer.trim_end_matches('/')
);
let response = self
.http
.get(&url)
.send()
.await
.map_err(|err| OidcFlowError::Internal(format!("OIDC discovery failed: {err}")))?;
let response = response
.error_for_status()
.map_err(|err| OidcFlowError::Internal(format!("OIDC discovery failed: {err}")))?;
let metadata: Value = response
.json()
.await
.map_err(|err| OidcFlowError::Internal(format!("OIDC discovery failed: {err}")))?;
*cache = Some(metadata.clone());
Ok(metadata)
}
async fn jwks(&self) -> Result<Value, OidcFlowError> {
{
let cache = self.jwks.lock().await;
if let Some(jwks) = cache.as_ref() {
return Ok(jwks.clone());
}
}
let metadata = self.metadata().await?;
let jwks_uri = metadata
.get("jwks_uri")
.and_then(Value::as_str)
.ok_or_else(|| {
OidcFlowError::Internal("OIDC provider does not advertise a jwks_uri".into())
})?;
let response = self
.http
.get(jwks_uri)
.send()
.await
.map_err(|err| OidcFlowError::Internal(format!("JWKS fetch failed: {err}")))?;
let response = response
.error_for_status()
.map_err(|err| OidcFlowError::Internal(format!("JWKS fetch failed: {err}")))?;
let jwks: Value = response
.json()
.await
.map_err(|err| OidcFlowError::Internal(format!("JWKS fetch failed: {err}")))?;
*self.jwks.lock().await = Some(jwks.clone());
Ok(jwks)
}
/// Return (authorization_url, state, nonce, code_verifier).
async fn build_authorization_url(
&self,
) -> Result<(String, String, String, String), OidcFlowError> {
let metadata = self.metadata().await?;
let endpoint = metadata
.get("authorization_endpoint")
.and_then(Value::as_str)
.ok_or_else(|| {
OidcFlowError::Internal(
"OIDC provider does not advertise an authorization_endpoint".into(),
)
})?;
let state = random_token(32);
let nonce = random_token(32);
let code_verifier = random_token(64);
let code_challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes()));
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
serializer.append_pair("client_id", &self.config.client_id);
serializer.append_pair("response_type", "code");
serializer.append_pair("scope", SCOPES);
serializer.append_pair("redirect_uri", &self.config.redirect_uri);
serializer.append_pair("state", &state);
serializer.append_pair("nonce", &nonce);
serializer.append_pair("code_challenge", &code_challenge);
serializer.append_pair("code_challenge_method", "S256");
let query = serializer.finish();
Ok((format!("{endpoint}?{query}"), state, nonce, code_verifier))
}
async fn fetch_token(
&self,
code: &str,
code_verifier: &str,
) -> Result<Map<String, Value>, OidcFlowError> {
let metadata = self.metadata().await?;
let endpoint = metadata
.get("token_endpoint")
.and_then(Value::as_str)
.ok_or_else(|| {
OidcFlowError::Internal("OIDC provider does not advertise a token_endpoint".into())
})?;
let form = [
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", self.config.redirect_uri.as_str()),
("client_id", self.config.client_id.as_str()),
("code_verifier", code_verifier),
];
let mut request = self.http.post(endpoint).form(&form);
if let Some(secret) = &self.config.client_secret {
request = request.basic_auth(&self.config.client_id, Some(secret));
}
let response = request
.send()
.await
.map_err(|err| OidcFlowError::Internal(format!("token request failed: {err}")))?;
let response = response
.error_for_status()
.map_err(|err| OidcFlowError::Internal(format!("token request failed: {err}")))?;
response
.json()
.await
.map_err(|err| OidcFlowError::Internal(format!("token response is not JSON: {err}")))
}
async fn validate_id_token(
&self,
id_token: &str,
nonce: &str,
) -> Result<Map<String, Value>, OidcFlowError> {
let payload = self.decode_id_token(id_token).await?;
if payload.get("nonce").and_then(Value::as_str) != Some(nonce) {
return Err(OidcFlowError::BadRequest(
"Authentication failed: ID token nonce mismatch".into(),
));
}
Ok(payload)
}
async fn decode_id_token(&self, id_token: &str) -> Result<Map<String, Value>, OidcFlowError> {
let header = jsonwebtoken::decode_header(id_token)
.map_err(|err| OidcFlowError::BadRequest(format!("Authentication failed: {err}")))?;
let alg_name = format!("{:?}", header.alg);
if alg_name == "none" {
return Err(OidcFlowError::BadRequest(
"Authentication failed: Unsigned ID tokens are not allowed".into(),
));
}
let allowed = [
"RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512",
"EdDSA",
];
if !allowed.contains(&alg_name.as_str()) {
return Err(OidcFlowError::BadRequest(format!(
"Authentication failed: ID token algorithm {alg_name} is not allowed"
)));
}
let jwks = self.jwks().await?;
let keys = jwks.get("keys").and_then(Value::as_array).ok_or_else(|| {
OidcFlowError::BadRequest("Authentication failed: JWKS contains no keys".into())
})?;
let selected = if let Some(kid) = header.kid.as_deref() {
keys.iter()
.find(|key| key.get("kid").and_then(Value::as_str) == Some(kid))
.ok_or_else(|| {
OidcFlowError::BadRequest(format!(
"Authentication failed: No signing key found for kid {kid}"
))
})?
} else if keys.len() == 1 {
&keys[0]
} else {
return Err(OidcFlowError::BadRequest(
"Authentication failed: ID token has no kid and JWKS contains multiple keys".into(),
));
};
let jwk: jsonwebtoken::jwk::Jwk = serde_json::from_value(selected.clone())
.map_err(|err| OidcFlowError::BadRequest(format!("Authentication failed: {err}")))?;
let key = jsonwebtoken::DecodingKey::from_jwk(&jwk)
.map_err(|err| OidcFlowError::BadRequest(format!("Authentication failed: {err}")))?;
let mut validation = jsonwebtoken::Validation::new(header.alg);
validation.set_audience(&[self.config.client_id.as_str()]);
validation.validate_nbf = true;
let data = jsonwebtoken::decode::<Map<String, Value>>(id_token, &key, &validation)
.map_err(|err| OidcFlowError::BadRequest(format!("Authentication failed: {err}")))?;
let claims = data.claims;
let metadata = self.metadata().await?;
let expected = metadata
.get("issuer")
.and_then(Value::as_str)
.ok_or_else(|| {
OidcFlowError::Internal("OIDC provider does not advertise an issuer".into())
})?;
let expected = if expected.contains("{tenantid}") || expected.contains("{tenantId}") {
let tenant = claims.get("tid").and_then(Value::as_str).ok_or_else(|| {
OidcFlowError::BadRequest("Authentication failed: Invalid issuer".into())
})?;
let re = Regex::new(r"(?i)\{tenantid\}").expect("valid tenant regex");
re.replace(expected, tenant).to_string()
} else {
expected.to_string()
};
if claims.get("iss").and_then(Value::as_str) != Some(expected.as_str()) {
return Err(OidcFlowError::BadRequest(
"Authentication failed: Invalid issuer".into(),
));
}
Ok(claims)
}
async fn fetch_userinfo(
&self,
access_token: &str,
) -> Result<Map<String, Value>, OidcFlowError> {
let metadata = self.metadata().await?;
let endpoint = metadata
.get("userinfo_endpoint")
.and_then(Value::as_str)
.ok_or_else(|| {
OidcFlowError::Internal(
"OIDC provider does not advertise a userinfo_endpoint".into(),
)
})?;
let response = self
.http
.get(endpoint)
.bearer_auth(access_token)
.send()
.await
.map_err(|err| OidcFlowError::Internal(format!("userinfo request failed: {err}")))?;
let response = response
.error_for_status()
.map_err(|err| OidcFlowError::Internal(format!("userinfo request failed: {err}")))?;
response
.json()
.await
.map_err(|err| OidcFlowError::Internal(format!("userinfo response is not JSON: {err}")))
}
async fn build_logout_url(
&self,
id_token: Option<&str>,
) -> Result<Option<String>, OidcFlowError> {
let metadata = self.metadata().await?;
let Some(endpoint) = metadata.get("end_session_endpoint").and_then(Value::as_str) else {
return Ok(None);
};
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
serializer.append_pair(
"post_logout_redirect_uri",
&self.config.post_logout_redirect,
);
if let Some(id_token) = id_token {
serializer.append_pair("id_token_hint", id_token);
}
Ok(Some(format!("{endpoint}?{}", serializer.finish())))
}
}
pub struct OidcProvider {
config: OidcProviderConfig,
client: OidcClient,
}
impl OidcProvider {
fn key(&self, name: &str) -> String {
format!("oidc:{}:{}", self.config.provider, name)
}
pub fn name(&self) -> &str {
&self.config.provider
}
/// Start the login flow: build the authorization URL and stash state,
/// nonce and PKCE verifier in the session; returns the redirect target.
pub async fn begin_login(&self, session: &SessionHandle) -> Result<String, OidcFlowError> {
let (url, state, nonce, code_verifier) = self.client.build_authorization_url().await?;
let mut session = session.lock().await;
session.insert(self.key("state"), json!(state));
session.insert(self.key("nonce"), json!(nonce));
session.insert(self.key("code_verifier"), json!(code_verifier));
Ok(url)
}
/// Handle the provider callback; returns the post-login redirect target.
pub async fn handle_callback(
&self,
session: &SessionHandle,
query: &HashMap<String, Vec<String>>,
) -> Result<String, OidcFlowError> {
let first = |name: &str| query.get(name).and_then(|values| values.first()).cloned();
if let Some(error) = first("error") {
let mut message = format!("OIDC error: {error}");
if let Some(description) = first("error_description") {
message.push_str(&format!(": {description}"));
}
return Err(OidcFlowError::BadRequest(message));
}
let (Some(code), Some(state)) = (first("code"), first("state")) else {
return Err(OidcFlowError::BadRequest(
"Missing code or state".to_string(),
));
};
let (expected_state, nonce, code_verifier) = {
let session = session.lock().await;
(
session
.get(&self.key("state"))
.and_then(Value::as_str)
.map(str::to_string),
session
.get(&self.key("nonce"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
session
.get(&self.key("code_verifier"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
)
};
if Some(state.as_str()) != expected_state.as_deref() {
return Err(OidcFlowError::BadRequest("Invalid state".to_string()));
}
let token_response = self.client.fetch_token(&code, &code_verifier).await?;
let Some(id_token) = token_response.get("id_token").and_then(Value::as_str) else {
return Err(OidcFlowError::BadRequest(
"No id_token in token response".to_string(),
));
};
let id_token = id_token.to_string();
let claims = self.client.validate_id_token(&id_token, &nonce).await?;
let mut user = claims.clone();
let access_token = token_response
.get("access_token")
.and_then(Value::as_str)
.map(str::to_string);
if let Some(access_token) = access_token.as_deref() {
let userinfo = self.client.fetch_userinfo(access_token).await?;
for (key, value) in userinfo {
user.insert(key, value);
}
}
let mut session = session.lock().await;
session.remove(&self.key("state"));
session.remove(&self.key("code_verifier"));
session.insert(self.key("user"), Value::Object(user));
session.insert(self.key("id_token"), json!(id_token));
if let Some(access_token) = access_token {
session.insert(self.key("access_token"), json!(access_token));
}
if let Some(refresh_token) = token_response.get("refresh_token") {
session.insert(self.key("refresh_token"), refresh_token.clone());
}
self.strip_other_identities(&mut session);
session.regenerate_id();
Ok(self.config.post_login_redirect.clone())
}
/// Drop this provider's session state; returns the post-logout redirect.
pub async fn logout(&self, session: &SessionHandle) -> Result<String, OidcFlowError> {
let mut session = session.lock().await;
let id_token = session
.get(&self.key("id_token"))
.and_then(Value::as_str)
.map(str::to_string);
let prefix = format!("oidc:{}:", self.config.provider);
let keys: Vec<String> = session
.keys()
.filter(|key| key.starts_with(&prefix))
.cloned()
.collect();
for key in keys {
session.remove(&key);
}
let any_user = session
.keys()
.any(|key| key.starts_with("oidc:") && key.ends_with(":user"));
if !any_user {
session.invalidate();
}
drop(session);
match self.client.build_logout_url(id_token.as_deref()).await {
Ok(Some(url)) => Ok(url),
_ => Ok(self.config.post_logout_redirect.clone()),
}
}
fn strip_other_identities(&self, session: &mut crate::session::SessionData) {
let own_prefix = format!("oidc:{}:", self.config.provider);
let keys: Vec<String> = session
.keys()
.filter(|key| {
key.starts_with("oidc:")
&& !key.starts_with(&own_prefix)
&& IDENTITY_FIELDS
.iter()
.any(|field| key.ends_with(&format!(":{field}")))
})
.cloned()
.collect();
for key in keys {
session.remove(&key);
}
}
}
/// The set of configured providers, in configuration order.
pub struct OidcProviders {
providers: Vec<Arc<OidcProvider>>,
}
impl OidcProviders {
pub fn new(configs: Vec<OidcProviderConfig>) -> Result<Self> {
let pattern = Regex::new(r"^[A-Za-z0-9_-]+$").expect("valid provider regex");
let mut seen = std::collections::HashSet::new();
let mut providers = Vec::new();
for config in configs {
if !pattern.is_match(&config.provider) {
return Err(Error::Internal(format!(
"provider must be a non-empty string of letters, digits, underscores or hyphens (got {:?})",
config.provider
)));
}
if !seen.insert(config.provider.clone()) {
return Err(Error::Internal(format!(
"duplicate OIDC provider name {:?}",
config.provider
)));
}
let client = OidcClient::new(config.clone());
providers.push(Arc::new(OidcProvider { config, client }));
}
if providers.is_empty() {
return Err(Error::Internal(
"At least one OIDC provider is required".into(),
));
}
Ok(Self { providers })
}
pub fn base_path(&self) -> &'static str {
BASE_PATH
}
pub fn providers(&self) -> impl Iterator<Item = &Arc<OidcProvider>> {
self.providers.iter()
}
pub fn first(&self) -> &Arc<OidcProvider> {
&self.providers[0]
}
pub fn get(&self, name: &str) -> Option<&Arc<OidcProvider>> {
self.providers
.iter()
.find(|provider| provider.name() == name)
}
pub fn login_url(&self, name: &str) -> String {
format!("{BASE_PATH}/{name}/login")
}
}
@@ -0,0 +1,122 @@
//! Cursor-based pagination for listing endpoints.
//!
//! Uses keyset pagination (not OFFSET/LIMIT): each page ends with an opaque
//! cursor encoding the sort key tuple of the last item on that page. The
//! cursor is a base64url-encoded JSON object mapping the sort-field names to
//! the values of the last item; it is opaque to callers.
use std::collections::HashMap;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use serde_json::{Map, Value};
pub const DEFAULT_LIMIT: usize = 20;
pub const MAX_LIMIT: usize = 100;
pub const MIN_LIMIT: usize = 1;
pub const CURSOR_PARAM: &str = "cursor";
pub const LIMIT_PARAM: &str = "limit";
/// Raised when the `cursor`/`limit` query params cannot be decoded; the
/// message is sent verbatim in the 400 response.
#[derive(Debug, Clone, thiserror::Error)]
pub enum CursorDecodeError {
#[error("invalid cursor")]
InvalidCursor,
#[error("invalid limit")]
InvalidLimit,
}
#[derive(Debug, Clone)]
pub struct Cursor {
pub limit: usize,
pub after: Option<Map<String, Value>>,
}
/// Encode sort-key values as an opaque cursor.
pub fn encode_cursor(values: &Map<String, Value>) -> String {
let raw = serde_json::to_vec(values).unwrap_or_default();
URL_SAFE_NO_PAD.encode(raw)
}
/// Decode an opaque cursor; tolerates missing base64 padding.
pub fn decode_cursor(value: Option<&str>) -> Result<Option<Map<String, Value>>, CursorDecodeError> {
let Some(value) = value else {
return Ok(None);
};
if value.is_empty() {
return Ok(None);
}
// Tolerate both padded and unpadded base64url input.
let unpadded = value.trim_end_matches('=');
let raw = URL_SAFE_NO_PAD
.decode(unpadded)
.map_err(|_| CursorDecodeError::InvalidCursor)?;
let parsed: Value =
serde_json::from_slice(&raw).map_err(|_| CursorDecodeError::InvalidCursor)?;
match parsed {
Value::Object(map) => Ok(Some(map)),
_ => Err(CursorDecodeError::InvalidCursor),
}
}
/// Parse the `limit` and `cursor` query parameters.
pub fn parse_cursor_params(
params: &HashMap<String, Vec<String>>,
) -> Result<Cursor, CursorDecodeError> {
let limit = match params.get(LIMIT_PARAM).and_then(|values| values.first()) {
Some(value) => value
.parse::<i64>()
.map_err(|_| CursorDecodeError::InvalidLimit)?,
None => DEFAULT_LIMIT as i64,
};
let limit = limit.clamp(MIN_LIMIT as i64, MAX_LIMIT as i64) as usize;
let after = decode_cursor(
params
.get(CURSOR_PARAM)
.and_then(|values| values.first())
.map(String::as_str),
)?;
Ok(Cursor { limit, after })
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn cursor_round_trip() {
let mut values = Map::new();
values.insert("finished_at".into(), json!("2026-09-19T06:59:17+00:00"));
values.insert("id".into(), json!("7c9e6679-7425-40de-944b-e07fc1f90ae7"));
let encoded = encode_cursor(&values);
assert!(!encoded.contains('='));
let decoded = decode_cursor(Some(&encoded)).unwrap().unwrap();
assert_eq!(decoded, values);
// Padded input is tolerated too.
let padded = format!("{encoded}==");
let decoded = decode_cursor(Some(&padded)).unwrap().unwrap();
assert_eq!(decoded, values);
}
#[test]
fn parse_defaults_and_clamps() {
let params = HashMap::new();
let cursor = parse_cursor_params(&params).unwrap();
assert_eq!(cursor.limit, 20);
assert!(cursor.after.is_none());
let mut params = HashMap::new();
params.insert("limit".into(), vec!["1000".into()]);
assert_eq!(parse_cursor_params(&params).unwrap().limit, 100);
let mut params = HashMap::new();
params.insert("limit".into(), vec!["zero".into()]);
assert!(matches!(
parse_cursor_params(&params),
Err(CursorDecodeError::InvalidLimit)
));
}
}
@@ -0,0 +1,22 @@
//! The collaborators every platform endpoint needs, bundled so route and
//! websocket modules never reach for global state (mirrors
//! `tavolo.platform.mixin.Platform`).
use std::sync::Arc;
use crate::deadlines::DeadlineScheduler;
use crate::oidc::OidcProviders;
use crate::registry::GameRegistry;
use crate::stats::SharedStatsRepo;
use crate::store::SharedGameStore;
#[derive(Clone)]
pub struct Platform {
pub registry: Arc<GameRegistry>,
pub game_store: SharedGameStore,
pub scheduler: Arc<DeadlineScheduler>,
pub oidc: Arc<OidcProviders>,
pub stats: SharedStatsRepo,
}
pub type SharedPlatform = Arc<Platform>;
@@ -0,0 +1,64 @@
//! Registry of the games the platform can host.
//!
//! The registry maps a `game_type` id to its [`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; the platform itself ships no game.
use std::collections::HashMap;
use std::sync::Arc;
use crate::engine::GameEngine;
use crate::errors::{GameError, Result};
/// An ordered collection of game engines, keyed by their id.
#[derive(Default)]
pub struct GameRegistry {
engines: Vec<Arc<dyn GameEngine>>,
by_id: HashMap<String, usize>,
}
impl GameRegistry {
pub fn new() -> Self {
Self::default()
}
/// Add `engine`; the first registered becomes the default.
pub fn register(&mut self, engine: Arc<dyn GameEngine>) -> Result<()> {
if self.by_id.contains_key(engine.id()) {
return Err(GameError::illegal_move(format!(
"duplicate game engine id: {:?}",
engine.id()
))
.into());
}
self.by_id
.insert(engine.id().to_string(), self.engines.len());
self.engines.push(engine);
Ok(())
}
/// Return the engine for `game_type`, or `None`.
pub fn get(&self, game_type: &str) -> Option<&Arc<dyn GameEngine>> {
self.by_id.get(game_type).map(|index| &self.engines[*index])
}
/// Return the engine for `game_type` or raise an error.
pub fn require(&self, game_type: &str) -> Result<&Arc<dyn GameEngine>> {
self.get(game_type).ok_or_else(|| {
GameError::unknown_game_type(format!("unknown game_type: {game_type:?}")).into()
})
}
/// The first registered engine, used when no type is requested.
pub fn default_engine(&self) -> Result<&Arc<dyn GameEngine>> {
self.engines
.first()
.ok_or_else(|| crate::errors::Error::Internal("no game engines registered".into()))
}
pub fn all(&self) -> &[Arc<dyn GameEngine>] {
&self.engines
}
}
@@ -0,0 +1,175 @@
//! Authentication routes for the single-page app.
//!
//! Each configured provider gets `/auth/<provider>/login`, `.../callback`
//! and `.../logout` routes (registered per provider, so an unknown provider
//! path falls through to the SPA fallback like in the Python server). This
//! module adds the provider-agnostic pieces:
//!
//! * `GET /api/auth/providers` lists the configured providers with their
//! login URLs, so the SPA can offer a choice without hardcoding names;
//! * `GET /auth/login` redirects to the first configured provider;
//! * `GET /auth/logout` redirects to the provider this session is logged in
//! with (or the SPA root when not logged in).
use axum::extract::{Extension, RawQuery, State};
use axum::http::StatusCode;
use axum::response::Response;
use axum::routing::get;
use axum::Router;
use serde_json::json;
use crate::auth::logged_in_providers;
use crate::http::{extract_query_params, redirect, send_json, send_text};
use crate::oidc::OidcFlowError;
use crate::platform::SharedPlatform;
use crate::session::SessionHandle;
pub fn routes(providers: &[String]) -> Router<SharedPlatform> {
let mut router = Router::new()
.route("/api/auth/providers", get(providers_handler))
.route("/auth/login", get(generic_login))
.route("/auth/logout", get(generic_logout));
for provider in providers {
let name = provider.clone();
router = router.route(
&format!("/auth/{name}/login"),
get(
move |state: State<SharedPlatform>, session: Extension<SessionHandle>| {
let name = name.clone();
async move { provider_login(state, session, name).await }
},
),
);
let name = provider.clone();
router = router.route(
&format!("/auth/{name}/callback"),
get(
move |state: State<SharedPlatform>,
query: RawQuery,
session: Extension<SessionHandle>| {
let name = name.clone();
async move { provider_callback(state, query, session, name).await }
},
),
);
let name = provider.clone();
router = router.route(
&format!("/auth/{name}/logout"),
get(
move |state: State<SharedPlatform>, session: Extension<SessionHandle>| {
let name = name.clone();
async move { provider_logout(state, session, name).await }
},
),
);
}
router
}
fn flow_error_response(error: OidcFlowError) -> Response {
match error {
OidcFlowError::BadRequest(message) => send_text(
StatusCode::BAD_REQUEST,
message,
"text/plain; charset=utf-8",
),
OidcFlowError::Internal(message) => send_text(
StatusCode::INTERNAL_SERVER_ERROR,
message,
"text/plain; charset=utf-8",
),
}
}
/// Names and login URLs of the configured providers, in configuration order.
async fn providers_handler(State(platform): State<SharedPlatform>) -> Response {
let results: Vec<serde_json::Value> = platform
.oidc
.providers()
.map(|provider| {
json!({
"name": provider.name(),
"login_url": platform.oidc.login_url(provider.name()),
})
})
.collect();
send_json(StatusCode::OK, &json!({ "providers": results }))
}
/// Redirect to the first configured provider's login.
async fn generic_login(State(platform): State<SharedPlatform>) -> Response {
redirect(platform.oidc.login_url(platform.oidc.first().name()))
}
/// Redirect to the logged-in provider's logout (or the SPA root).
async fn generic_logout(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
) -> Response {
let logged_in = {
let session = session.lock().await;
logged_in_providers(&session)
};
match logged_in.first() {
Some(provider) if platform.oidc.get(provider).is_some() => {
redirect(format!("/auth/{provider}/logout"))
}
_ => redirect("/"),
}
}
async fn provider_login(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
provider: String,
) -> Response {
let Some(provider) = platform.oidc.get(&provider) else {
return send_text(
StatusCode::NOT_FOUND,
"not found",
"text/plain; charset=utf-8",
);
};
match provider.begin_login(&session).await {
Ok(location) => redirect(location),
Err(error) => flow_error_response(error),
}
}
async fn provider_callback(
State(platform): State<SharedPlatform>,
RawQuery(query): RawQuery,
Extension(session): Extension<SessionHandle>,
provider: String,
) -> Response {
let Some(provider) = platform.oidc.get(&provider) else {
return send_text(
StatusCode::NOT_FOUND,
"not found",
"text/plain; charset=utf-8",
);
};
let params = extract_query_params(query.as_deref());
match provider.handle_callback(&session, &params).await {
Ok(location) => redirect(location),
Err(error) => flow_error_response(error),
}
}
async fn provider_logout(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
provider: String,
) -> Response {
let Some(provider) = platform.oidc.get(&provider) else {
return send_text(
StatusCode::NOT_FOUND,
"not found",
"text/plain; charset=utf-8",
);
};
match provider.logout(&session).await {
Ok(location) => redirect(location),
Err(error) => flow_error_response(error),
}
}
@@ -0,0 +1,421 @@
//! 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; 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`.
use axum::body::Bytes;
use axum::extract::{Path, RawQuery, State};
use axum::http::StatusCode;
use axum::response::Response;
use axum::routing::{get, post};
use axum::Extension;
use axum::Router;
use rand::seq::SliceRandom;
use serde_json::{json, Value};
use crate::auth::{current_user, display_name};
use crate::datetime::now;
use crate::engine::GameSession;
use crate::http::{
extract_query_params, py_repr, read_json, read_json_optional, send_error, send_json,
unauthorized,
};
use crate::platform::{Platform, SharedPlatform};
use crate::session::SessionHandle;
/// Join codes avoid characters that are easy to confuse when read aloud.
const CODE_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const CODE_LENGTH: usize = 6;
const MAX_CODE_ATTEMPTS: usize = 20;
pub fn routes() -> Router<SharedPlatform> {
Router::new()
.route("/api/game-types", get(list_game_types))
.route("/api/games", post(create_game))
.route("/api/games/join", post(join_game))
.route("/api/games/preview", get(preview_game))
.route("/api/games/{game_id}", get(get_game))
}
fn random_code() -> String {
let mut rng = rand::thread_rng();
(0..CODE_LENGTH)
.map(|_| *CODE_ALPHABET.choose(&mut rng).expect("non-empty alphabet") as char)
.collect()
}
async fn unique_code(platform: &Platform) -> Result<String, Response> {
for _ in 0..MAX_CODE_ATTEMPTS {
let code = random_code();
match platform.game_store.find_by_code(&code).await {
Ok(None) => return Ok(code),
Ok(Some(_)) => continue,
Err(err) => {
tracing::error!("join code lookup failed: {err}");
return Err(send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not allocate a join code",
));
}
}
}
Err(send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not allocate a unique join code",
))
}
fn merge(base: &mut Value, extra: Value) {
if let (Value::Object(base), Value::Object(extra)) = (base, extra) {
for (key, value) in extra {
base.insert(key, value);
}
}
}
fn lobby_payload(platform: &Platform, game: &GameSession) -> Result<Value, Response> {
let engine = platform
.registry
.require(&game.game_type)
.map_err(|_| send_error(StatusCode::INTERNAL_SERVER_ERROR, "unknown game type"))?;
let players: Vec<Value> = game
.players
.iter()
.enumerate()
.map(|(index, seat)| {
json!({
"sub": seat.user_sub,
"name": seat.display_name,
"seat": index,
"team": seat.team,
})
})
.collect();
let mut payload = json!({
"id": game.id,
"join_code": game.join_code,
"game_type": game.game_type,
"players": players,
"seats_open": engine.max_players().saturating_sub(game.players.len()),
});
merge(&mut payload, engine.lobby_view(game));
Ok(payload)
}
fn view_payload(platform: &Platform, game: &GameSession, sub: &str) -> Result<Value, Response> {
let engine = platform
.registry
.require(&game.game_type)
.map_err(|_| send_error(StatusCode::INTERNAL_SERVER_ERROR, "unknown game type"))?;
let mut payload = json!({
"id": game.id,
"join_code": game.join_code,
"game_type": game.game_type,
});
merge(&mut payload, engine.view_for(game, sub));
Ok(payload)
}
fn preview_payload(platform: &Platform, game: &GameSession) -> Result<Value, Response> {
let engine = platform
.registry
.require(&game.game_type)
.map_err(|_| send_error(StatusCode::INTERNAL_SERVER_ERROR, "unknown game type"))?;
let mut payload = lobby_payload(platform, game)?;
merge(
&mut payload,
json!({
"game_name": engine.name(),
"game_description": engine.description(),
}),
);
Ok(payload)
}
/// Every game the platform can host, for the match-creation dropdown.
async fn list_game_types(State(platform): State<SharedPlatform>) -> Response {
let results: Vec<Value> = platform
.registry
.all()
.iter()
.map(|engine| {
json!({
"id": engine.id(),
"name": engine.name(),
"description": engine.description(),
"min_players": engine.min_players(),
"max_players": engine.max_players(),
"options_schema": engine.options_schema(),
})
})
.collect();
send_json(StatusCode::OK, &json!({ "results": results }))
}
/// Create a lobby game and seat the caller in seat 0.
async fn create_game(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
body: Bytes,
) -> Response {
let body = match read_json_optional(&body) {
Ok(body) => body,
Err(err) => return send_error(StatusCode::BAD_REQUEST, err.to_string()),
};
let engine = match body.get("game_type") {
None | Some(Value::Null) => match platform.registry.default_engine() {
Ok(engine) => engine.clone(),
Err(_) => {
return send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"no game engines registered",
)
}
},
Some(Value::String(name)) => match platform.registry.require(name) {
Ok(engine) => engine.clone(),
Err(_) => {
return send_error(
StatusCode::BAD_REQUEST,
format!(
"unknown game_type: {}",
py_repr(&Value::String(name.clone()))
),
)
}
},
Some(other) => {
return send_error(
StatusCode::BAD_REQUEST,
format!("unknown game_type: {}", py_repr(other)),
)
}
};
let options = body.get("options").cloned().unwrap_or_else(|| json!({}));
let Value::Object(options) = options else {
return send_error(StatusCode::BAD_REQUEST, "options must be an object");
};
let Some(user) = current_user(&session).await else {
return unauthorized();
};
let join_code = match unique_code(&platform).await {
Ok(code) => code,
Err(response) => return response,
};
let mut game = GameSession::new(uuid::Uuid::new_v4().to_string(), engine.id(), join_code);
game.creator_sub = user.sub.clone();
game.players = vec![crate::engine::Seat::new(&user.sub, display_name(&user))];
game.created_at = Some(now());
if let Err(err) = engine.create(&mut game, &options) {
match err.as_game() {
Some(game_error) => {
return send_error(StatusCode::BAD_REQUEST, game_error.message.clone())
}
None => {
tracing::error!("game create failed: {err}");
return send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not create game");
}
}
}
if let Err(err) = platform.game_store.save(&game).await {
tracing::error!("game save failed: {err}");
return send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not create game");
}
tracing::info!(
"game {} created by {} ({}, options {})",
game.id,
user.sub,
engine.id(),
serde_json::to_string(&options).unwrap_or_default()
);
match lobby_payload(&platform, &game) {
Ok(payload) => send_json(StatusCode::CREATED, &payload),
Err(response) => response,
}
}
/// Join with `{"code": "ABC123"}`; the last player triggers the start.
async fn join_game(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
body: Bytes,
) -> Response {
let body = match read_json(&body) {
Ok(body) => body,
Err(err) => return send_error(StatusCode::BAD_REQUEST, err.to_string()),
};
let Some(code) = body
.get("code")
.and_then(Value::as_str)
.filter(|code| !code.is_empty())
else {
return send_error(StatusCode::BAD_REQUEST, "code is required");
};
let Some(user) = current_user(&session).await else {
return unauthorized();
};
let existing = match platform.game_store.find_by_code(code).await {
Ok(Some(existing)) => existing,
Ok(None) => {
tracing::debug!("join rejected for {}: unknown code {code:?}", user.sub);
return send_error(StatusCode::NOT_FOUND, "unknown join code");
}
Err(err) => {
tracing::error!("join lookup failed: {err}");
return send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not join game");
}
};
let game_id = existing.id.clone();
let lock = match platform.game_store.acquire_lock(&game_id).await {
Ok(lock) => lock,
Err(err) => {
tracing::error!("join lock failed: {err}");
return send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not join game");
}
};
let result = async {
let Some(mut game) = (match platform.game_store.load(&game_id).await {
Ok(game) => game,
Err(err) => {
tracing::error!("join load failed: {err}");
return Err(send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not join game",
));
}
}) else {
return Err(send_error(StatusCode::NOT_FOUND, "unknown join code"));
};
let engine = match platform.registry.require(&game.game_type) {
Ok(engine) => engine.clone(),
Err(err) => {
tracing::error!("join engine lookup failed: {err}");
return Err(send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not join game",
));
}
};
if let Err(err) = engine.join(&mut game, &user.sub, &display_name(&user)) {
match err.as_game() {
Some(game_error) => {
tracing::debug!(
"join rejected for {} in game {game_id}: {}",
user.sub,
game_error.message
);
return Err(send_error(StatusCode::CONFLICT, game_error.message.clone()));
}
None => {
tracing::error!("join failed: {err}");
return Err(send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not join game",
));
}
}
}
if let Err(err) = platform.game_store.save(&game).await {
tracing::error!("join save failed: {err}");
return Err(send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not join game",
));
}
let _ = platform.game_store.publish(&game.id).await;
if let Err(err) = platform.scheduler.sync_deadline(&game).await {
tracing::error!("join sync_deadline failed: {err}");
}
let seat = game.seat_index_of(&user.sub).unwrap_or(0);
if engine.in_lobby(&game) {
tracing::info!(
"{} joined game {} (seat {}, {}/{} players)",
user.sub,
game.id,
seat,
game.players.len(),
engine.max_players()
);
return lobby_payload(&platform, &game);
}
tracing::info!(
"{} joined game {} (seat {}); match started",
user.sub,
game.id,
seat
);
view_payload(&platform, &game, &user.sub)
}
.await;
let _ = lock.release().await;
match result {
Ok(payload) => send_json(StatusCode::OK, &payload),
Err(response) => response,
}
}
/// Pre-join summary of the lobby behind a code.
async fn preview_game(
State(platform): State<SharedPlatform>,
RawQuery(query): RawQuery,
) -> Response {
let params = extract_query_params(query.as_deref());
let code = params
.get("code")
.and_then(|values| values.first())
.map(|code| code.trim().to_string())
.unwrap_or_default();
if code.is_empty() {
return send_error(StatusCode::BAD_REQUEST, "code is required");
}
match platform.game_store.find_by_code(&code).await {
Ok(Some(game)) => match preview_payload(&platform, &game) {
Ok(payload) => send_json(StatusCode::OK, &payload),
Err(response) => response,
},
Ok(None) => send_error(StatusCode::NOT_FOUND, "unknown join code"),
Err(err) => {
tracing::error!("preview lookup failed: {err}");
send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not preview game")
}
}
}
/// Personalized snapshot; only seated players may read a game.
async fn get_game(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
Path(game_id): Path<String>,
) -> Response {
let game = match platform.game_store.load(&game_id).await {
Ok(Some(game)) => game,
Ok(None) => return send_error(StatusCode::NOT_FOUND, "game not found"),
Err(err) => {
tracing::error!("game load failed: {err}");
return send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not load game");
}
};
let Some(user) = current_user(&session).await else {
return unauthorized();
};
if !game.seated(&user.sub) {
return send_error(StatusCode::FORBIDDEN, "forbidden");
}
match view_payload(&platform, &game, &user.sub) {
Ok(payload) => send_json(StatusCode::OK, &payload),
Err(response) => response,
}
}
@@ -0,0 +1,20 @@
//! Liveness probe.
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::Response;
use axum::routing::get;
use axum::Router;
use serde_json::json;
use crate::http::send_json;
use crate::platform::SharedPlatform;
pub fn routes() -> Router<SharedPlatform> {
Router::new().route("/api/health", get(health))
}
/// Health check: the service is up.
async fn health(State(_platform): State<SharedPlatform>) -> Response {
send_json(StatusCode::OK, &json!({ "status": "ok" }))
}
@@ -0,0 +1,32 @@
//! Whoami endpoint: lets the single-page app detect the login state.
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::Response;
use axum::routing::get;
use axum::Extension;
use axum::Router;
use serde_json::json;
use crate::auth::{current_user, display_name};
use crate::http::{send_json, unauthorized};
use crate::platform::SharedPlatform;
use crate::session::SessionHandle;
pub fn routes() -> Router<SharedPlatform> {
Router::new().route("/api/me", get(me))
}
/// Current user; 401 when not logged in.
async fn me(
State(_platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
) -> Response {
let Some(user) = current_user(&session).await else {
return unauthorized();
};
send_json(
StatusCode::OK,
&json!({ "sub": user.sub, "name": display_name(&user) }),
)
}
@@ -0,0 +1,33 @@
//! The platform's HTTP surface: lobby, stats, auth and health routes.
//!
//! Handlers live in submodules mirroring the Python implementation; this
//! module assembles them into one axum [`Router`].
pub mod auth;
pub mod games;
pub mod health;
pub mod me;
pub mod openapi;
pub mod stats;
use axum::routing::get;
use axum::Router;
use crate::platform::SharedPlatform;
use crate::ws;
/// All platform routes (HTTP + the live-play websocket).
///
/// `providers` lists the configured OIDC provider names; login/callback/
/// logout routes are registered per provider so unknown provider paths fall
/// through to the router's fallback.
pub fn router(providers: &[String]) -> Router<SharedPlatform> {
Router::new()
.merge(health::routes())
.merge(me::routes())
.merge(auth::routes(providers))
.merge(games::routes())
.merge(stats::routes())
.merge(openapi::routes())
.route("/ws/games/{game_id}", get(ws::game_socket))
}
@@ -0,0 +1,314 @@
//! OpenAPI document and Swagger UI, mirroring kaya-openapi's output for the
//! tavolo API.
//!
//! The document is assembled from the route metadata below (kaya generated it
//! from the routing tree on every request). Wildcard SPA routes and the
//! websocket endpoint are skipped, exactly like kaya does.
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::Response;
use axum::routing::get;
use axum::Router;
use serde_json::{json, Value};
use crate::http::{send_json, send_text};
use crate::platform::SharedPlatform;
pub const TITLE: &str = "tavolo";
pub const VERSION: &str = "0.1.0";
pub const DESCRIPTION: &str = "Multiplayer card-game platform API";
pub const SPEC_PATH: &str = "/api/openapi.json";
pub const DOCS_PATH: &str = "/api/docs";
fn query_parameter(name: &str, description: &str, schema: Value) -> Value {
json!({
"name": name,
"in": "query",
"required": false,
"schema": schema,
"description": description,
})
}
fn game_type_parameter() -> Value {
query_parameter(
"game_type",
"Only count matches of this game (id from GET /api/game-types).",
json!({"type": "string"}),
)
}
fn game_id_parameter() -> Value {
json!({
"name": "game_id",
"in": "path",
"required": true,
"schema": {"type": "string"},
})
}
/// The OpenAPI 3.1 document for the platform API.
pub fn spec() -> Value {
json!({
"openapi": "3.1.0",
"info": {
"title": TITLE,
"version": VERSION,
"description": DESCRIPTION,
},
"paths": {
"/api/health": {
"get": {
"summary": "Health check",
"tags": ["health"],
"responses": {"200": {"description": "The service is up"}},
}
},
"/api/me": {
"get": {
"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"},
},
}
},
"/api/auth/providers": {
"get": {
"summary": "Configured login providers",
"description": "Names and login URLs of the configured OIDC providers, in configuration order.",
"tags": ["auth"],
"responses": {"200": {"description": "The configured providers"}},
}
},
"/auth/{provider}/login": {
"get": {
"summary": "Start an OIDC login",
"tags": ["auth"],
"parameters": [{
"name": "provider",
"in": "path",
"required": true,
"schema": {"type": "string"},
}],
"responses": {"302": {"description": "Redirect to the provider"}},
}
},
"/auth/{provider}/callback": {
"get": {
"summary": "OIDC login callback",
"tags": ["auth"],
"parameters": [{
"name": "provider",
"in": "path",
"required": true,
"schema": {"type": "string"},
}],
"responses": {
"302": {"description": "Redirect to the post-login target"},
"400": {"description": "Authentication failed"},
},
}
},
"/auth/{provider}/logout": {
"get": {
"summary": "Log out of an OIDC provider",
"tags": ["auth"],
"parameters": [{
"name": "provider",
"in": "path",
"required": true,
"schema": {"type": "string"},
}],
"responses": {"302": {"description": "Redirect to the provider or the SPA root"}},
}
},
"/api/game-types": {
"get": {
"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"}},
}
},
"/api/games": {
"post": {
"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"],
"requestBody": {
"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"},
},
}
},
"/api/games/join": {
"post": {
"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"],
"requestBody": {
"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"},
},
}
},
"/api/games/preview": {
"get": {
"summary": "Preview a game by join code",
"description": "Looks up a game by its join code without joining, for the lobby's pre-join summary: game name and description, seated players, open seats and the game-specific options echoed by its engine.",
"tags": ["games"],
"responses": {
"200": {"description": "The lobby preview"},
"400": {"description": "Missing code"},
"401": {"description": "Authentication required"},
"404": {"description": "Unknown join code"},
},
}
},
"/api/games/{game_id}": {
"get": {
"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"],
"parameters": [game_id_parameter()],
"responses": {
"200": {"description": "The personalized game state"},
"401": {"description": "Authentication required"},
"403": {"description": "Not a player in this game"},
"404": {"description": "Game not found"},
},
}
},
"/api/me/matches": {
"get": {
"summary": "List my matches",
"description": "Cursor-paginated history of finished matches the caller played, newest first, with the result.",
"tags": ["stats"],
"parameters": [
query_parameter(
"limit",
"Maximum number of results per page (clamped to [1, 100]).",
json!({"type": "integer", "minimum": 1, "maximum": 100, "default": 20}),
),
query_parameter(
"cursor",
"Opaque pagination cursor from a previous response's next_cursor.",
json!({"type": "string"}),
),
game_type_parameter(),
],
"responses": {
"200": {"description": "A page of matches"},
"400": {"description": "Invalid pagination cursor or game_type"},
"401": {"description": "Authentication required"},
},
}
},
"/api/leaderboard": {
"get": {
"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"},
},
}
},
"/api/me/ratings": {
"get": {
"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"},
},
}
}
}
})
}
/// The Swagger UI page (assets loaded from a CDN, like kaya-openapi).
pub fn docs_page() -> String {
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{TITLE} - API documentation</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js" crossorigin></script>
<script>
window.onload = () => {{
window.ui = SwaggerUIBundle({{
url: '{SPEC_PATH}',
dom_id: '#swagger-ui',
}});
}};
</script>
</body>
</html>
"#
)
}
pub fn routes() -> Router<SharedPlatform> {
Router::new()
.route(SPEC_PATH, get(spec_handler))
.route(DOCS_PATH, get(docs_handler))
}
async fn spec_handler(State(_platform): State<SharedPlatform>) -> Response {
send_json(StatusCode::OK, &spec())
}
async fn docs_handler(State(_platform): State<SharedPlatform>) -> Response {
send_text(StatusCode::OK, docs_page(), "text/html; charset=utf-8")
}
@@ -0,0 +1,165 @@
//! Player statistics endpoints, served from the stats repository.
//!
//! Every finished match is persisted by
//! [`StatsRepo::save_match`](crate::stats::StatsRepo::save_match). These
//! endpoints expose a player's own match history and a global leaderboard
//! aggregated from the same tables. The game-specific outcome of each match
//! is exposed verbatim through the `result` JSON column.
use axum::extract::{RawQuery, State};
use axum::http::StatusCode;
use axum::response::Response;
use axum::routing::get;
use axum::Extension;
use axum::Router;
use serde_json::{json, Value};
use crate::auth::current_user;
use crate::http::{extract_query_params, py_repr, send_error, send_json, unauthorized};
use crate::pagination::{parse_cursor_params, CursorDecodeError};
use crate::platform::{Platform, SharedPlatform};
use crate::session::SessionHandle;
pub fn routes() -> Router<SharedPlatform> {
Router::new()
.route("/api/me/matches", get(my_matches))
.route("/api/me/ratings", get(my_ratings))
.route("/api/leaderboard", get(leaderboard))
}
fn parse_game_type(
platform: &Platform,
params: &std::collections::HashMap<String, Vec<String>>,
) -> Result<Option<String>, Response> {
let Some(game_type) = params.get("game_type").and_then(|values| values.first()) else {
return Ok(None);
};
if platform.registry.get(game_type).is_none() {
return Err(send_error(
StatusCode::BAD_REQUEST,
format!(
"unknown game_type: {}",
py_repr(&Value::String(game_type.clone()))
),
));
}
Ok(Some(game_type.clone()))
}
fn cursor_error(error: CursorDecodeError) -> Response {
send_error(StatusCode::BAD_REQUEST, error.to_string())
}
/// Cursor-paginated history of finished matches the caller played.
async fn my_matches(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
RawQuery(query): RawQuery,
) -> Response {
let params = extract_query_params(query.as_deref());
let cursor = match parse_cursor_params(&params) {
Ok(cursor) => cursor,
Err(err) => return cursor_error(err),
};
let game_type = match parse_game_type(&platform, &params) {
Ok(game_type) => game_type,
Err(response) => return response,
};
let Some(user) = current_user(&session).await else {
return unauthorized();
};
match platform
.stats
.my_matches(
&user.sub,
game_type.as_deref(),
cursor.limit,
cursor.after.as_ref(),
)
.await
{
Ok((entries, next_cursor)) => {
let results: Vec<Value> = entries
.iter()
.map(|entry| serde_json::to_value(entry).unwrap_or(Value::Null))
.collect();
send_json(
StatusCode::OK,
&json!({ "results": results, "next_cursor": next_cursor }),
)
}
Err(err) => {
tracing::error!("match history query failed: {err}");
send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not load matches")
}
}
}
/// Global leaderboard, sorted by Elo (the rating for the requested
/// game_type, or the default game when the filter is absent).
async fn leaderboard(
State(platform): State<SharedPlatform>,
RawQuery(query): RawQuery,
) -> Response {
let params = extract_query_params(query.as_deref());
let game_type = match parse_game_type(&platform, &params) {
Ok(game_type) => game_type,
Err(response) => return response,
};
let rating_game = match &game_type {
Some(game_type) => game_type.clone(),
None => match platform.registry.default_engine() {
Ok(engine) => engine.id().to_string(),
Err(err) => {
tracing::error!("leaderboard default game lookup failed: {err}");
return send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not load leaderboard",
);
}
},
};
match platform
.stats
.leaderboard(game_type.as_deref(), &rating_game)
.await
{
Ok(ranking) => {
let results: Vec<Value> = ranking
.iter()
.map(|entry| serde_json::to_value(entry).unwrap_or(Value::Null))
.collect();
send_json(StatusCode::OK, &json!({ "results": results }))
}
Err(err) => {
tracing::error!("leaderboard query failed: {err}");
send_error(
StatusCode::INTERNAL_SERVER_ERROR,
"could not load leaderboard",
)
}
}
}
/// The caller's current Elo rating for every game type they have played.
async fn my_ratings(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
) -> Response {
let Some(user) = current_user(&session).await else {
return unauthorized();
};
match platform.stats.my_ratings(&user.sub).await {
Ok(rows) => {
let results: Vec<Value> = rows
.iter()
.map(|entry| serde_json::to_value(entry).unwrap_or(Value::Null))
.collect();
send_json(StatusCode::OK, &json!({ "results": results }))
}
Err(err) => {
tracing::error!("ratings query failed: {err}");
send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not load ratings")
}
}
}
@@ -0,0 +1,531 @@
//! Server-side HTTP sessions.
//!
//! Mirrors kaya's `SessionMixin` / `SessionStore`:
//!
//! * [`SessionData`] is a dict-like container with modification tracking
//! (`modified` / `regenerate` / `invalidated` flags) used by the
//! middleware to decide whether to persist, rotate or delete it;
//! * [`SessionStore`] is the pluggable backend: Redis in production,
//! in-memory for tests and ephemeral single-process dev;
//! * [`session_middleware`] loads the session from the `session_id` cookie,
//! exposes it as a [`SessionHandle`] request extension, and persists it
//! (refreshing the cookie) after the handler ran.
//!
//! Session data is serialized as JSON (the Python server used pickle; old
//! sessions are deliberately not readable, users log in again).
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use async_trait::async_trait;
use axum::extract::{Request, State};
use axum::middleware::Next;
use axum::response::Response;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use rand::RngCore;
use serde_json::{Map, Value};
use tokio::sync::{Mutex, MutexGuard};
use crate::errors::{Error, Result};
/// The session cookie name (kaya's default).
pub const COOKIE_NAME: &str = "session_id";
/// The session idle timeout (kaya's default: 14 days).
pub const MAX_AGE_SECONDS: i64 = 14 * 24 * 60 * 60;
/// Key prefix of the Redis session store. The prefix differs from kaya's
/// (`kaya:session:`) because the payload format changed from pickle to JSON.
pub const REDIS_KEY_PREFIX: &str = "tavolo:session:";
/// Opaque session identifier with 256 bits of entropy, matching Python's
/// `secrets.token_urlsafe(32)` (43 URL-safe base64 characters, no padding).
pub fn new_session_id() -> String {
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
/// Dict-like session container with modification tracking.
#[derive(Debug, Clone, Default)]
pub struct SessionData {
id: Option<String>,
old_id: Option<String>,
data: Map<String, Value>,
modified: bool,
regenerate: bool,
invalidated: bool,
}
impl SessionData {
pub fn new() -> Self {
Self::default()
}
pub fn with_id(id: impl Into<String>, data: Map<String, Value>) -> Self {
Self {
id: Some(id.into()),
old_id: None,
data,
modified: false,
regenerate: false,
invalidated: false,
}
}
pub fn id(&self) -> Option<&str> {
self.id.as_deref()
}
pub fn is_modified(&self) -> bool {
self.modified
}
pub fn is_regenerate(&self) -> bool {
self.regenerate
}
pub fn is_invalidated(&self) -> bool {
self.invalidated
}
pub fn get(&self, key: &str) -> Option<&Value> {
self.data.get(key)
}
pub fn insert(&mut self, key: impl Into<String>, value: Value) {
self.data.insert(key.into(), value);
self.modified = true;
}
pub fn remove(&mut self, key: &str) -> Option<Value> {
let removed = self.data.remove(key);
if removed.is_some() {
self.modified = true;
}
removed
}
pub fn keys(&self) -> impl Iterator<Item = &String> {
self.data.keys()
}
pub fn data(&self) -> &Map<String, Value> {
&self.data
}
pub fn mark_modified(&mut self) {
self.modified = true;
}
/// Mark the session for ID rotation (session fixation defense); the
/// middleware mints a new id and deletes the old store entry.
pub fn regenerate_id(&mut self) {
self.old_id = self.id.take();
self.regenerate = true;
self.modified = true;
}
/// Mark the session for deletion and send an expired cookie.
pub fn invalidate(&mut self) {
self.invalidated = true;
self.modified = true;
self.data.clear();
}
}
/// Shared, mutable session attached to a request.
#[derive(Clone)]
pub struct SessionHandle(pub Arc<Mutex<SessionData>>);
impl SessionHandle {
pub fn new(session: SessionData) -> Self {
Self(Arc::new(Mutex::new(session)))
}
pub async fn lock(&self) -> MutexGuard<'_, SessionData> {
self.0.lock().await
}
}
/// Pluggable backend for session persistence.
#[async_trait]
pub trait SessionStore: Send + Sync {
/// Load an existing session, or `None` if unknown or expired. When
/// `max_age` is given the store slides the server-side expiry.
async fn load(&self, session_id: &str, max_age: Option<i64>) -> Result<Option<SessionData>>;
/// Persist the session data under `session_id`.
async fn save(
&self,
session_id: &str,
data: &Map<String, Value>,
max_age: Option<i64>,
) -> Result<()>;
/// Remove the session from the store.
async fn delete(&self, session_id: &str) -> Result<()>;
/// Number of live sessions (for the `tavolo.active_users` gauge).
async fn count(&self) -> Result<usize>;
}
// ---------------------------------------------------------------------------
// In-memory store
// ---------------------------------------------------------------------------
struct MemorySession {
data: Map<String, Value>,
expires: Option<Instant>,
}
/// Process-local session store used by tests and when Redis is not configured.
#[derive(Default)]
pub struct InMemorySessionStore {
sessions: Mutex<HashMap<String, MemorySession>>,
}
impl InMemorySessionStore {
pub fn new() -> Self {
Self::default()
}
fn clock(&self) -> Instant {
Instant::now()
}
}
#[async_trait]
impl SessionStore for InMemorySessionStore {
async fn load(&self, session_id: &str, max_age: Option<i64>) -> Result<Option<SessionData>> {
let mut sessions = self.sessions.lock().await;
let Some(entry) = sessions.get_mut(session_id) else {
return Ok(None);
};
let now = self.clock();
if let Some(expires) = entry.expires {
if now > expires {
sessions.remove(session_id);
return Ok(None);
}
}
if max_age.is_some() {
entry.expires = Some(now + Duration::from_secs(max_age.unwrap_or(0) as u64));
}
Ok(Some(SessionData::with_id(
session_id.to_string(),
entry.data.clone(),
)))
}
async fn save(
&self,
session_id: &str,
data: &Map<String, Value>,
max_age: Option<i64>,
) -> Result<()> {
let mut sessions = self.sessions.lock().await;
sessions.insert(
session_id.to_string(),
MemorySession {
data: data.clone(),
expires: max_age.map(|age| self.clock() + Duration::from_secs(age as u64)),
},
);
Ok(())
}
async fn delete(&self, session_id: &str) -> Result<()> {
self.sessions.lock().await.remove(session_id);
Ok(())
}
async fn count(&self) -> Result<usize> {
let sessions = self.sessions.lock().await;
let now = self.clock();
Ok(sessions
.values()
.filter(|entry| entry.expires.is_none_or(|expires| expires > now))
.count())
}
}
// ---------------------------------------------------------------------------
// Redis store
// ---------------------------------------------------------------------------
/// Redis-backed session store: one JSON value per session under
/// `tavolo:session:<id>`, expiry delegated to the key TTL.
pub struct RedisSessionStore {
client: redis::Client,
manager: redis::aio::ConnectionManager,
prefix: String,
}
impl RedisSessionStore {
pub async fn new(url: &str) -> Result<Self> {
let client = redis::Client::open(url)?;
let manager = redis::aio::ConnectionManager::new(client.clone()).await?;
Ok(Self {
client,
manager,
prefix: REDIS_KEY_PREFIX.to_string(),
})
}
pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
self.prefix = prefix.into();
self
}
pub fn client(&self) -> redis::Client {
self.client.clone()
}
fn key(&self, session_id: &str) -> String {
format!("{}{}", self.prefix, session_id)
}
}
#[async_trait]
impl SessionStore for RedisSessionStore {
async fn load(&self, session_id: &str, max_age: Option<i64>) -> Result<Option<SessionData>> {
let mut conn = self.manager.clone();
let key = self.key(session_id);
let payload: Option<String> = redis::cmd("GET").arg(&key).query_async(&mut conn).await?;
let Some(payload) = payload else {
return Ok(None);
};
if let Some(max_age) = max_age {
redis::cmd("EXPIRE")
.arg(&key)
.arg(max_age)
.query_async::<i64>(&mut conn)
.await?;
}
let data: Map<String, Value> = serde_json::from_str(&payload)?;
Ok(Some(SessionData::with_id(session_id.to_string(), data)))
}
async fn save(
&self,
session_id: &str,
data: &Map<String, Value>,
max_age: Option<i64>,
) -> Result<()> {
let mut conn = self.manager.clone();
let payload = serde_json::to_string(data)?;
let mut cmd = redis::cmd("SET");
cmd.arg(self.key(session_id)).arg(payload);
if let Some(max_age) = max_age {
cmd.arg("EX").arg(max_age);
}
cmd.query_async::<()>(&mut conn).await?;
Ok(())
}
async fn delete(&self, session_id: &str) -> Result<()> {
let mut conn = self.manager.clone();
redis::cmd("DEL")
.arg(self.key(session_id))
.query_async::<i64>(&mut conn)
.await?;
Ok(())
}
async fn count(&self) -> Result<usize> {
let mut conn = self.manager.clone();
let mut cursor: u64 = 0;
let mut total = 0usize;
loop {
let (next, keys): (u64, Vec<String>) = redis::cmd("SCAN")
.arg(cursor)
.arg("MATCH")
.arg(format!("{}*", self.prefix))
.arg("COUNT")
.arg(500)
.query_async(&mut conn)
.await?;
total += keys.len();
cursor = next;
if cursor == 0 {
break;
}
}
Ok(total)
}
}
/// Convenience alias used by the application.
pub type SharedSessionStore = Arc<dyn SessionStore>;
// ---------------------------------------------------------------------------
// Cookie helpers
// ---------------------------------------------------------------------------
/// Return the value of `name` from a `Cookie` header, if present.
pub fn parse_cookie_value(header_value: &str, name: &str) -> Option<String> {
for part in header_value.split(';') {
let part = part.trim();
if let Some((key, value)) = part.split_once('=') {
if key.trim() == name {
return Some(value.trim().trim_matches('"').to_string());
}
}
}
None
}
/// Build a `Set-Cookie` value the way Python's `SimpleCookie` renders one,
/// i.e. sorted attributes: `HttpOnly; Max-Age=n; Path=/; SameSite=Lax`.
pub fn format_set_cookie(
name: &str,
value: &str,
path: &str,
max_age: Option<i64>,
httponly: bool,
secure: bool,
samesite: Option<&str>,
) -> String {
let mut out = format!("{name}={value}");
if httponly {
out.push_str("; HttpOnly");
}
if let Some(max_age) = max_age {
out.push_str(&format!("; Max-Age={max_age}"));
}
out.push_str(&format!("; Path={path}"));
if let Some(samesite) = samesite {
out.push_str(&format!("; SameSite={samesite}"));
}
if secure {
out.push_str("; Secure");
}
out
}
fn extract_session_id(headers: &axum::http::HeaderMap) -> Option<String> {
let mut found = None;
for value in headers.get_all(axum::http::header::COOKIE) {
if let Ok(value) = value.to_str() {
if let Some(id) = parse_cookie_value(value, COOKIE_NAME) {
found = Some(id);
break;
}
}
}
found
}
/// Load the session, expose it as a [`SessionHandle`] extension and persist
/// it (refreshing the cookie) after the handler ran.
///
/// Mirrors kaya's `SessionMixin`: an existing session is always re-saved and
/// its cookie re-sent, so the client and server expiry windows slide on
/// every request.
pub async fn session_middleware(
State(store): State<SharedSessionStore>,
mut request: Request,
next: Next,
) -> Response {
// Static assets never reach the application in the Python deployment
// (Granian serves them): skip the session round-trip for them.
let path = request.uri().path();
if path.starts_with("/static") || path.starts_with("/assets") {
return next.run(request).await;
}
let session_id = extract_session_id(request.headers());
let session = match session_id.as_deref() {
Some(id) => match store.load(id, Some(MAX_AGE_SECONDS)).await {
Ok(Some(session)) => session,
Ok(None) => SessionData::new(),
Err(err) => {
tracing::error!("session load failed: {err}");
SessionData::new()
}
},
None => SessionData::new(),
};
let handle = SessionHandle::new(session);
request.extensions_mut().insert(handle.clone());
let mut response = next.run(request).await;
let mut session = handle.lock().await;
let mut cookie: Option<String> = None;
if session.invalidated {
let id = session.old_id.clone().or_else(|| session.id.clone());
if let Some(id) = &id {
if let Err(err) = store.delete(id).await {
tracing::error!("session delete failed: {err}");
}
}
cookie = Some(format_set_cookie(
COOKIE_NAME,
id.as_deref().unwrap_or(""),
"/",
Some(0),
true,
false,
Some("Lax"),
));
} else if session.regenerate {
if let Some(old_id) = session.old_id.clone() {
if let Err(err) = store.delete(&old_id).await {
tracing::error!("session delete failed: {err}");
}
session.old_id = None;
}
if session.id.is_none() {
session.id = Some(new_session_id());
}
session.regenerate = false;
if let Some(id) = session.id.clone() {
if let Err(err) = store.save(&id, session.data(), Some(MAX_AGE_SECONDS)).await {
tracing::error!("session save failed: {err}");
}
cookie = Some(format_set_cookie(
COOKIE_NAME,
&id,
"/",
Some(MAX_AGE_SECONDS),
true,
false,
Some("Lax"),
));
}
} else {
if session.id.is_none() && session.is_modified() {
session.id = Some(new_session_id());
}
if let Some(id) = session.id.clone() {
if let Err(err) = store.save(&id, session.data(), Some(MAX_AGE_SECONDS)).await {
tracing::error!("session save failed: {err}");
}
cookie = Some(format_set_cookie(
COOKIE_NAME,
&id,
"/",
Some(MAX_AGE_SECONDS),
true,
false,
Some("Lax"),
));
}
}
drop(session);
if let Some(cookie) = cookie {
if let Ok(value) = axum::http::HeaderValue::from_str(&cookie) {
response
.headers_mut()
.append(axum::http::header::SET_COOKIE, value);
}
}
response
}
/// Errors from the session layer surfaced as platform errors.
pub fn session_error(message: impl Into<String>) -> Error {
Error::Internal(message.into())
}
+825
View File
@@ -0,0 +1,825 @@
//! Copy finished match results from the live store into Postgres, plus the
//! read side of the statistics endpoints.
//!
//! 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 [`crate::elo`]).
//!
//! This module is game-independent: everything it persists comes from the
//! engine's [`crate::engine::MatchResult`]. The data access is behind
//! [`StatsRepo`] so tests can run without a database.
use std::collections::HashMap;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::Serialize;
use serde_json::{Map, Value};
use sqlx::{PgPool, Postgres, Transaction};
use uuid::Uuid;
use crate::datetime::serialize_iso;
use crate::elo::{match_delta, INITIAL_RATING};
use crate::engine::{GameEngine, GameSession};
use crate::errors::{Error, Result};
use crate::models::{MatchPlayerRow, MatchRow};
#[derive(Debug, Clone, Serialize)]
pub struct MatchPlayerView {
pub user_sub: String,
pub display_name: String,
pub seat: i32,
pub team: Option<String>,
pub won: bool,
pub score: f64,
pub elo_delta: Option<i32>,
pub details: Value,
}
#[derive(Debug, Clone, Serialize)]
pub struct MatchHistoryEntry {
pub id: String,
pub game_type: String,
pub result: Value,
#[serde(serialize_with = "serialize_iso")]
pub started_at: DateTime<Utc>,
#[serde(serialize_with = "serialize_iso")]
pub finished_at: DateTime<Utc>,
pub you_won: bool,
pub your_elo_delta: Option<i32>,
pub players: Vec<MatchPlayerView>,
}
#[derive(Debug, Clone, Serialize)]
pub struct RatingEntry {
pub game_type: String,
pub rating: i32,
pub matches_played: i32,
}
#[derive(Debug, Clone, Serialize)]
pub struct LeaderboardEntry {
pub user_sub: String,
pub display_name: String,
pub matches: u64,
pub wins: u64,
pub points: f64,
pub elo: i32,
}
/// Persistence + queries for finished matches and ratings.
#[async_trait]
pub trait StatsRepo: Send + Sync {
/// Persist a finished session. Callers must check `stats_saved` and
/// `engine.is_finished` first and set `session.stats_saved` after success.
async fn save_match(&self, session: &GameSession, engine: &dyn GameEngine) -> Result<()>;
/// One page of finished matches the user played, newest first.
async fn my_matches(
&self,
user_sub: &str,
game_type: Option<&str>,
limit: usize,
after: Option<&Map<String, Value>>,
) -> Result<(Vec<MatchHistoryEntry>, Option<String>)>;
/// The caller's rating per game type, ordered by game type.
async fn my_ratings(&self, user_sub: &str) -> Result<Vec<RatingEntry>>;
/// Global leaderboard; `rating_game` selects the rating table to use when
/// no game type filter is given.
async fn leaderboard(
&self,
game_type: Option<&str>,
rating_game: &str,
) -> Result<Vec<LeaderboardEntry>>;
/// Recompute every rating from the match history; returns the number of
/// matches replayed.
async fn backfill_elo(&self) -> Result<usize>;
}
pub type SharedStatsRepo = std::sync::Arc<dyn StatsRepo>;
// ---------------------------------------------------------------------------
// Postgres implementation
// ---------------------------------------------------------------------------
pub struct PostgresStatsRepo {
pool: PgPool,
}
impl PostgresStatsRepo {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
pub fn pool(&self) -> &PgPool {
&self.pool
}
}
/// Update the Elo ratings of `teams` for `game_type` inside a transaction.
/// Returns the per-player delta.
pub async fn apply_elo(
tx: &mut Transaction<'_, Postgres>,
game_type: &str,
teams: &[Vec<String>],
winner_team: usize,
) -> Result<HashMap<String, i64>> {
if teams.len() != 2 {
return Err(Error::Internal(
"Elo rating requires exactly two teams".into(),
));
}
let mut ratings: HashMap<String, i32> = HashMap::new();
for team in teams {
for sub in team {
sqlx::query(
r#"INSERT INTO player_rating (id, user_sub, game_type, rating, matches_played, updated_at)
VALUES ($1, $2, $3, $4, 0, now())
ON CONFLICT (user_sub, game_type) DO NOTHING"#,
)
.bind(Uuid::new_v4())
.bind(sub)
.bind(game_type)
.bind(INITIAL_RATING as i32)
.execute(&mut **tx)
.await?;
let rating: (i32,) = sqlx::query_as(
r#"SELECT rating FROM player_rating WHERE user_sub = $1 AND game_type = $2 FOR UPDATE"#,
)
.bind(sub)
.bind(game_type)
.fetch_one(&mut **tx)
.await?;
ratings.insert(sub.clone(), rating.0);
}
}
let team_a: Vec<f64> = teams[0].iter().map(|sub| ratings[sub] as f64).collect();
let team_b: Vec<f64> = teams[1].iter().map(|sub| ratings[sub] as f64).collect();
let delta_a = match_delta(&team_a, &team_b, winner_team);
let mut deltas = HashMap::new();
for sub in &teams[0] {
deltas.insert(sub.clone(), delta_a);
}
for sub in &teams[1] {
deltas.insert(sub.clone(), -delta_a);
}
for (sub, delta) in &deltas {
sqlx::query(
r#"UPDATE player_rating
SET rating = rating + $1, matches_played = matches_played + 1, updated_at = now()
WHERE user_sub = $2 AND game_type = $3"#,
)
.bind(*delta as i32)
.bind(sub)
.bind(game_type)
.execute(&mut **tx)
.await?;
}
Ok(deltas)
}
fn row_to_entry(row: MatchRow, viewer: &str, players: &[MatchPlayerRow]) -> MatchHistoryEntry {
let viewer_won = players.iter().any(|p| p.user_sub == viewer && p.won);
let viewer_delta = players
.iter()
.find(|p| p.user_sub == viewer)
.and_then(|p| p.elo_delta)
.map(i32::from);
MatchHistoryEntry {
id: row.id.to_string(),
game_type: row.game_type,
result: row.result,
started_at: row.started_at,
finished_at: row.finished_at,
you_won: viewer_won,
your_elo_delta: viewer_delta,
players: players
.iter()
.map(|p| MatchPlayerView {
user_sub: p.user_sub.clone(),
display_name: p.display_name.clone(),
seat: p.seat as i32,
team: p.team.clone(),
won: p.won,
score: p.score,
elo_delta: p.elo_delta.map(i32::from),
details: p.details.clone(),
})
.collect(),
}
}
#[async_trait]
impl StatsRepo for PostgresStatsRepo {
async fn save_match(&self, session: &GameSession, engine: &dyn GameEngine) -> Result<()> {
let result = engine.result(session)?;
let started_at = session.created_at.unwrap_or_else(Utc::now);
let finished_at = session.finished_at.unwrap_or_else(Utc::now);
let display_names: HashMap<&str, &str> = session
.players
.iter()
.map(|seat| (seat.user_sub.as_str(), seat.display_name.as_str()))
.collect();
let mut tx = self.pool.begin().await?;
let match_id = Uuid::new_v4();
sqlx::query(
r#"INSERT INTO "match" (id, game_type, started_at, finished_at, result)
VALUES ($1, $2, $3, $4, $5)"#,
)
.bind(match_id)
.bind(&session.game_type)
.bind(started_at)
.bind(finished_at)
.bind(&result.summary)
.execute(&mut *tx)
.await?;
let deltas = apply_elo(
&mut tx,
&session.game_type,
&result.teams,
result.winner_team,
)
.await?;
for player in &result.players {
let display_name = display_names
.get(player.user_sub.as_str())
.copied()
.unwrap_or(player.user_sub.as_str());
let delta = deltas.get(&player.user_sub).copied().unwrap_or(0);
sqlx::query(
r#"INSERT INTO match_player
(id, match_id, user_sub, display_name, seat, team, won, score, elo_delta, details)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)"#,
)
.bind(Uuid::new_v4())
.bind(match_id)
.bind(&player.user_sub)
.bind(display_name)
.bind(player.seat as i16)
.bind(&player.team)
.bind(player.won)
.bind(player.score)
.bind(delta as i16)
.bind(&player.details)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
tracing::info!(
"match result persisted: game {} ({}), team {} of {} won",
session.id,
session.game_type,
result.winner_team,
result.teams.len()
);
Ok(())
}
async fn my_matches(
&self,
user_sub: &str,
game_type: Option<&str>,
limit: usize,
after: Option<&Map<String, Value>>,
) -> Result<(Vec<MatchHistoryEntry>, Option<String>)> {
let cursor_finished = after
.and_then(|map| map.get("finished_at"))
.and_then(crate::datetime::from_json);
let cursor_id = after
.and_then(|map| map.get("id"))
.and_then(Value::as_str)
.and_then(|value| Uuid::parse_str(value).ok());
let rows: Vec<MatchRow> = sqlx::query_as(
r#"SELECT DISTINCT m.id, m.game_type, m.started_at, m.finished_at, m.result
FROM "match" m
JOIN match_player p ON p.match_id = m.id
WHERE p.user_sub = $1
AND ($2::text IS NULL OR m.game_type = $2)
AND ($3::timestamptz IS NULL
OR m.finished_at < $3
OR (m.finished_at = $3 AND m.id > $4))
ORDER BY m.finished_at DESC, m.id ASC
LIMIT $5"#,
)
.bind(user_sub)
.bind(game_type)
.bind(cursor_finished)
.bind(cursor_id)
.bind((limit + 1) as i64)
.fetch_all(&self.pool)
.await?;
let has_more = rows.len() > limit;
let page: Vec<MatchRow> = rows.into_iter().take(limit).collect();
let next_cursor = if has_more {
page.last().map(|row| {
let mut values = Map::new();
values.insert(
"finished_at".into(),
Value::String(crate::datetime::to_iso(row.finished_at)),
);
values.insert("id".into(), Value::String(row.id.to_string()));
crate::pagination::encode_cursor(&values)
})
} else {
None
};
let mut players_by_match: HashMap<Uuid, Vec<MatchPlayerRow>> = HashMap::new();
if !page.is_empty() {
let ids: Vec<Uuid> = page.iter().map(|row| row.id).collect();
let players: Vec<MatchPlayerRow> = sqlx::query_as(
r#"SELECT id, match_id, user_sub, display_name, seat, team, won, score, elo_delta, details
FROM match_player WHERE match_id = ANY($1)
ORDER BY match_id, seat"#,
)
.bind(&ids)
.fetch_all(&self.pool)
.await?;
for player in players {
players_by_match
.entry(player.match_id)
.or_default()
.push(player);
}
}
let entries = page
.into_iter()
.map(|row| {
let players = players_by_match.remove(&row.id).unwrap_or_default();
row_to_entry(row, user_sub, &players)
})
.collect();
Ok((entries, next_cursor))
}
async fn my_ratings(&self, user_sub: &str) -> Result<Vec<RatingEntry>> {
let rows: Vec<(String, i32, i32)> = sqlx::query_as(
r#"SELECT game_type, rating, matches_played FROM player_rating
WHERE user_sub = $1 ORDER BY game_type"#,
)
.bind(user_sub)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|(game_type, rating, matches_played)| RatingEntry {
game_type,
rating,
matches_played,
})
.collect())
}
async fn leaderboard(
&self,
game_type: Option<&str>,
rating_game: &str,
) -> Result<Vec<LeaderboardEntry>> {
let rows: Vec<(String, String, bool, f64)> = sqlx::query_as(
r#"SELECT mp.user_sub, mp.display_name, mp.won, mp.score
FROM match_player mp
JOIN "match" m ON m.id = mp.match_id
WHERE ($1::text IS NULL OR m.game_type = $1)"#,
)
.bind(game_type)
.fetch_all(&self.pool)
.await?;
let ratings: Vec<(String, i32)> =
sqlx::query_as(r#"SELECT user_sub, rating FROM player_rating WHERE game_type = $1"#)
.bind(rating_game)
.fetch_all(&self.pool)
.await?;
let ratings: HashMap<String, i32> = ratings.into_iter().collect();
let mut order: Vec<String> = Vec::new();
let mut aggregate: HashMap<String, LeaderboardEntry> = HashMap::new();
for (user_sub, display_name, won, score) in rows {
let entry = aggregate.entry(user_sub.clone()).or_insert_with(|| {
order.push(user_sub.clone());
LeaderboardEntry {
user_sub: user_sub.clone(),
display_name: display_name.clone(),
matches: 0,
wins: 0,
points: 0.0,
elo: ratings
.get(&user_sub)
.copied()
.unwrap_or(INITIAL_RATING as i32),
}
});
entry.matches += 1;
entry.wins += u64::from(won);
entry.points += score;
// Keep the most recent display name seen.
entry.display_name = display_name;
}
let mut ranking: Vec<LeaderboardEntry> = order
.into_iter()
.filter_map(|sub| aggregate.remove(&sub))
.collect();
ranking.sort_by(|a, b| {
b.elo
.cmp(&a.elo)
.then(b.wins.cmp(&a.wins))
.then(
b.points
.partial_cmp(&a.points)
.unwrap_or(std::cmp::Ordering::Equal),
)
.then(a.matches.cmp(&b.matches))
});
Ok(ranking)
}
async fn backfill_elo(&self) -> Result<usize> {
let mut tx = self.pool.begin().await?;
sqlx::query("DELETE FROM player_rating")
.execute(&mut *tx)
.await?;
let matches: Vec<MatchRow> = sqlx::query_as(
r#"SELECT id, game_type, started_at, finished_at, result FROM "match"
ORDER BY finished_at, id"#,
)
.fetch_all(&mut *tx)
.await?;
let mut replayed = 0usize;
for row in matches {
let players: Vec<MatchPlayerRow> = sqlx::query_as(
r#"SELECT id, match_id, user_sub, display_name, seat, team, won, score, elo_delta, details
FROM match_player WHERE match_id = $1"#,
)
.bind(row.id)
.fetch_all(&mut *tx)
.await?;
let mut by_team: std::collections::BTreeMap<String, Vec<String>> = Default::default();
for player in &players {
if let Some(team) = &player.team {
by_team
.entry(team.clone())
.or_default()
.push(player.user_sub.clone());
}
}
let teams: Vec<Vec<String>> = by_team.into_values().collect();
let winner_index = teams
.iter()
.position(|members| {
players
.iter()
.any(|p| p.won && members.contains(&p.user_sub))
})
.unwrap_or(0);
let deltas = apply_elo(&mut tx, &row.game_type, &teams, winner_index).await?;
for player in &players {
let delta = deltas.get(&player.user_sub).copied().unwrap_or(0);
sqlx::query("UPDATE match_player SET elo_delta = $1 WHERE id = $2")
.bind(delta as i16)
.bind(player.id)
.execute(&mut *tx)
.await?;
}
replayed += 1;
}
tx.commit().await?;
tracing::info!("elo backfill complete: {replayed} matches replayed");
Ok(replayed)
}
}
// ---------------------------------------------------------------------------
// In-memory implementation (tests and ephemeral dev)
// ---------------------------------------------------------------------------
#[derive(Clone)]
struct StoredMatch {
id: Uuid,
game_type: String,
started_at: DateTime<Utc>,
finished_at: DateTime<Utc>,
result: Value,
}
#[derive(Clone)]
struct StoredPlayer {
match_id: Uuid,
user_sub: String,
display_name: String,
seat: i16,
team: Option<String>,
won: bool,
score: f64,
elo_delta: Option<i32>,
details: Value,
}
#[derive(Default)]
struct MemoryStats {
matches: Vec<StoredMatch>,
players: Vec<StoredPlayer>,
ratings: HashMap<(String, String), (i32, i32)>,
}
#[derive(Default)]
pub struct InMemoryStatsRepo {
inner: tokio::sync::Mutex<MemoryStats>,
}
impl InMemoryStatsRepo {
pub fn new() -> Self {
Self::default()
}
}
fn memory_apply_elo(
inner: &mut MemoryStats,
game_type: &str,
teams: &[Vec<String>],
winner_team: usize,
) -> Result<HashMap<String, i64>> {
if teams.len() != 2 {
return Err(Error::Internal(
"Elo rating requires exactly two teams".into(),
));
}
let mut ratings: HashMap<String, i32> = HashMap::new();
for team in teams {
for sub in team {
let entry = inner
.ratings
.entry((sub.clone(), game_type.to_string()))
.or_insert((INITIAL_RATING as i32, 0));
ratings.insert(sub.clone(), entry.0);
}
}
let team_a: Vec<f64> = teams[0].iter().map(|sub| ratings[sub] as f64).collect();
let team_b: Vec<f64> = teams[1].iter().map(|sub| ratings[sub] as f64).collect();
let delta_a = match_delta(&team_a, &team_b, winner_team);
let mut deltas = HashMap::new();
for sub in &teams[0] {
deltas.insert(sub.clone(), delta_a);
}
for sub in &teams[1] {
deltas.insert(sub.clone(), -delta_a);
}
for (sub, delta) in &deltas {
let entry = inner
.ratings
.get_mut(&(sub.clone(), game_type.to_string()))
.expect("rating entry exists");
entry.0 += *delta as i32;
entry.1 += 1;
}
Ok(deltas)
}
#[async_trait]
impl StatsRepo for InMemoryStatsRepo {
async fn save_match(&self, session: &GameSession, engine: &dyn GameEngine) -> Result<()> {
let result = engine.result(session)?;
let started_at = session.created_at.unwrap_or_else(Utc::now);
let finished_at = session.finished_at.unwrap_or_else(Utc::now);
let mut inner = self.inner.lock().await;
let match_id = Uuid::new_v4();
let deltas = memory_apply_elo(
&mut inner,
&session.game_type,
&result.teams,
result.winner_team,
)?;
inner.matches.push(StoredMatch {
id: match_id,
game_type: session.game_type.clone(),
started_at,
finished_at,
result: result.summary.clone(),
});
for player in &result.players {
let display_name = session
.seat_of(&player.user_sub)
.map(|seat| seat.display_name.clone())
.unwrap_or_else(|| player.user_sub.clone());
inner.players.push(StoredPlayer {
match_id,
user_sub: player.user_sub.clone(),
display_name,
seat: player.seat as i16,
team: player.team.clone(),
won: player.won,
score: player.score,
elo_delta: deltas.get(&player.user_sub).map(|d| *d as i32),
details: player.details.clone(),
});
}
Ok(())
}
async fn my_matches(
&self,
user_sub: &str,
game_type: Option<&str>,
limit: usize,
after: Option<&Map<String, Value>>,
) -> Result<(Vec<MatchHistoryEntry>, Option<String>)> {
let inner = self.inner.lock().await;
let cursor_finished = after
.and_then(|map| map.get("finished_at"))
.and_then(crate::datetime::from_json);
let cursor_id = after
.and_then(|map| map.get("id"))
.and_then(Value::as_str)
.and_then(|value| Uuid::parse_str(value).ok());
let mut candidate_ids: Vec<Uuid> = inner
.players
.iter()
.filter(|p| p.user_sub == user_sub)
.map(|p| p.match_id)
.collect();
candidate_ids.sort();
candidate_ids.dedup();
let mut matches: Vec<&StoredMatch> = candidate_ids
.iter()
.filter_map(|id| inner.matches.iter().find(|m| m.id == *id))
.filter(|m| game_type.is_none_or(|game_type| m.game_type == game_type))
.filter(|m| match (cursor_finished, cursor_id) {
(Some(finished), Some(id)) => {
m.finished_at < finished || (m.finished_at == finished && m.id > id)
}
_ => true,
})
.collect();
matches.sort_by(|a, b| b.finished_at.cmp(&a.finished_at).then(a.id.cmp(&b.id)));
let has_more = matches.len() > limit;
matches.truncate(limit);
let next_cursor = if has_more {
matches.last().map(|row| {
let mut values = Map::new();
values.insert(
"finished_at".into(),
Value::String(crate::datetime::to_iso(row.finished_at)),
);
values.insert("id".into(), Value::String(row.id.to_string()));
crate::pagination::encode_cursor(&values)
})
} else {
None
};
let entries = matches
.into_iter()
.map(|row| {
let players: Vec<MatchPlayerRow> = inner
.players
.iter()
.filter(|p| p.match_id == row.id)
.map(|p| MatchPlayerRow {
id: Uuid::nil(),
match_id: p.match_id,
user_sub: p.user_sub.clone(),
display_name: p.display_name.clone(),
seat: p.seat,
team: p.team.clone(),
won: p.won,
score: p.score,
elo_delta: p.elo_delta.map(|d| d as i16),
details: p.details.clone(),
})
.collect();
row_to_entry(
MatchRow {
id: row.id,
game_type: row.game_type.clone(),
started_at: row.started_at,
finished_at: row.finished_at,
result: row.result.clone(),
},
user_sub,
&players,
)
})
.collect();
Ok((entries, next_cursor))
}
async fn my_ratings(&self, user_sub: &str) -> Result<Vec<RatingEntry>> {
let inner = self.inner.lock().await;
let mut rows: Vec<RatingEntry> = inner
.ratings
.iter()
.filter(|((sub, _), _)| sub == user_sub)
.map(|((_, game_type), (rating, matches_played))| RatingEntry {
game_type: game_type.clone(),
rating: *rating,
matches_played: *matches_played,
})
.collect();
rows.sort_by(|a, b| a.game_type.cmp(&b.game_type));
Ok(rows)
}
async fn leaderboard(
&self,
game_type: Option<&str>,
rating_game: &str,
) -> Result<Vec<LeaderboardEntry>> {
let inner = self.inner.lock().await;
let ratings: HashMap<&str, i32> = inner
.ratings
.iter()
.filter(|((_, gt), _)| gt == rating_game)
.map(|((sub, _), (rating, _))| (sub.as_str(), *rating))
.collect();
let mut order: Vec<String> = Vec::new();
let mut aggregate: HashMap<String, LeaderboardEntry> = HashMap::new();
for player in &inner.players {
let in_scope = match game_type {
Some(game_type) => inner
.matches
.iter()
.any(|m| m.id == player.match_id && m.game_type == game_type),
None => true,
};
if !in_scope {
continue;
}
let entry = aggregate.entry(player.user_sub.clone()).or_insert_with(|| {
order.push(player.user_sub.clone());
LeaderboardEntry {
user_sub: player.user_sub.clone(),
display_name: player.display_name.clone(),
matches: 0,
wins: 0,
points: 0.0,
elo: ratings
.get(player.user_sub.as_str())
.copied()
.unwrap_or(INITIAL_RATING as i32),
}
});
entry.matches += 1;
entry.wins += u64::from(player.won);
entry.points += player.score;
entry.display_name = player.display_name.clone();
}
let mut ranking: Vec<LeaderboardEntry> = order
.into_iter()
.filter_map(|sub| aggregate.remove(&sub))
.collect();
ranking.sort_by(|a, b| {
b.elo
.cmp(&a.elo)
.then(b.wins.cmp(&a.wins))
.then(
b.points
.partial_cmp(&a.points)
.unwrap_or(std::cmp::Ordering::Equal),
)
.then(a.matches.cmp(&b.matches))
});
Ok(ranking)
}
async fn backfill_elo(&self) -> Result<usize> {
let mut inner = self.inner.lock().await;
inner.ratings.clear();
let matches = inner.matches.clone();
for row in &matches {
let players: Vec<StoredPlayer> = inner
.players
.iter()
.filter(|p| p.match_id == row.id)
.cloned()
.collect();
let mut by_team: std::collections::BTreeMap<String, Vec<String>> = Default::default();
for player in &players {
if let Some(team) = &player.team {
by_team
.entry(team.clone())
.or_default()
.push(player.user_sub.clone());
}
}
let teams: Vec<Vec<String>> = by_team.into_values().collect();
let winner_index = teams
.iter()
.position(|members| {
players
.iter()
.any(|p| p.won && members.contains(&p.user_sub))
})
.unwrap_or(0);
let deltas = memory_apply_elo(&mut inner, &row.game_type, &teams, winner_index)?;
for player in inner.players.iter_mut().filter(|p| p.match_id == row.id) {
player.elo_delta = deltas.get(&player.user_sub).map(|d| *d as i32);
}
}
Ok(matches.len())
}
}
+588
View File
@@ -0,0 +1,588 @@
//! 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 [`crate::stats`]); Redis keeps serving the finished session
//! until it expires.
//!
//! Two implementations satisfy [`GameStore`]:
//!
//! * [`RedisGameStore`] — production, used when `REDIS_URL` is set.
//! * [`InMemoryGameStore`] — tests and ephemeral dev, used otherwise.
//!
//! 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.
//!
//! Timeouts are driven by a shared delayed-deadline queue (see
//! [`crate::deadlines`]).
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use futures_util::stream::{BoxStream, StreamExt};
use tokio::sync::{broadcast, mpsc, Mutex, OwnedMutexGuard};
use crate::engine::{session_from_json, session_to_json, GameSession};
use crate::errors::{Error, Result};
use crate::registry::GameRegistry;
pub const GAME_KEY_PREFIX: &str = "tavolo:game:";
pub const CODE_KEY_PREFIX: &str = "tavolo:code:";
/// Channel prefix of the per-game change notifications.
pub const CHANNEL_PREFIX: &str = "tavolo:game:";
pub const DEADLINES_KEY: &str = "tavolo:deadlines";
/// Channel announcing newly enqueued deadline entries, so every worker's
/// consumer can wake immediately instead of polling on its heartbeat.
pub const DEADLINES_CHANNEL: &str = "tavolo:deadlines:events";
/// Sorted set of running games (score = expiry timestamp), maintained by the
/// stores so the telemetry gauge can count ongoing matches without loading
/// every session. Finished games are removed on their final save.
pub const ONGOING_GAMES_KEY: &str = "tavolo:games:ongoing";
const LOCK_TIMEOUT_SECONDS: f64 = 10.0;
const LOCK_BLOCKING_TIMEOUT_SECONDS: f64 = 10.0;
fn channel(game_id: &str) -> String {
format!("{CHANNEL_PREFIX}{game_id}:events")
}
fn now_secs() -> f64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or_default()
}
/// A held per-game mutation lock. Released explicitly with
/// [`GameLock::release`] or best-effort on drop.
pub enum GameLock {
Memory(OwnedMutexGuard<()>),
Redis {
manager: redis::aio::ConnectionManager,
key: String,
token: String,
released: bool,
},
}
const UNLOCK_SCRIPT: &str = "if redis.call('get', KEYS[1]) == ARGV[1] \
then return redis.call('del', KEYS[1]) else return 0 end";
impl GameLock {
/// Release the lock. Consumes the guard; plain drop also releases it
/// (best-effort for the Redis variant).
pub async fn release(mut self) -> Result<()> {
if let GameLock::Redis {
manager,
key,
token,
released,
} = &mut self
{
if !*released {
*released = true;
let script = redis::Script::new(UNLOCK_SCRIPT);
let mut conn = manager.clone();
let _: i64 = script
.key(key.clone())
.arg(token.clone())
.invoke_async(&mut conn)
.await?;
}
}
Ok(())
}
}
impl Drop for GameLock {
fn drop(&mut self) {
if let GameLock::Redis {
manager,
key,
token,
released,
} = self
{
if !*released {
*released = true;
let mut manager = manager.clone();
let key = key.clone();
let token = token.clone();
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
let script = redis::Script::new(UNLOCK_SCRIPT);
let _: std::result::Result<i64, _> =
script.key(key).arg(token).invoke_async(&mut manager).await;
});
}
}
}
}
}
/// Abstract persistence + notification layer for live games.
#[async_trait]
pub trait GameStore: Send + Sync {
/// Return the live session for `game_id` or `None`.
async fn load(&self, game_id: &str) -> Result<Option<GameSession>>;
/// Persist `session`, refreshing its TTL and code index.
async fn save(&self, session: &GameSession) -> Result<()>;
/// Return the live session for a join `code` or `None`.
async fn find_by_code(&self, code: &str) -> Result<Option<GameSession>>;
/// Number of live games that have not finished yet.
async fn count_ongoing(&self) -> Result<usize>;
/// Acquire the per-game mutation lock, waiting up to ten seconds.
async fn acquire_lock(&self, game_id: &str) -> Result<GameLock>;
/// Signal that the session of `game_id` changed.
async fn publish(&self, game_id: &str) -> Result<()>;
/// Stream of change signals for one game.
async fn subscribe(&self, game_id: &str) -> Result<BoxStream<'static, ()>>;
/// Enqueue `member` to fire at `due_at` (epoch seconds). Idempotent for
/// identical members: re-adding only updates the due time.
async fn add_deadline(&self, member: &str, due_at: f64) -> Result<()>;
/// Return up to `limit` enqueued members due at or before `now`.
async fn due_deadlines(&self, now: f64, limit: usize) -> Result<Vec<String>>;
/// Return the earliest pending due time (epoch seconds), if any.
async fn next_deadline(&self) -> Result<Option<f64>>;
/// Remove `member` from the queue; a no-op when absent.
async fn remove_deadline(&self, member: &str) -> Result<()>;
/// Wake every worker's deadline consumer (best effort).
async fn notify_deadline(&self) -> Result<()>;
/// Stream of deadline-enqueue signals.
async fn subscribe_deadlines(&self) -> Result<BoxStream<'static, ()>>;
}
pub type SharedGameStore = Arc<dyn GameStore>;
fn broadcast_stream(rx: broadcast::Receiver<()>) -> BoxStream<'static, ()> {
Box::pin(futures_util::stream::unfold(rx, |mut rx| async move {
loop {
match rx.recv().await {
Ok(()) => return Some(((), rx)),
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => return None,
}
}
}))
}
fn mpsc_stream(rx: mpsc::Receiver<()>) -> BoxStream<'static, ()> {
Box::pin(futures_util::stream::unfold(rx, |mut rx| async move {
rx.recv().await.map(|()| ((), rx))
}))
}
// ---------------------------------------------------------------------------
// Redis store
// ---------------------------------------------------------------------------
pub struct RedisGameStore {
client: redis::Client,
manager: redis::aio::ConnectionManager,
registry: Arc<GameRegistry>,
ttl: i64,
}
impl RedisGameStore {
pub async fn new(url: &str, registry: Arc<GameRegistry>, ttl_seconds: i64) -> Result<Self> {
let client = redis::Client::open(url)?;
let manager = redis::aio::ConnectionManager::new(client.clone()).await?;
Ok(Self {
client,
manager,
registry,
ttl: ttl_seconds,
})
}
fn game_key(&self, game_id: &str) -> String {
format!("{GAME_KEY_PREFIX}{game_id}")
}
fn lock_key(&self, game_id: &str) -> String {
format!("{GAME_KEY_PREFIX}{game_id}:lock")
}
fn code_key(&self, code: &str) -> String {
format!("{CODE_KEY_PREFIX}{}", code.to_uppercase())
}
async fn subscribe_to(&self, target: String) -> Result<BoxStream<'static, ()>> {
let mut pubsub = self.client.get_async_pubsub().await?;
pubsub.subscribe(target).await?;
let (tx, rx) = mpsc::channel(32);
let mut messages = pubsub.into_on_message();
tokio::spawn(async move {
while let Some(_message) = messages.next().await {
if tx.send(()).await.is_err() {
break;
}
}
});
Ok(mpsc_stream(rx))
}
}
#[async_trait]
impl GameStore for RedisGameStore {
async fn load(&self, game_id: &str) -> Result<Option<GameSession>> {
let mut conn = self.manager.clone();
let raw: Option<String> = redis::cmd("GET")
.arg(self.game_key(game_id))
.query_async(&mut conn)
.await?;
let Some(raw) = raw else {
tracing::debug!("redis load {game_id}: miss");
return Ok(None);
};
tracing::debug!("redis load {game_id}: hit");
let data: serde_json::Value = serde_json::from_str(&raw)?;
let game_type = data
.get("game_type")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| Error::Store("stored session is missing game_type".into()))?;
let engine = self.registry.require(game_type)?;
Ok(Some(session_from_json(&data, engine.as_ref())?))
}
async fn save(&self, session: &GameSession) -> Result<()> {
let engine = self.registry.require(&session.game_type)?;
let payload = serde_json::to_string(&session_to_json(session, engine.as_ref())?)?;
let mut conn = self.manager.clone();
let mut pipe = redis::pipe();
pipe.atomic();
pipe.cmd("SET")
.arg(self.game_key(&session.id))
.arg(&payload)
.arg("EX")
.arg(self.ttl);
pipe.cmd("SET")
.arg(self.code_key(&session.join_code))
.arg(&session.id)
.arg("EX")
.arg(self.ttl);
if engine.is_finished(session) {
pipe.cmd("ZREM").arg(ONGOING_GAMES_KEY).arg(&session.id);
} else {
pipe.cmd("ZADD")
.arg(ONGOING_GAMES_KEY)
.arg(now_secs() + self.ttl as f64)
.arg(&session.id);
pipe.cmd("EXPIRE").arg(ONGOING_GAMES_KEY).arg(self.ttl);
}
let _: Vec<redis::Value> = pipe.query_async(&mut conn).await?;
tracing::debug!(
"redis save {} (game {}, ttl {}s)",
session.id,
session.game_type,
self.ttl
);
Ok(())
}
async fn find_by_code(&self, code: &str) -> Result<Option<GameSession>> {
let mut conn = self.manager.clone();
let game_id: Option<String> = redis::cmd("GET")
.arg(self.code_key(code))
.query_async(&mut conn)
.await?;
match game_id {
Some(game_id) => self.load(&game_id).await,
None => Ok(None),
}
}
async fn count_ongoing(&self) -> Result<usize> {
let mut conn = self.manager.clone();
let mut pipe = redis::pipe();
pipe.atomic();
pipe.cmd("ZREMRANGEBYSCORE")
.arg(ONGOING_GAMES_KEY)
.arg("-inf")
.arg(now_secs());
pipe.cmd("ZCARD").arg(ONGOING_GAMES_KEY);
let values: Vec<i64> = pipe.query_async(&mut conn).await?;
Ok(values.last().copied().unwrap_or(0).max(0) as usize)
}
async fn acquire_lock(&self, game_id: &str) -> Result<GameLock> {
let key = self.lock_key(game_id);
let token = uuid::Uuid::new_v4().to_string();
let mut conn = self.manager.clone();
let deadline = std::time::Instant::now()
+ std::time::Duration::from_secs_f64(LOCK_BLOCKING_TIMEOUT_SECONDS);
loop {
let acquired: Option<String> = redis::cmd("SET")
.arg(&key)
.arg(&token)
.arg("NX")
.arg("PX")
.arg((LOCK_TIMEOUT_SECONDS * 1000.0) as i64)
.query_async(&mut conn)
.await?;
if acquired.is_some() {
return Ok(GameLock::Redis {
manager: self.manager.clone(),
key,
token,
released: false,
});
}
if std::time::Instant::now() >= deadline {
return Err(Error::Store(format!(
"could not acquire the lock for game {game_id}"
)));
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
}
async fn publish(&self, game_id: &str) -> Result<()> {
let mut conn = self.manager.clone();
redis::cmd("PUBLISH")
.arg(channel(game_id))
.arg("update")
.query_async::<i64>(&mut conn)
.await?;
tracing::debug!("redis publish {game_id}");
Ok(())
}
async fn subscribe(&self, game_id: &str) -> Result<BoxStream<'static, ()>> {
self.subscribe_to(channel(game_id)).await
}
async fn add_deadline(&self, member: &str, due_at: f64) -> Result<()> {
let mut conn = self.manager.clone();
redis::cmd("ZADD")
.arg(DEADLINES_KEY)
.arg(due_at)
.arg(member)
.query_async::<i64>(&mut conn)
.await?;
Ok(())
}
async fn due_deadlines(&self, now: f64, limit: usize) -> Result<Vec<String>> {
let mut conn = self.manager.clone();
let members: Vec<String> = redis::cmd("ZRANGE")
.arg(DEADLINES_KEY)
.arg("-inf")
.arg(now)
.arg("BYSCORE")
.arg("LIMIT")
.arg(0)
.arg(limit)
.query_async(&mut conn)
.await?;
Ok(members)
}
async fn next_deadline(&self) -> Result<Option<f64>> {
let mut conn = self.manager.clone();
let earliest: Vec<(String, f64)> = redis::cmd("ZRANGE")
.arg(DEADLINES_KEY)
.arg(0)
.arg(0)
.arg("WITHSCORES")
.query_async(&mut conn)
.await?;
Ok(earliest.first().map(|(_, score)| *score))
}
async fn remove_deadline(&self, member: &str) -> Result<()> {
let mut conn = self.manager.clone();
redis::cmd("ZREM")
.arg(DEADLINES_KEY)
.arg(member)
.query_async::<i64>(&mut conn)
.await?;
Ok(())
}
async fn notify_deadline(&self) -> Result<()> {
let mut conn = self.manager.clone();
redis::cmd("PUBLISH")
.arg(DEADLINES_CHANNEL)
.arg("deadline")
.query_async::<i64>(&mut conn)
.await?;
Ok(())
}
async fn subscribe_deadlines(&self) -> Result<BoxStream<'static, ()>> {
self.subscribe_to(DEADLINES_CHANNEL.to_string()).await
}
}
// ---------------------------------------------------------------------------
// In-memory store
// ---------------------------------------------------------------------------
/// Process-local store used by tests and when Redis is not configured.
pub struct InMemoryGameStore {
registry: Arc<GameRegistry>,
games: Mutex<HashMap<String, String>>,
codes: Mutex<HashMap<String, String>>,
ongoing: Mutex<HashSet<String>>,
locks: Mutex<HashMap<String, Arc<Mutex<()>>>>,
subscribers: Mutex<HashMap<String, broadcast::Sender<()>>>,
deadlines: Mutex<HashMap<String, f64>>,
deadline_subscribers: broadcast::Sender<()>,
}
impl InMemoryGameStore {
pub fn new(registry: Arc<GameRegistry>) -> Self {
let (deadline_subscribers, _) = broadcast::channel(64);
Self {
registry,
games: Mutex::new(HashMap::new()),
codes: Mutex::new(HashMap::new()),
ongoing: Mutex::new(HashSet::new()),
locks: Mutex::new(HashMap::new()),
subscribers: Mutex::new(HashMap::new()),
deadlines: Mutex::new(HashMap::new()),
deadline_subscribers,
}
}
async fn sender_for(&self, game_id: &str) -> broadcast::Sender<()> {
let mut subscribers = self.subscribers.lock().await;
subscribers
.entry(game_id.to_string())
.or_insert_with(|| broadcast::channel(64).0)
.clone()
}
}
#[async_trait]
impl GameStore for InMemoryGameStore {
async fn load(&self, game_id: &str) -> Result<Option<GameSession>> {
let raw = self.games.lock().await.get(game_id).cloned();
let Some(raw) = raw else {
return Ok(None);
};
let data: serde_json::Value = serde_json::from_str(&raw)?;
let game_type = data
.get("game_type")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| Error::Store("stored session is missing game_type".into()))?;
let engine = self.registry.require(game_type)?;
Ok(Some(session_from_json(&data, engine.as_ref())?))
}
async fn save(&self, session: &GameSession) -> Result<()> {
let engine = self.registry.require(&session.game_type)?;
let payload = serde_json::to_string(&session_to_json(session, engine.as_ref())?)?;
self.games.lock().await.insert(session.id.clone(), payload);
self.codes
.lock()
.await
.insert(session.join_code.to_uppercase(), session.id.clone());
if engine.is_finished(session) {
self.ongoing.lock().await.remove(&session.id);
} else {
self.ongoing.lock().await.insert(session.id.clone());
}
Ok(())
}
async fn find_by_code(&self, code: &str) -> Result<Option<GameSession>> {
let game_id = self.codes.lock().await.get(&code.to_uppercase()).cloned();
match game_id {
Some(game_id) => self.load(&game_id).await,
None => Ok(None),
}
}
async fn count_ongoing(&self) -> Result<usize> {
Ok(self.ongoing.lock().await.len())
}
async fn acquire_lock(&self, game_id: &str) -> Result<GameLock> {
let lock = {
let mut locks = self.locks.lock().await;
locks
.entry(game_id.to_string())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
};
Ok(GameLock::Memory(lock.lock_owned().await))
}
async fn publish(&self, game_id: &str) -> Result<()> {
let sender = self.sender_for(game_id).await;
let _ = sender.send(());
Ok(())
}
async fn subscribe(&self, game_id: &str) -> Result<BoxStream<'static, ()>> {
let sender = self.sender_for(game_id).await;
Ok(broadcast_stream(sender.subscribe()))
}
async fn add_deadline(&self, member: &str, due_at: f64) -> Result<()> {
self.deadlines
.lock()
.await
.insert(member.to_string(), due_at);
Ok(())
}
async fn due_deadlines(&self, now: f64, limit: usize) -> Result<Vec<String>> {
let deadlines = self.deadlines.lock().await;
let mut due: Vec<(String, f64)> = deadlines
.iter()
.filter(|(_, due_at)| **due_at <= now)
.map(|(member, due_at)| (member.clone(), *due_at))
.collect();
due.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
Ok(due
.into_iter()
.take(limit)
.map(|(member, _)| member)
.collect())
}
async fn next_deadline(&self) -> Result<Option<f64>> {
Ok(self
.deadlines
.lock()
.await
.values()
.copied()
.min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)))
}
async fn remove_deadline(&self, member: &str) -> Result<()> {
self.deadlines.lock().await.remove(member);
Ok(())
}
async fn notify_deadline(&self) -> Result<()> {
let _ = self.deadline_subscribers.send(());
Ok(())
}
async fn subscribe_deadlines(&self) -> Result<BoxStream<'static, ()>> {
Ok(broadcast_stream(self.deadline_subscribers.subscribe()))
}
}
+324
View File
@@ -0,0 +1,324 @@
//! WebSocket endpoint for live play.
//!
//! Clients connect to `/ws/games/{game_id}` using their session cookie. Only
//! seated players are accepted; the upgrade is refused before the handshake
//! completes with an HTTP status mirroring `GET /api/games/{id}`: 401
//! unauthenticated, 404 unknown game, 403 not seated.
//!
//! Server -> client messages are JSON objects with a `type`: `state`,
//! `game_over` and `error`. Client -> server messages are JSON objects with
//! an `action`: the platform handles `state`/`sync` itself; every other
//! action is dispatched to the session's game engine with the rest of the
//! message as its payload.
//!
//! 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.
use std::sync::Arc;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Extension;
use futures_util::stream::{SplitSink, SplitStream};
use futures_util::{SinkExt, StreamExt};
use serde_json::{json, Map, Value};
use tokio::sync::Mutex;
use tracing::Instrument;
use crate::auth::current_user;
use crate::engine::GameSession;
use crate::http::py_repr;
use crate::platform::{Platform, SharedPlatform};
use crate::session::SessionHandle;
/// The live-play endpoint; rejects non-seated callers before the upgrade.
pub async fn game_socket(
State(platform): State<SharedPlatform>,
Extension(session): Extension<SessionHandle>,
Path(game_id): Path<String>,
ws: WebSocketUpgrade,
) -> Response {
let Some(user) = current_user(&session).await else {
tracing::debug!("websocket {game_id} rejected: no authenticated user");
return StatusCode::UNAUTHORIZED.into_response();
};
let game = match platform.game_store.load(&game_id).await {
Ok(Some(game)) => game,
Ok(None) => {
tracing::debug!("websocket rejected: unknown game {game_id}");
return StatusCode::NOT_FOUND.into_response();
}
Err(err) => {
tracing::error!("websocket game load failed: {err}");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
if !game.seated(&user.sub) {
tracing::debug!("websocket {game_id} rejected: {} is not seated", user.sub);
return StatusCode::FORBIDDEN.into_response();
}
let session_tag = {
let guard = session.lock().await;
guard.id().map(crate::auth::session_tag)
};
let sub = user.sub.clone();
let span = tracing::info_span!(
"websocket",
"user.id" = %user.sub,
"session.id" = tracing::field::Empty,
"game.id" = %game_id,
);
if let Some(tag) = session_tag {
span.record("session.id", tag);
}
ws.on_upgrade(move |socket| {
async move {
run_socket(platform, socket, game_id, sub).await;
}
.instrument(span)
})
}
#[derive(Clone)]
struct Sender {
sink: Arc<Mutex<SplitSink<WebSocket, Message>>>,
}
impl Sender {
async fn send(&self, payload: Value) {
let Ok(text) = serde_json::to_string(&payload) else {
return;
};
let mut sink = self.sink.lock().await;
let _ = sink.send(Message::Text(text.into())).await;
}
}
fn error_payload(message: impl Into<String>, code: &str) -> Value {
json!({ "type": "error", "code": code, "message": message.into() })
}
fn state_message(platform: &Platform, game: &GameSession, sub: &str) -> Option<Value> {
let engine = platform.registry.require(&game.game_type).ok()?;
let mut game_view = json!({
"id": game.id,
"join_code": game.join_code,
"game_type": game.game_type,
});
if let (Value::Object(view), Value::Object(base)) = (engine.view_for(game, sub), &mut game_view)
{
for (key, value) in view {
base.insert(key, value);
}
}
Some(json!({ "type": "state", "game": game_view }))
}
async fn run_socket(platform: SharedPlatform, socket: WebSocket, game_id: String, sub: String) {
let (sink, mut stream): (SplitSink<WebSocket, Message>, SplitStream<WebSocket>) =
socket.split();
let sender = Sender {
sink: Arc::new(Mutex::new(sink)),
};
tracing::info!("{sub} connected to game {game_id}");
// Initial snapshot and a deadline backstop (the entry may have been lost
// while the game lived on thanks to its sliding TTL).
match platform.game_store.load(&game_id).await {
Ok(Some(game)) => {
if let Some(message) = state_message(&platform, &game, &sub) {
sender.send(message).await;
}
let _ = platform.scheduler.sync_deadline(&game).await;
}
_ => return,
}
let mut events = match platform.game_store.subscribe(&game_id).await {
Ok(events) => events,
Err(err) => {
tracing::error!("websocket subscribe failed: {err}");
return;
}
};
let forward_sender = sender.clone();
let forward_platform = platform.clone();
let forward_game_id = game_id.clone();
let forward_sub = sub.clone();
let forward = tokio::spawn(async move {
while events.next().await.is_some() {
let Ok(Some(game)) = forward_platform.game_store.load(&forward_game_id).await else {
return;
};
if let Some(message) = state_message(&forward_platform, &game, &forward_sub) {
forward_sender.send(message).await;
}
if let Ok(engine) = forward_platform.registry.require(&game.game_type) {
if engine.is_finished(&game) {
let mut payload = json!({ "type": "game_over" });
if let (Value::Object(extra), Value::Object(base)) =
(engine.game_over_view(&game, &forward_sub), &mut payload)
{
for (key, value) in extra {
base.insert(key, value);
}
}
forward_sender.send(payload).await;
return;
}
}
}
});
while let Some(message) = stream.next().await {
match message {
Ok(Message::Text(text)) => {
handle_message(&platform, &sender, &game_id, &sub, text.as_str()).await;
}
Ok(Message::Binary(_)) => {
sender
.send(error_payload(
"expected a text frame with a JSON object",
"invalid",
))
.await;
}
Ok(Message::Close(_)) => break,
Ok(Message::Ping(_)) | Ok(Message::Pong(_)) => {}
Err(err) => {
tracing::debug!("websocket {game_id} receive error: {err}");
break;
}
}
}
forward.abort();
tracing::debug!("{sub} disconnected from game {game_id}");
}
async fn handle_message(
platform: &SharedPlatform,
sender: &Sender,
game_id: &str,
sub: &str,
raw: &str,
) {
let parsed: Value = match serde_json::from_str(raw) {
Ok(parsed) => parsed,
Err(_) => {
tracing::debug!("game {game_id}: malformed message from {sub} (not JSON)");
sender.send(error_payload("invalid JSON", "invalid")).await;
return;
}
};
let Value::Object(data) = parsed else {
tracing::debug!("game {game_id}: malformed message from {sub} (not an object)");
sender
.send(error_payload("message must be a JSON object", "invalid"))
.await;
return;
};
let action = data.get("action");
if let Some(Value::String(action)) = action {
if action == "state" || action == "sync" {
if let Ok(Some(game)) = platform.game_store.load(game_id).await {
if let Some(message) = state_message(platform, &game, sub) {
sender.send(message).await;
}
}
return;
}
}
let Some(Value::String(action)) = action else {
let value = action.cloned().unwrap_or(Value::Null);
sender
.send(error_payload(
format!("unknown action: {}", py_repr(&value)),
"invalid",
))
.await;
return;
};
handle_action(platform, sender, game_id, sub, action, &data).await;
}
async fn handle_action(
platform: &SharedPlatform,
sender: &Sender,
game_id: &str,
sub: &str,
action: &str,
data: &Map<String, Value>,
) {
let lock = match platform.game_store.acquire_lock(game_id).await {
Ok(lock) => lock,
Err(err) => {
tracing::error!("game {game_id}: lock failed: {err}");
sender
.send(error_payload("internal error", "internal"))
.await;
return;
}
};
let result = async {
let Some(mut game) = (match platform.game_store.load(game_id).await {
Ok(game) => game,
Err(err) => {
tracing::error!("game {game_id}: load failed: {err}");
return Err(());
}
}) else {
sender
.send(error_payload("game not found", "not_found"))
.await;
return Ok(());
};
let engine = match platform.registry.require(&game.game_type) {
Ok(engine) => engine.clone(),
Err(err) => {
tracing::error!("game {game_id}: engine lookup failed: {err}");
return Err(());
}
};
let payload: Map<String, Value> = data
.iter()
.filter(|(key, _)| key.as_str() != "action")
.map(|(key, value)| (key.clone(), value.clone()))
.collect();
match engine.handle_action(&mut game, sub, action, &payload) {
Ok(()) => {}
Err(err) => match err.as_game() {
Some(game_error) => {
tracing::debug!(
"game {game_id}: rejected {action:?} by {sub}: {}",
game_error.message
);
sender
.send(error_payload(&game_error.message, game_error.code))
.await;
return Ok(());
}
None => {
tracing::error!("game {game_id}: action failed: {err}");
return Err(());
}
},
}
tracing::debug!("game {game_id}: {sub} performed {action:?}");
if let Err(err) = platform.scheduler.finalize_mutation(&mut game).await {
tracing::error!("game {game_id}: finalize failed: {err}");
}
Ok(())
}
.await;
let _ = lock.release().await;
if result.is_err() {
sender
.send(error_payload("internal error", "internal"))
.await;
}
}
@@ -0,0 +1,469 @@
//! Shared fixtures for the tavolo-platform test suite.
//!
//! `DummyEngine` is a tiny two-player game implementing the platform's
//! `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.
#![allow(dead_code)]
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, Response, StatusCode};
use axum::Router;
use futures_util::StreamExt;
use serde_json::{json, Map, Value};
use tower::ServiceExt;
use tavolo_platform::deadlines::DeadlineScheduler;
use tavolo_platform::engine::{Deadline, GameEngine, GameSession, MatchResult, PlayerResult, Seat};
use tavolo_platform::errors::{Error, GameError, Result};
use tavolo_platform::oidc::{OidcProviderConfig, OidcProviders};
use tavolo_platform::platform::{Platform, SharedPlatform};
use tavolo_platform::registry::GameRegistry;
use tavolo_platform::session::{session_middleware, InMemorySessionStore, SharedSessionStore};
use tavolo_platform::stats::{InMemoryStatsRepo, SharedStatsRepo};
use tavolo_platform::store::{InMemoryGameStore, SharedGameStore};
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct DummyState {
pub target: i64,
pub plays: Vec<String>,
pub started: bool,
pub finished: bool,
pub winner: Option<String>,
pub deadline_in_seconds: Option<f64>,
}
pub struct DummyEngine;
fn state_of(session: &GameSession) -> Result<&DummyState> {
session
.state
.as_ref()
.and_then(|state| state.downcast_ref::<DummyState>())
.ok_or_else(|| Error::Internal("dummy state missing".into()))
}
fn state_of_mut(session: &mut GameSession) -> Result<&mut DummyState> {
session
.state
.as_mut()
.and_then(|state| state.downcast_mut::<DummyState>())
.ok_or_else(|| Error::Internal("dummy state missing".into()))
}
impl DummyEngine {
fn play(session: &mut GameSession, user_sub: &str) -> Result<()> {
let state = state_of_mut(session)?;
state.plays.push(user_sub.to_string());
if state.plays.len() as i64 >= state.target {
state.finished = true;
state.winner = Some(user_sub.to_string());
}
Ok(())
}
}
impl GameEngine for DummyEngine {
fn id(&self) -> &'static str {
"dummy"
}
fn name(&self) -> &'static str {
"Dummy game"
}
fn description(&self) -> &'static str {
"A two-player toy game for testing the platform."
}
fn min_players(&self) -> usize {
2
}
fn max_players(&self) -> usize {
2
}
fn options_schema(&self) -> Value {
json!({
"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.",
},
},
})
}
fn create(&self, session: &mut GameSession, options: &Map<String, Value>) -> Result<()> {
let target = options.get("target").and_then(Value::as_i64).unwrap_or(3);
if target < 1 || options.get("target").is_some_and(|v| v.is_boolean()) {
return Err(GameError::illegal_move("target must be a positive integer").into());
}
let creator = session.players[0].clone();
session.players[0] = Seat {
user_sub: creator.user_sub,
display_name: creator.display_name,
team: Some("A".into()),
};
session.state = Some(Box::new(DummyState {
target,
plays: Vec::new(),
started: false,
finished: false,
winner: None,
deadline_in_seconds: options.get("deadline_in_seconds").and_then(Value::as_f64),
}));
Ok(())
}
fn join(&self, session: &mut GameSession, user_sub: &str, display_name: &str) -> Result<()> {
if state_of(session)?.started {
return Err(GameError::game_not_started("game has already started").into());
}
if session.seated(user_sub) {
return Err(GameError::already_joined("already joined this game").into());
}
if session.players.len() >= 2 {
return Err(GameError::lobby_full("game is full").into());
}
let team = if session.players.is_empty() { "A" } else { "B" };
session.players.push(Seat {
user_sub: user_sub.into(),
display_name: display_name.into(),
team: Some(team.into()),
});
if session.players.len() == 2 {
state_of_mut(session)?.started = true;
}
Ok(())
}
fn handle_action(
&self,
session: &mut GameSession,
user_sub: &str,
action: &str,
_payload: &Map<String, Value>,
) -> Result<()> {
if !state_of(session)?.started {
return Err(GameError::game_not_started("the game has not started yet").into());
}
if state_of(session)?.finished {
return Err(GameError::game_finished("the match is over").into());
}
if !session.seated(user_sub) {
return Err(GameError::not_your_turn("you are not seated in this game").into());
}
if action != "play" {
return Err(GameError::illegal_move(format!(
"unknown action: {}",
tavolo_platform::http::py_repr(&Value::String(action.to_string()))
))
.into());
}
DummyEngine::play(session, user_sub)
}
fn view_for(&self, session: &GameSession, user_sub: &str) -> Value {
let state = state_of(session).expect("dummy state");
json!({
"started": state.started,
"finished": state.finished,
"winner": state.winner,
"plays": state.plays.len(),
"target": state.target,
"viewer_seated": session.seated(user_sub),
})
}
fn lobby_view(&self, session: &GameSession) -> Value {
let state = state_of(session).expect("dummy state");
json!({
"phase": if state.started { "playing" } else { "lobby" },
"target": state.target,
})
}
fn in_lobby(&self, session: &GameSession) -> bool {
state_of(session)
.map(|state| !state.started)
.unwrap_or(false)
}
fn is_finished(&self, session: &GameSession) -> bool {
state_of(session)
.map(|state| state.finished)
.unwrap_or(false)
}
fn result(&self, session: &GameSession) -> Result<MatchResult> {
let state = state_of(session)?;
let Some(winner) = state.winner.clone() else {
return Err(GameError::illegal_move("no result: the match is not finished").into());
};
let subs: Vec<String> = session
.players
.iter()
.map(|seat| seat.user_sub.clone())
.collect();
Ok(MatchResult {
teams: vec![vec![subs[0].clone()], vec![subs[1].clone()]],
winner_team: subs.iter().position(|sub| *sub == winner).unwrap_or(0),
players: session
.players
.iter()
.enumerate()
.map(|(index, seat)| PlayerResult {
user_sub: seat.user_sub.clone(),
seat: index as i64,
won: seat.user_sub == winner,
team: seat.team.clone(),
score: state
.plays
.iter()
.filter(|sub| **sub == seat.user_sub)
.count() as f64,
details: json!({
"plays": state.plays.iter().filter(|sub| **sub == seat.user_sub).count(),
}),
})
.collect(),
summary: json!({
"target": state.target,
"plays": state.plays.len(),
"winner": winner,
}),
})
}
fn state_to_json(&self, state: &(dyn std::any::Any + Send + Sync)) -> Result<Value> {
let state = state
.downcast_ref::<DummyState>()
.ok_or_else(|| Error::Internal("dummy state missing".into()))?;
Ok(serde_json::to_value(state)?)
}
fn state_from_json(&self, data: &Value) -> Result<Box<dyn std::any::Any + Send + Sync>> {
Ok(Box::new(serde_json::from_value::<DummyState>(
data.clone(),
)?))
}
fn next_deadline(&self, session: &GameSession) -> Result<Option<Deadline>> {
let state = state_of(session)?;
let Some(seconds) = state.deadline_in_seconds else {
return Ok(None);
};
if !state.started || state.finished {
return Ok(None);
}
let due_at = tavolo_platform::datetime::now()
+ chrono::Duration::milliseconds((seconds * 1000.0) as i64);
Ok(Some(Deadline {
kind: "tick".into(),
due_at,
token: format!("tick:{}", state.plays.len()),
}))
}
fn fire_deadline(&self, session: &mut GameSession, kind: &str, token: &str) -> Result<()> {
let current = self.next_deadline(session)?;
match current {
Some(current) if current.kind == kind && current.token == token => {}
_ => return Err(GameError::illegal_move("stale deadline").into()),
}
let user_sub = session.players[0].user_sub.clone();
DummyEngine::play(session, &user_sub)
}
}
fn test_oidc_config(provider: &str) -> OidcProviderConfig {
OidcProviderConfig {
provider: provider.into(),
issuer: format!("http://localhost:8180/{provider}"),
client_id: "tavolo".into(),
client_secret: None,
redirect_uri: format!("http://localhost:8080/auth/{provider}/callback"),
post_login_redirect: "/".into(),
post_logout_redirect: "/".into(),
}
}
pub struct TestPlatform {
pub platform: SharedPlatform,
pub session_store: SharedSessionStore,
pub stats: Arc<InMemoryStatsRepo>,
}
impl TestPlatform {
pub fn router(&self) -> Router {
Router::new()
.merge(
tavolo_platform::routes::router(&["default".to_string()])
.with_state(self.platform.clone()),
)
.layer(axum::middleware::from_fn_with_state(
self.session_store.clone(),
session_middleware,
))
}
}
pub fn make_platform(heartbeat_ms: u64) -> TestPlatform {
let mut registry = GameRegistry::new();
registry
.register(Arc::new(DummyEngine))
.expect("register dummy");
let registry = Arc::new(registry);
let game_store: SharedGameStore = Arc::new(InMemoryGameStore::new(registry.clone()));
let session_store: SharedSessionStore = Arc::new(InMemorySessionStore::new());
let stats = Arc::new(InMemoryStatsRepo::new());
let stats_repo: SharedStatsRepo = stats.clone();
let oidc =
Arc::new(OidcProviders::new(vec![test_oidc_config("default")]).expect("oidc providers"));
let scheduler = DeadlineScheduler::new(
game_store.clone(),
registry.clone(),
stats_repo.clone(),
heartbeat_ms,
20,
);
scheduler.start();
let platform = Arc::new(Platform {
registry,
game_store,
scheduler,
oidc,
stats: stats_repo,
});
TestPlatform {
platform,
session_store,
stats,
}
}
/// Persist a logged-in session and return the `Cookie` header value for it.
pub async fn login_cookie(
store: &SharedSessionStore,
provider: &str,
sub: &str,
name: &str,
) -> String {
let id = format!("session-{sub}");
let mut data = Map::new();
data.insert(
format!("oidc:{provider}:user"),
json!({"sub": sub, "preferred_username": name}),
);
store
.save(&id, &data, Some(3600))
.await
.expect("save session");
format!("session_id={id}")
}
pub async fn call(router: &Router, request: Request<Body>) -> Response<axum::body::Body> {
router.clone().oneshot(request).await.expect("request")
}
pub async fn body_json(response: Response<Body>) -> Value {
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
serde_json::from_slice(&bytes).expect("json body")
}
pub fn get(uri: &str, cookie: Option<&str>) -> Request<Body> {
let mut builder = Request::builder().method("GET").uri(uri);
if let Some(cookie) = cookie {
builder = builder.header("cookie", cookie);
}
builder.body(Body::empty()).expect("request")
}
pub fn post_json(uri: &str, cookie: Option<&str>, body: &Value) -> Request<Body> {
let mut builder = Request::builder()
.method("POST")
.uri(uri)
.header("content-type", "application/json");
if let Some(cookie) = cookie {
builder = builder.header("cookie", cookie);
}
builder
.body(Body::from(serde_json::to_vec(body).expect("json")))
.expect("request")
}
pub async fn read_text(
mut socket: tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
) -> String {
let message = socket
.next()
.await
.expect("some message")
.expect("ok message");
match message {
tokio_tungstenite::tungstenite::Message::Text(text) => text.to_string(),
other => panic!("expected text message, got {other:?}"),
}
}
pub async fn read_json(
socket: &mut tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
) -> Value {
let message = socket
.next()
.await
.expect("some message")
.expect("ok message");
match message {
tokio_tungstenite::tungstenite::Message::Text(text) => {
serde_json::from_str(&text).expect("json message")
}
other => panic!("expected text message, got {other:?}"),
}
}
pub fn ws_request(addr: std::net::SocketAddr, game_id: &str, cookie: Option<&str>) -> Request<()> {
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
let url = format!("ws://{addr}/ws/games/{game_id}");
let mut request = url.into_client_request().expect("websocket client request");
if let Some(cookie) = cookie {
request.headers_mut().insert(
"cookie",
axum::http::HeaderValue::from_str(cookie).expect("cookie header"),
);
}
request
}
pub async fn start_server(router: Router) -> std::net::SocketAddr {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let addr = listener.local_addr().expect("addr");
tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
addr
}
pub fn json_status(response: &Response<Body>) -> StatusCode {
response.status()
}
@@ -0,0 +1,410 @@
//! HTTP routes and WebSocket protocol of the platform.
mod helpers;
use futures_util::SinkExt;
use helpers::*;
use serde_json::json;
use tokio_tungstenite::tungstenite::handshake::client::Response as WsResponse;
async fn create_and_start(test: &helpers::TestPlatform) -> (String, String, String, String) {
let alice = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let bob = login_cookie(&test.session_store, "default", "bob", "Bob").await;
let router = test.router();
let lobby = body_json(
call(
&router,
post_json(
"/api/games",
Some(&alice),
&json!({"options": {"target": 3}}),
),
)
.await,
)
.await;
let game_id = lobby["id"].as_str().unwrap().to_string();
let code = lobby["join_code"].as_str().unwrap().to_string();
let response = call(
&router,
post_json("/api/games/join", Some(&bob), &json!({"code": code})),
)
.await;
assert_eq!(response.status(), 200);
(game_id, code, alice, bob)
}
#[tokio::test]
async fn game_types_lists_the_registered_engine() {
let test = make_platform(50);
let router = test.router();
let response = call(&router, get("/api/game-types", None)).await;
assert_eq!(response.status(), 200);
let body = body_json(response).await;
assert_eq!(body["results"][0]["id"], "dummy");
assert_eq!(body["results"][0]["min_players"], 2);
assert_eq!(body["results"][0]["options_schema"]["type"], "object");
}
#[tokio::test]
async fn create_requires_authentication() {
let test = make_platform(50);
let router = test.router();
let response = call(&router, post_json("/api/games", None, &json!({}))).await;
assert_eq!(response.status(), 401);
let body = body_json(response).await;
assert_eq!(body["error"], "unauthenticated");
}
#[tokio::test]
async fn create_validates_game_type_and_options() {
let test = make_platform(50);
let cookie = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let router = test.router();
let response = call(
&router,
post_json("/api/games", Some(&cookie), &json!({"game_type": "nope"})),
)
.await;
assert_eq!(response.status(), 400);
let body = body_json(response).await;
assert_eq!(body["error"], "unknown game_type: 'nope'");
let response = call(
&router,
post_json(
"/api/games",
Some(&cookie),
&json!({"options": {"target": 0}}),
),
)
.await;
assert_eq!(response.status(), 400);
let body = body_json(response).await;
assert_eq!(body["error"], "target must be a positive integer");
let response = call(
&router,
post_json("/api/games", Some(&cookie), &json!({"options": 3})),
)
.await;
assert_eq!(response.status(), 400);
assert_eq!(
body_json(response).await["error"],
"options must be an object"
);
}
#[tokio::test]
async fn join_flow_and_snapshot_authorization() {
let test = make_platform(50);
let alice = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let router = test.router();
let lobby =
body_json(call(&router, post_json("/api/games", Some(&alice), &json!({}))).await).await;
let game_id = lobby["id"].as_str().unwrap().to_string();
let code = lobby["join_code"].as_str().unwrap().to_string();
// Missing / unknown code.
let response = call(
&router,
post_json("/api/games/join", Some(&alice), &json!({})),
)
.await;
assert_eq!(response.status(), 400);
assert_eq!(body_json(response).await["error"], "code is required");
let bob = login_cookie(&test.session_store, "default", "bob", "Bob").await;
let response = call(
&router,
post_json("/api/games/join", Some(&bob), &json!({"code": "ZZZZZZ"})),
)
.await;
assert_eq!(response.status(), 404);
// Preview before joining.
let preview = body_json(
call(
&router,
get(&format!("/api/games/preview?code={code}"), Some(&bob)),
)
.await,
)
.await;
assert_eq!(preview["game_name"], "Dummy game");
assert_eq!(preview["seats_open"], 1);
assert_eq!(preview["players"][0]["sub"], "alice");
// The creator cannot join their own (still open) lobby twice.
let response = call(
&router,
post_json("/api/games/join", Some(&alice), &json!({"code": code})),
)
.await;
assert_eq!(response.status(), 409);
assert_eq!(
body_json(response).await["error"],
"already joined this game"
);
// Bob joins and starts the match.
let response = call(
&router,
post_json("/api/games/join", Some(&bob), &json!({"code": code})),
)
.await;
assert_eq!(response.status(), 200);
let started = body_json(response).await;
assert_eq!(started["started"], true);
assert_eq!(started["your_turn"], json!(null));
// Bob cannot rejoin a started game; a third player finds the lobby full.
let response = call(
&router,
post_json("/api/games/join", Some(&bob), &json!({"code": code})),
)
.await;
assert_eq!(response.status(), 409);
assert_eq!(
body_json(response).await["error"],
"game has already started"
);
let carol = login_cookie(&test.session_store, "default", "carol", "Carol").await;
let response = call(
&router,
post_json("/api/games/join", Some(&carol), &json!({"code": code})),
)
.await;
assert_eq!(response.status(), 409);
assert_eq!(
body_json(response).await["error"],
"game has already started"
);
// Snapshots: unknown game 404, non-seated 403, seated 200, anonymous 401.
let response = call(&router, get("/api/games/no-such-id", Some(&alice))).await;
assert_eq!(response.status(), 404);
let response = call(&router, get(&format!("/api/games/{game_id}"), Some(&carol))).await;
assert_eq!(response.status(), 403);
let response = call(&router, get(&format!("/api/games/{game_id}"), Some(&alice))).await;
assert_eq!(response.status(), 200);
let snapshot = body_json(response).await;
assert_eq!(snapshot["id"], game_id);
assert_eq!(snapshot["viewer_seated"], true);
let response = call(&router, get(&format!("/api/games/{game_id}"), None)).await;
assert_eq!(response.status(), 401);
// The whoami endpoint reflects the session identity.
let me = body_json(call(&router, get("/api/me", Some(&alice))).await).await;
assert_eq!(me["sub"], "alice");
assert_eq!(me["name"], "Alice");
let response = call(&router, get("/api/me", None)).await;
assert_eq!(response.status(), 401);
}
#[tokio::test]
async fn auth_helpers_are_provider_agnostic() {
let test = make_platform(50);
let alice = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let router = test.router();
let providers = body_json(call(&router, get("/api/auth/providers", None)).await).await;
assert_eq!(
providers["providers"],
json!([{"name": "default", "login_url": "/auth/default/login"}])
);
let response = call(&router, get("/auth/login", None)).await;
assert_eq!(response.status(), 302);
assert_eq!(
response.headers()["location"].to_str().unwrap(),
"/auth/default/login"
);
let response = call(&router, get("/auth/logout", Some(&alice))).await;
assert_eq!(response.status(), 302);
assert_eq!(
response.headers()["location"].to_str().unwrap(),
"/auth/default/logout"
);
let response = call(&router, get("/auth/logout", None)).await;
assert_eq!(response.headers()["location"].to_str().unwrap(), "/");
let response = call(&router, get("/api/openapi.json", None)).await;
assert_eq!(response.status(), 200);
}
#[tokio::test]
async fn websocket_rejects_unauthorized_unknown_and_unseated() {
let test = make_platform(50);
let (game_id, _code, alice, _bob) = create_and_start(&test).await;
let carol = login_cookie(&test.session_store, "default", "carol", "Carol").await;
let addr = start_server(test.router()).await;
let error = tokio_tungstenite::connect_async(ws_request(addr, &game_id, None))
.await
.expect_err("anonymous upgrade must be refused");
match error {
tokio_tungstenite::tungstenite::Error::Http(response) => {
assert_eq!(response.status(), 401);
}
other => panic!("unexpected error: {other:?}"),
}
let error = tokio_tungstenite::connect_async(ws_request(addr, "unknown-game", Some(&alice)))
.await
.expect_err("unknown game upgrade must be refused");
match error {
tokio_tungstenite::tungstenite::Error::Http(response) => {
assert_eq!(response.status(), 404);
}
other => panic!("unexpected error: {other:?}"),
}
let error = tokio_tungstenite::connect_async(ws_request(addr, &game_id, Some(&carol)))
.await
.expect_err("non-seated upgrade must be refused");
match error {
tokio_tungstenite::tungstenite::Error::Http(response) => {
assert_eq!(response.status(), 403);
}
other => panic!("unexpected error: {other:?}"),
}
}
#[tokio::test]
async fn websocket_broadcasts_moves_and_game_over() {
let test = make_platform(50);
let (game_id, _code, alice, bob) = create_and_start(&test).await;
let addr = start_server(test.router()).await;
let (mut alice_ws, _): (_, WsResponse) =
tokio_tungstenite::connect_async(ws_request(addr, &game_id, Some(&alice)))
.await
.expect("alice connects");
let (mut bob_ws, _): (_, WsResponse) =
tokio_tungstenite::connect_async(ws_request(addr, &game_id, Some(&bob)))
.await
.expect("bob connects");
let first = read_json(&mut alice_ws).await;
assert_eq!(first["type"], "state");
assert_eq!(first["game"]["viewer_seated"], true);
let first_bob = read_json(&mut bob_ws).await;
assert_eq!(first_bob["type"], "state");
// Alice plays; both connections see the update.
alice_ws
.send(tokio_tungstenite::tungstenite::Message::Text(
json!({"action": "play"}).to_string(),
))
.await
.expect("send play");
let update = read_json(&mut alice_ws).await;
assert_eq!(update["game"]["plays"], 1);
let update = read_json(&mut bob_ws).await;
assert_eq!(update["game"]["plays"], 1);
// Requesting a snapshot resends the state.
bob_ws
.send(tokio_tungstenite::tungstenite::Message::Text(
json!({"action": "state"}).to_string(),
))
.await
.expect("send state");
let snapshot = read_json(&mut bob_ws).await;
assert_eq!(snapshot["type"], "state");
assert_eq!(snapshot["game"]["plays"], 1);
}
#[tokio::test]
async fn websocket_reports_errors_and_malformed_messages() {
let test = make_platform(50);
let (game_id, _code, alice, _bob) = create_and_start(&test).await;
let addr = start_server(test.router()).await;
let (mut ws, _): (_, WsResponse) =
tokio_tungstenite::connect_async(ws_request(addr, &game_id, Some(&alice)))
.await
.expect("alice connects");
read_json(&mut ws).await;
ws.send(tokio_tungstenite::tungstenite::Message::Text(
"not json".into(),
))
.await
.unwrap();
let error = read_json(&mut ws).await;
assert_eq!(error["type"], "error");
assert_eq!(error["message"], "invalid JSON");
ws.send(tokio_tungstenite::tungstenite::Message::Text(
json!([1, 2]).to_string(),
))
.await
.unwrap();
let error = read_json(&mut ws).await;
assert_eq!(error["message"], "message must be a JSON object");
ws.send(tokio_tungstenite::tungstenite::Message::Text(
json!({"action": "dance"}).to_string(),
))
.await
.unwrap();
let error = read_json(&mut ws).await;
assert_eq!(error["type"], "error");
assert_eq!(error["code"], "illegal_move");
assert_eq!(error["message"], "unknown action: 'dance'");
ws.send(tokio_tungstenite::tungstenite::Message::Text(
json!({"action": 7}).to_string(),
))
.await
.unwrap();
let error = read_json(&mut ws).await;
assert_eq!(error["message"], "unknown action: 7");
}
#[tokio::test]
async fn websocket_sends_game_over_once_the_match_finishes() {
let test = make_platform(50);
let alice = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let bob = login_cookie(&test.session_store, "default", "bob", "Bob").await;
let router = test.router();
let lobby = body_json(
call(
&router,
post_json(
"/api/games",
Some(&alice),
&json!({"options": {"target": 1}}),
),
)
.await,
)
.await;
let game_id = lobby["id"].as_str().unwrap().to_string();
let code = lobby["join_code"].as_str().unwrap().to_string();
call(
&router,
post_json("/api/games/join", Some(&bob), &json!({"code": code})),
)
.await;
let addr = start_server(test.router()).await;
let (mut ws, _): (_, WsResponse) =
tokio_tungstenite::connect_async(ws_request(addr, &game_id, Some(&alice)))
.await
.expect("alice connects");
read_json(&mut ws).await;
ws.send(tokio_tungstenite::tungstenite::Message::Text(
json!({"action": "play"}).to_string(),
))
.await
.unwrap();
let state = read_json(&mut ws).await;
assert_eq!(state["type"], "state");
assert_eq!(state["game"]["finished"], true);
let game_over = read_json(&mut ws).await;
assert_eq!(game_over["type"], "game_over");
}
@@ -0,0 +1,160 @@
//! Postgres-backed stats tests, run only when `TEST_DATABASE_URL` points at a
//! throwaway Postgres database. The compose stack exposes one on
//! `postgres://tavolo:password@localhost:5432/tavolo`.
mod helpers;
use helpers::DummyEngine;
use serde_json::Map;
use sqlx::PgPool;
use tavolo_platform::engine::{GameEngine, GameSession, Seat};
use tavolo_platform::stats::{PostgresStatsRepo, StatsRepo};
async fn pool() -> Option<PgPool> {
let url = std::env::var("TEST_DATABASE_URL")
.ok()
.filter(|url| !url.is_empty())?;
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(4)
.connect(&url)
.await
.expect("connect");
sqlx::raw_sql(include_str!("../../tavolo-app/migrations/0001_init.sql"))
.execute(&pool)
.await
.expect("schema");
// Each test starts from a clean slate; other sessions may hold rows, so
// only this test's users are removed.
sqlx::query("DELETE FROM match_player WHERE user_sub IN ('alice', 'bob', 'carol', 'dave')")
.execute(&pool)
.await
.expect("clean players");
sqlx::query("DELETE FROM player_rating WHERE user_sub IN ('alice', 'bob', 'carol', 'dave')")
.execute(&pool)
.await
.expect("clean ratings");
sqlx::query(
"DELETE FROM \"match\" WHERE id NOT IN (SELECT DISTINCT match_id FROM match_player)",
)
.execute(&pool)
.await
.expect("clean matches");
Some(pool)
}
fn finished_game(id: &str, winner_plays: &str) -> GameSession {
let engine = DummyEngine;
let mut game = GameSession::new(id.to_string(), engine.id(), "ABCDEF");
game.creator_sub = "alice".into();
game.players = vec![Seat::new("alice", "Alice"), Seat::new("bob", "Bob")];
game.players[0].team = Some("A".into());
game.players[1].team = Some("B".into());
game.created_at = Some(tavolo_platform::datetime::now() - chrono::Duration::minutes(5));
let mut options = Map::new();
options.insert("target".into(), serde_json::json!(1));
engine
.create(&mut game, &options)
.expect("create dummy state");
{
let state = game
.state
.as_mut()
.and_then(|state| state.downcast_mut::<helpers::DummyState>())
.expect("dummy state");
state.started = true;
}
engine
.handle_action(&mut game, winner_plays, "play", &Map::new())
.expect("winning play");
game.finished_at = Some(tavolo_platform::datetime::now());
game
}
#[tokio::test]
async fn postgres_persists_matches_history_ratings_and_leaderboard() {
let Some(pool) = pool().await else {
eprintln!("TEST_DATABASE_URL unset: skipping");
return;
};
let repo = PostgresStatsRepo::new(pool.clone());
let engine = DummyEngine;
let game = finished_game("00000000-0000-0000-0000-000000000001", "bob");
assert!(engine.is_finished(&game));
repo.save_match(&game, &engine).await.expect("save match");
// History for the winner includes the result and the Elo delta.
let (matches, cursor) = repo
.my_matches("bob", None, 20, None)
.await
.expect("history");
assert_eq!(matches.len(), 1);
assert!(cursor.is_none());
assert_eq!(matches[0].result["winner"], "bob");
assert!(matches[0].you_won);
assert_eq!(matches[0].your_elo_delta, Some(16));
assert_eq!(matches[0].players.len(), 2);
// Ratings: 1500 + 16 for bob, 1500 - 16 for alice.
let ratings = repo.my_ratings("bob").await.expect("ratings");
assert_eq!(ratings.len(), 1);
assert_eq!(ratings[0].rating, 1516);
assert_eq!(ratings[0].matches_played, 1);
let alice_ratings = repo.my_ratings("alice").await.expect("ratings");
assert_eq!(alice_ratings[0].rating, 1484);
// Leaderboard: winner first.
let leaderboard = repo.leaderboard(None, "dummy").await.expect("leaderboard");
let bob = leaderboard
.iter()
.find(|entry| entry.user_sub == "bob")
.expect("bob ranked");
let alice = leaderboard
.iter()
.find(|entry| entry.user_sub == "alice")
.expect("alice ranked");
assert_eq!(bob.elo, 1516);
assert_eq!(bob.wins, 1);
assert_eq!(alice.elo, 1484);
assert_eq!(leaderboard[0].user_sub, "bob");
// Backfill replays the same match and reproduces the deltas.
let replayed = repo.backfill_elo().await.expect("backfill");
assert!(replayed >= 1);
let ratings = repo.my_ratings("bob").await.expect("ratings");
assert_eq!(ratings[0].rating, 1516);
// A second match loses to a fresh page cursor: page size 1 has more.
let game2 = finished_game("00000000-0000-0000-0000-000000000002", "alice");
repo.save_match(&game2, &engine)
.await
.expect("save match 2");
let (page, cursor) = repo.my_matches("alice", None, 1, None).await.expect("page");
assert_eq!(page.len(), 1);
assert!(cursor.is_some(), "expected a next cursor");
let cursor_map = cursor.as_deref().map(parse_cursor);
let (next, _) = repo
.my_matches("alice", None, 1, cursor_map.as_ref())
.await
.expect("next page");
assert_eq!(next.len(), 1);
assert_ne!(page[0].id, next[0].id);
// Game-type filters are applied.
let (filtered, _) = repo
.my_matches("alice", Some("dummy"), 20, None)
.await
.expect("filtered");
assert_eq!(filtered.len(), 2);
let (none, _) = repo
.my_matches("alice", Some("scopone_scientifico"), 20, None)
.await
.expect("filtered");
assert!(none.is_empty());
}
fn parse_cursor(cursor: &str) -> serde_json::Map<String, serde_json::Value> {
tavolo_platform::pagination::decode_cursor(Some(cursor))
.expect("cursor")
.expect("some")
}
@@ -0,0 +1,138 @@
//! Redis-backed store tests, run only when `TEST_REDIS_URL` points at a
//! throwaway Redis instance. The compose stack exposes one on
//! `redis://127.0.0.1:6379/0`.
mod helpers;
use std::sync::Arc;
use helpers::DummyEngine;
use tavolo_platform::registry::GameRegistry;
use tavolo_platform::store::{GameStore, RedisGameStore};
fn redis_url() -> Option<String> {
std::env::var("TEST_REDIS_URL")
.ok()
.filter(|url| !url.is_empty())
}
async fn store(ttl: i64) -> Option<RedisGameStore> {
let url = redis_url()?;
let mut registry = GameRegistry::new();
registry.register(Arc::new(DummyEngine)).expect("register");
Some(
RedisGameStore::new(&url, Arc::new(registry), ttl)
.await
.expect("redis store"),
)
}
async fn create_game(store: &RedisGameStore) -> tavolo_platform::GameSession {
use tavolo_platform::engine::{GameEngine, GameSession, Seat};
let engine = DummyEngine;
let unique = uuid::Uuid::new_v4().to_string();
let mut game = GameSession::new(unique.clone(), engine.id(), unique[..6].to_uppercase());
game.creator_sub = "alice".into();
game.players = vec![Seat::new("alice", "Alice")];
game.created_at = Some(tavolo_platform::datetime::now());
engine
.create(&mut game, &serde_json::Map::new())
.expect("create");
store.save(&game).await.expect("save");
game
}
#[tokio::test]
async fn redis_round_trips_sessions_codes_and_ongoing_counts() {
let Some(store) = store(60).await else {
eprintln!("TEST_REDIS_URL unset: skipping");
return;
};
let game = create_game(&store).await;
let loaded = store.load(&game.id).await.expect("load").expect("found");
assert_eq!(loaded.join_code, game.join_code);
assert!(loaded.seated("alice"));
let by_code = store
.find_by_code(&game.join_code.to_lowercase())
.await
.expect("find")
.expect("found");
assert_eq!(by_code.id, game.id);
assert!(store.count_ongoing().await.expect("count") >= 1);
// Save with TTL still lists the game; a second save does not duplicate.
store.save(&loaded).await.expect("save again");
assert!(store.count_ongoing().await.expect("count") >= 1);
store
.remove_deadline(&format!("missing-{}", game.id))
.await
.unwrap();
store
.remove_deadline(&serde_json::json!({}).to_string())
.await
.unwrap();
}
#[tokio::test]
async fn redis_locks_are_exclusive_and_released() {
let Some(store) = store(60).await else {
eprintln!("TEST_REDIS_URL unset: skipping");
return;
};
let game = create_game(&store).await;
let first = store.acquire_lock(&game.id).await.expect("first lock");
let second_store = store_second().await.expect("second store");
let acquired = Arc::new(std::sync::atomic::AtomicBool::new(false));
let acquired_clone = acquired.clone();
let game_id = game.id.clone();
let waiter = tokio::spawn(async move {
let _lock = second_store
.acquire_lock(&game_id)
.await
.expect("second lock");
acquired_clone.store(true, std::sync::atomic::Ordering::SeqCst);
});
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
assert!(!acquired.load(std::sync::atomic::Ordering::SeqCst));
first.release().await.expect("release");
tokio::time::timeout(std::time::Duration::from_secs(3), waiter)
.await
.expect("waiter acquired")
.expect("waiter task");
assert!(acquired.load(std::sync::atomic::Ordering::SeqCst));
}
#[tokio::test]
async fn redis_pubsub_signals_and_deadline_queue() {
let Some(store) = store(60).await else {
eprintln!("TEST_REDIS_URL unset: skipping");
return;
};
use futures_util::StreamExt;
let game = create_game(&store).await;
let mut events = store.subscribe(&game.id).await.expect("subscribe");
// The subscription registers asynchronously; a short delay avoids racing
// the PUBLISH with the SUBSCRIBE.
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
store.publish(&game.id).await.expect("publish");
let signalled = tokio::time::timeout(std::time::Duration::from_secs(3), events.next()).await;
assert!(signalled.is_ok(), "no change signal received");
let member = format!("deadline-{}", uuid::Uuid::new_v4());
store.add_deadline(&member, 0.0).await.expect("add");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let due = store
.due_deadlines(tavolo_platform::datetime::now().timestamp() as f64, 10)
.await
.expect("due");
assert!(due.contains(&member), "{due:?}");
assert!(store.next_deadline().await.expect("next").is_some());
store.remove_deadline(&member).await.expect("remove");
}
async fn store_second() -> Option<RedisGameStore> {
store(60).await
}
@@ -0,0 +1,317 @@
//! Store, deadline scheduler and stats behavior of the platform.
mod helpers;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use futures_util::StreamExt;
use helpers::*;
use serde_json::{json, Map};
use tokio::sync::Notify;
#[tokio::test]
async fn store_round_trips_a_session_and_indexes_join_codes() {
let test = make_platform(50);
let cookie = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let router = test.router();
let response = call(&router, post_json("/api/games", Some(&cookie), &json!({}))).await;
assert_eq!(response.status(), 201);
let lobby = body_json(response).await;
let game_id = lobby["id"].as_str().unwrap().to_string();
let code = lobby["join_code"].as_str().unwrap().to_string();
assert_eq!(lobby["seats_open"], 1);
let platform = &test.platform;
let loaded = platform.game_store.load(&game_id).await.unwrap().unwrap();
assert_eq!(loaded.join_code, code);
assert!(loaded.seated("alice"));
assert_eq!(platform.game_store.count_ongoing().await.unwrap(), 1);
// Codes are case-insensitive.
let by_code = platform
.game_store
.find_by_code(&code.to_lowercase())
.await
.unwrap()
.unwrap();
assert_eq!(by_code.id, game_id);
}
#[tokio::test]
async fn store_lock_serializes_mutations() {
let test = make_platform(50);
let cookie = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let router = test.router();
let lobby =
body_json(call(&router, post_json("/api/games", Some(&cookie), &json!({}))).await).await;
let game_id = lobby["id"].as_str().unwrap().to_string();
let store = test.platform.game_store.clone();
let first = store.acquire_lock(&game_id).await.unwrap();
let acquired = Arc::new(AtomicBool::new(false));
let acquired_clone = acquired.clone();
let store_clone = store.clone();
let game_id_clone = game_id.clone();
let waiter = tokio::spawn(async move {
let _lock = store_clone.acquire_lock(&game_id_clone).await.unwrap();
acquired_clone.store(true, Ordering::SeqCst);
});
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
assert!(!acquired.load(Ordering::SeqCst), "lock was not exclusive");
first.release().await.unwrap();
tokio::time::timeout(std::time::Duration::from_secs(2), waiter)
.await
.expect("waiter acquired the lock")
.expect("waiter task");
assert!(acquired.load(Ordering::SeqCst));
}
#[tokio::test]
async fn store_publish_reaches_subscribers() {
let test = make_platform(50);
let store = test.platform.game_store.clone();
let mut events = store.subscribe("some-game").await.unwrap();
store.publish("some-game").await.unwrap();
let notified = tokio::time::timeout(std::time::Duration::from_secs(2), events.next()).await;
assert!(notified.is_ok(), "subscriber was not notified");
}
#[tokio::test]
async fn deadline_fires_an_auto_play() {
let test = make_platform(20);
let cookie = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let router = test.router();
let lobby = body_json(
call(
&router,
post_json(
"/api/games",
Some(&cookie),
&json!({"options": {"target": 100, "deadline_in_seconds": 0.05}}),
),
)
.await,
)
.await;
let game_id = lobby["id"].as_str().unwrap().to_string();
let code = lobby["join_code"].as_str().unwrap().to_string();
let bob_cookie = login_cookie(&test.session_store, "default", "bob", "Bob").await;
// Joining the last player arms the tick deadline.
let response = call(
&router,
post_json("/api/games/join", Some(&bob_cookie), &json!({"code": code})),
)
.await;
assert_eq!(response.status(), 200);
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
let game = test
.platform
.game_store
.load(&game_id)
.await
.unwrap()
.unwrap();
let view = test
.platform
.registry
.require("dummy")
.unwrap()
.view_for(&game, "alice");
assert!(
view["plays"].as_u64().unwrap() >= 1,
"deadline never fired: {view}"
);
}
#[tokio::test]
async fn deadline_finishes_a_match_and_persists_it() {
let test = make_platform(20);
let cookie = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let router = test.router();
let lobby = body_json(
call(
&router,
post_json(
"/api/games",
Some(&cookie),
&json!({"options": {"target": 1, "deadline_in_seconds": 0.05}}),
),
)
.await,
)
.await;
let game_id = lobby["id"].as_str().unwrap().to_string();
let code = lobby["join_code"].as_str().unwrap().to_string();
let bob_cookie = login_cookie(&test.session_store, "default", "bob", "Bob").await;
call(
&router,
post_json("/api/games/join", Some(&bob_cookie), &json!({"code": code})),
)
.await;
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
let game = test
.platform
.game_store
.load(&game_id)
.await
.unwrap()
.unwrap();
let engine = test.platform.registry.require("dummy").unwrap().clone();
assert!(engine.is_finished(&game));
assert!(game.stats_saved, "the deadline finish was not persisted");
assert_eq!(test.platform.game_store.count_ongoing().await.unwrap(), 0);
let matches = test
.platform
.stats
.my_matches("alice", None, 20, None)
.await
.unwrap()
.0;
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].result["target"], 1);
}
#[tokio::test]
async fn finalized_match_updates_history_ratings_and_leaderboard() {
let test = make_platform(50);
let cookie = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let router = test.router();
let lobby = body_json(
call(
&router,
post_json(
"/api/games",
Some(&cookie),
&json!({"options": {"target": 1}}),
),
)
.await,
)
.await;
let game_id = lobby["id"].as_str().unwrap().to_string();
let code = lobby["join_code"].as_str().unwrap().to_string();
let bob_cookie = login_cookie(&test.session_store, "default", "bob", "Bob").await;
call(
&router,
post_json("/api/games/join", Some(&bob_cookie), &json!({"code": code})),
)
.await;
// Bob plays the winning move directly through the platform.
let mut game = test
.platform
.game_store
.load(&game_id)
.await
.unwrap()
.unwrap();
let engine = test.platform.registry.require("dummy").unwrap().clone();
engine
.handle_action(&mut game, "bob", "play", &Map::new())
.unwrap();
test.platform
.scheduler
.finalize_mutation(&mut game)
.await
.unwrap();
let response = call(&router, get("/api/me/matches", Some(&bob_cookie))).await;
assert_eq!(response.status(), 200);
let history = body_json(response).await;
assert_eq!(history["next_cursor"], json!(null));
let entry = &history["results"][0];
assert_eq!(entry["you_won"], true);
assert_eq!(entry["your_elo_delta"], 16);
assert_eq!(entry["players"].as_array().unwrap().len(), 2);
let ratings = body_json(call(&router, get("/api/me/ratings", Some(&bob_cookie))).await).await;
assert_eq!(ratings["results"][0]["rating"], 1516);
assert_eq!(ratings["results"][0]["matches_played"], 1);
let leaderboard = body_json(call(&router, get("/api/leaderboard", None)).await).await;
let results = leaderboard["results"].as_array().unwrap();
assert_eq!(results[0]["user_sub"], "bob");
assert_eq!(results[0]["wins"], 1);
assert_eq!(results[1]["user_sub"], "alice");
assert_eq!(results[1]["elo"], 1484);
}
#[tokio::test]
async fn stale_deadline_is_discarded_without_effect() {
let test = make_platform(50);
let cookie = login_cookie(&test.session_store, "default", "alice", "Alice").await;
let router = test.router();
let lobby = body_json(
call(
&router,
post_json(
"/api/games",
Some(&cookie),
&json!({"options": {"target": 100, "deadline_in_seconds": 0.05}}),
),
)
.await,
)
.await;
let game_id = lobby["id"].as_str().unwrap().to_string();
let code = lobby["join_code"].as_str().unwrap().to_string();
let bob_cookie = login_cookie(&test.session_store, "default", "bob", "Bob").await;
call(
&router,
post_json("/api/games/join", Some(&bob_cookie), &json!({"code": code})),
)
.await;
// Wait for one auto-play, then deliver the same (now stale) entry again.
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
let game = test
.platform
.game_store
.load(&game_id)
.await
.unwrap()
.unwrap();
let engine = test.platform.registry.require("dummy").unwrap().clone();
let plays = engine.view_for(&game, "alice")["plays"].as_u64().unwrap();
let stale = json!({
"game_id": game_id,
"kind": "tick",
"token": format!("tick:{}", plays.saturating_sub(1)),
})
.to_string();
test.platform.scheduler.process_due(&stale).await.unwrap();
let after = test
.platform
.game_store
.load(&game_id)
.await
.unwrap()
.unwrap();
assert_eq!(
engine.view_for(&after, "alice")["plays"].as_u64().unwrap(),
plays
);
}
#[tokio::test]
async fn in_memory_notify_wakes_without_deadlines() {
let notify = Arc::new(Notify::new());
let wait = notify.clone();
let task = tokio::spawn(async move {
wait.notified().await;
true
});
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
notify.notify_waiters();
assert!(
tokio::time::timeout(std::time::Duration::from_secs(1), task)
.await
.unwrap()
.unwrap()
);
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "tavolo-scopone"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Scopone scientifico rules engine and platform adapter"
[dependencies]
chrono.workspace = true
rand.workspace = true
serde.workspace = true
serde_json.workspace = true
tavolo-platform = { path = "../tavolo-platform" }
thiserror.workspace = true
tracing.workspace = true
[dev-dependencies]
tokio = { workspace = true }
+791
View File
@@ -0,0 +1,791 @@
//! Pure rules engine for scopone scientifico.
//!
//! Every function here is deterministic and I/O-free (the only side effect is
//! debug logging): it mutates (or reads) [`ScoponeState`] and returns a
//! [`GameError`] on rule violations. This makes the whole rule set
//! unit-testable without a store or transport. The platform-facing adapter is
//! [`crate::plugin::ScoponeEngine`].
//!
//! Rules implemented
//! -----------------
//! * 40-card Italian deck (4 suits x ranks 1-10), ten cards per player, empty
//! table at the start of every hand.
//! * A card captures either a **single card of equal rank** or a
//! **combination of cards whose ranks sum to its own**. When an equal-ranked
//! card is on the table that capture is mandatory; the player may not take
//! an alternative combination instead.
//! * Emptying the table with a capture is a **scopa** (+1), except on the very
//! last play of a hand.
//! * At the end of a hand the remaining table cards go to the player who made
//! the last capture.
//! * Hand points: `carte` (most captured cards), `denara` (most diamond
//! 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.
//! * Optional `asso_pigliatutto` rule (disabled by default): an ace played
//! when no equal rank is on the table sweeps the whole table — or captures
//! itself, leaving the table empty, when the table is bare. The sweep never
//! scores a scopa, even when cards remain in hand, but it counts as a
//! capture for the last-taker rule. When another ace is on the table the
//! mandatory equal-rank capture still applies.
//! * 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.
use std::collections::HashSet;
use rand::seq::SliceRandom;
use rand::Rng;
use serde_json::{json, Map, Value};
use tavolo_platform::datetime::{now, to_iso};
use tavolo_platform::errors::{GameError, Result};
use crate::errors::card_not_in_hand;
use crate::state::{
parse_card, Card, Move, PlayerState, ScoponeState, PHASE_FINISHED, PHASE_HAND_END, PHASE_LOBBY,
PHASE_PLAYING, SUITS, TEAM_NAMES,
};
/// Number of cards dealt to each player at the start of a hand.
pub const HAND_SIZE: usize = 10;
pub const PLAYERS: usize = 4;
/// Default seconds the hand-end summary waits before dealing anyway. Games
/// carry their own copy in [`ScoponeState::hand_ack_timeout`].
pub const DEFAULT_HAND_ACK_TIMEOUT_SECONDS: i64 = 30;
/// Default seconds a player has to play before the server plays a random
/// legal card for them. Games carry their own copy in
/// [`ScoponeState::turn_timeout`].
pub const DEFAULT_TURN_TIMEOUT_SECONDS: i64 = 30;
/// Primiera card values: sevens are best, then sixes, then aces, then the
/// remaining ranks in descending order. All of 8/9/10 are worth 10.
fn primiera_value(rank: u32) -> i64 {
match rank {
7 => 21,
6 => 18,
1 => 16,
5 => 15,
4 => 14,
3 => 13,
2 => 12,
8..=10 => 10,
_ => 0,
}
}
/// Return the 40 cards of the Italian deck in canonical order.
pub fn full_deck() -> Vec<Card> {
let mut deck = Vec::with_capacity(40);
for &suit in SUITS.iter() {
for rank in 1..=10u32 {
deck.push(Card { rank, suit });
}
}
deck
}
/// Return a shuffled deck. Pass `rng` for deterministic tests.
pub fn shuffled_deck<R: Rng + ?Sized>(rng: &mut R) -> Vec<Card> {
let mut deck = full_deck();
deck.shuffle(rng);
deck
}
/// Every `size`-element subset of `items`, in the same lexicographic order
/// `itertools.combinations` produces.
fn combinations(items: &[Card], size: usize) -> Vec<Vec<Card>> {
let mut result = Vec::new();
if size == 0 {
return vec![Vec::new()];
}
if size > items.len() {
return result;
}
let mut indices: Vec<usize> = (0..size).collect();
loop {
result.push(indices.iter().map(|&i| items[i].clone()).collect());
// Advance to the next combination; stop once the last one is reached.
let mut i = size;
loop {
if i == 0 {
return result;
}
i -= 1;
if indices[i] != i + items.len() - size {
break;
}
}
indices[i] += 1;
for j in (i + 1)..size {
indices[j] = indices[j - 1] + 1;
}
}
}
/// Return every legal capture (a list of card sets) for `card`.
///
/// If an equal-ranked card is on the table, only those single-card captures
/// are returned (the rule forbids taking a combination instead). Otherwise,
/// when `asso_pigliatutto` is enabled and `card` is an ace, the only capture
/// is the whole table — or an empty capture when the table is bare, the ace
/// taking itself. Without the option every subset of the table whose ranks sum
/// to `card.rank` is returned.
pub fn legal_captures(table: &[Card], card: Card, asso_pigliatutto: bool) -> Vec<Vec<Card>> {
let equal: Vec<Card> = table
.iter()
.filter(|c| c.rank == card.rank)
.cloned()
.collect();
if !equal.is_empty() {
return equal.into_iter().map(|c| vec![c]).collect();
}
if asso_pigliatutto && card.rank == 1 {
return vec![table.to_vec()];
}
let candidates: Vec<Card> = table
.iter()
.filter(|c| c.rank <= card.rank)
.cloned()
.collect();
let mut captures: Vec<Vec<Card>> = Vec::new();
// A sum-equal capture needs at least two cards (single non-equal cards
// cannot sum to the played card).
for size in 2..=candidates.len() {
for combo in combinations(&candidates, size) {
if combo.iter().map(|c| c.rank).sum::<u32>() == card.rank {
captures.push(combo);
}
}
}
captures
}
/// Create a lobby game with the creator seated first.
pub fn create_game(
creator_sub: &str,
creator_name: &str,
target_score: i64,
hand_ack_timeout: i64,
turn_timeout: i64,
napola: bool,
asso_pigliatutto: bool,
) -> Result<ScoponeState> {
if !(1..=100).contains(&target_score) {
return Err(GameError::illegal_move("target_score must be between 1 and 100").into());
}
Ok(ScoponeState {
target_score,
napola,
asso_pigliatutto,
phase: PHASE_LOBBY.to_string(),
players: vec![PlayerState {
sub: creator_sub.to_string(),
name: creator_name.to_string(),
seat: 0,
hand: Vec::new(),
captured: Vec::new(),
scope: 0,
}],
hand_ack_timeout,
turn_timeout,
..Default::default()
})
}
/// Seat `sub` in the next free chair, starting the match when full.
pub fn join_game<R: Rng + ?Sized>(
state: &mut ScoponeState,
sub: &str,
name: &str,
rng: &mut R,
) -> Result<()> {
if state.phase != PHASE_LOBBY {
return Err(GameError::game_not_started("game has already started").into());
}
if state.seated(sub) {
return Err(GameError::already_joined("already joined this game").into());
}
if state.players.len() >= PLAYERS {
return Err(GameError::lobby_full("game is full").into());
}
let seat = state.players.len() as i64;
state.players.push(PlayerState {
sub: sub.to_string(),
name: name.to_string(),
seat,
hand: Vec::new(),
captured: Vec::new(),
scope: 0,
});
if state.players.len() == PLAYERS {
start_game(state, rng)?;
}
Ok(())
}
/// Deal the first hand and switch the game to playing.
pub fn start_game<R: Rng + ?Sized>(state: &mut ScoponeState, rng: &mut R) -> Result<()> {
if state.players.len() != PLAYERS {
return Err(GameError::game_not_started("need exactly four players to start").into());
}
state.phase = PHASE_PLAYING.to_string();
deal_hand(state, rng);
Ok(())
}
/// Arm the auto-play deadline for whoever is on turn.
fn set_turn_deadline(state: &mut ScoponeState) {
let deadline = now() + chrono::Duration::seconds(state.turn_timeout);
state.turn_deadline = Some(to_iso(deadline));
}
fn deal_hand<R: Rng + ?Sized>(state: &mut ScoponeState, rng: &mut R) {
let mut deck = shuffled_deck(rng);
for player in state.players.iter_mut() {
player.hand = Vec::new();
player.captured = Vec::new();
player.scope = 0;
}
state.table = Vec::new();
state.last_taker = None;
// Dealer rotates each hand; the first card is played by the player to the
// dealer's left.
state.turn = (state.dealer + 1) % PLAYERS as i64;
set_turn_deadline(state);
for _offset in 0..HAND_SIZE {
for seat_offset in 0..PLAYERS {
let seat = (state.dealer + 1 + seat_offset as i64) % PLAYERS as i64;
let index = player_index_at(state, seat).expect("seated player");
state.players[index]
.hand
.push(deck.pop().expect("deck has enough cards"));
}
}
}
/// The index in `state.players` of the player in `seat`.
fn player_index_at(state: &ScoponeState, seat: i64) -> Result<usize> {
state
.players
.iter()
.position(|player| player.seat == seat)
.ok_or_else(|| GameError::illegal_move(format!("no player in seat {seat}")).into())
}
/// Return the option matching `requested` exactly, if any.
///
/// An empty request matches only an empty option — the asso pigliatutto
/// self-capture — so requesting nothing never satisfies a real capture.
fn match_option(options: &[Vec<Card>], requested: &[Card]) -> Option<Vec<Card>> {
let mut wanted: Vec<String> = requested.iter().map(Card::code).collect();
wanted.sort();
for option in options {
let mut have: Vec<String> = option.iter().map(Card::code).collect();
have.sort();
if have == wanted {
return Some(option.clone());
}
}
None
}
/// Apply one move by the player identified by `sub`.
///
/// `capture_codes` selects which table cards to capture; it must be a legal
/// capture (see [`legal_captures`]) when one exists and empty otherwise.
/// Returns a [`GameError`] on any violation.
pub fn play(
state: &mut ScoponeState,
sub: &str,
card_code: &str,
capture_codes: Option<&[String]>,
) -> Result<()> {
if state.phase == PHASE_FINISHED {
return Err(GameError::game_finished("the match is over").into());
}
if state.phase == PHASE_HAND_END {
return Err(GameError::illegal_move(
"the hand is over; acknowledge the summary to continue",
)
.into());
}
if state.phase != PHASE_PLAYING {
return Err(GameError::game_not_started("the game has not started yet").into());
}
let index = state
.players
.iter()
.position(|player| player.sub == sub)
.filter(|&i| state.players[i].seat == state.turn);
let index = match index {
Some(index) => index,
None => return Err(GameError::not_your_turn("it is not your turn").into()),
};
let card = parse_card(card_code)?;
let hand_position = state.players[index].hand.iter().position(|c| c == &card);
let hand_position = match hand_position {
Some(position) => position,
None => return Err(card_not_in_hand(&card.code()).into()),
};
// Remove the card now so the scopa check below can tell whether this was
// the last play of the hand.
state.players[index].hand.remove(hand_position);
let mut requested: Vec<Card> = Vec::new();
if let Some(codes) = capture_codes {
for code in codes {
requested.push(parse_card(code)?);
}
}
let table_before = state.table.clone();
let options = legal_captures(&table_before, card.clone(), state.asso_pigliatutto);
// An asso pigliatutto capture: the ace takes the whole table because no
// equal rank was on it, or takes itself when the table is bare. Such a
// capture never scores a scopa.
let pigliatutto =
state.asso_pigliatutto && card.rank == 1 && table_before.iter().all(|c| c.rank != 1);
let mut taken: Vec<Card> = Vec::new();
let mut scopa = false;
if options.is_empty() {
if !requested.is_empty() {
return Err(GameError::illegal_move("no capture is possible with that card").into());
}
state.table.push(card.clone());
} else {
let chosen = match match_option(&options, &requested) {
Some(chosen) => chosen,
None => {
return Err(GameError::illegal_move("the requested capture is not legal").into())
}
};
for captured in &chosen {
if let Some(position) = state.table.iter().position(|c| c == captured) {
state.table.remove(position);
}
state.players[index].captured.push(captured.clone());
}
state.players[index].captured.push(card.clone());
state.last_taker = Some(state.players[index].seat);
// A scopa scores only if cards remain to be played this hand.
let hands_empty = state.players.iter().all(|player| player.hand.is_empty());
if state.table.is_empty() && !hands_empty && !pigliatutto {
state.players[index].scope += 1;
scopa = true;
}
taken = chosen;
}
state.last_move = Some(Move {
seat: state.players[index].seat,
name: state.players[index].name.clone(),
card: card.code(),
captured: taken.iter().map(Card::code).collect(),
scopa,
pigliatutto,
});
if state.players.iter().all(|player| player.hand.is_empty()) {
end_hand(state)?;
} else {
state.turn = (state.turn + 1) % PLAYERS as i64;
set_turn_deadline(state);
}
Ok(())
}
/// 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, one
/// of the legal captures is chosen at random (the rules require a capture when
/// one exists). Delegates to [`play`], so the move is fully validated and can
/// end the hand or the match. Pass `rng` for deterministic tests.
pub fn auto_play<R: Rng + ?Sized>(state: &mut ScoponeState, rng: &mut R) -> Result<()> {
if state.phase != PHASE_PLAYING {
return Err(GameError::game_not_started("the game has not started yet").into());
}
let index = player_index_at(state, state.turn)?;
if state.players[index].hand.is_empty() {
return Err(GameError::illegal_move("the player on turn has no cards").into());
}
let card = state.players[index]
.hand
.choose(rng)
.cloned()
.expect("hand is not empty");
let options = legal_captures(&state.table, card.clone(), state.asso_pigliatutto);
let capture: Option<Vec<Card>> = if options.is_empty() {
None
} else {
options.choose(rng).cloned()
};
let sub = state.players[index].sub.clone();
let capture_codes: Option<Vec<String>> = capture
.as_ref()
.map(|cards| cards.iter().map(Card::code).collect());
play(state, &sub, &card.code(), capture_codes.as_deref())
}
/// 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
/// [`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.
fn end_hand(state: &mut ScoponeState) -> Result<()> {
state.turn_deadline = None;
if !state.table.is_empty() {
if let Some(taker) = state.last_taker {
let index = player_index_at(state, taker)?;
let swept: Vec<Card> = std::mem::take(&mut state.table);
state.players[index].captured.extend(swept);
}
}
let (points, mut details) = hand_points(state);
for (score, awarded) in state.scores.iter_mut().zip(points.iter()) {
*score += awarded;
}
details.insert("hand".to_string(), json!(state.hand_number));
details.insert("team_a_points".to_string(), json!(points[0]));
details.insert("team_b_points".to_string(), json!(points[1]));
state.hand_scores.push(Value::Object(details.clone()));
// A full napola (the whole denari suit) wins the match outright,
// regardless of the score.
if let Some(Value::Object(napola)) = details.get("napola") {
for (team, name) in TEAM_NAMES.iter().enumerate() {
if napola.get(*name).and_then(Value::as_i64) == Some(10) {
state.phase = PHASE_FINISHED.to_string();
state.winner = Some(team as i64);
return Ok(());
}
}
}
let a = state.scores[0];
let b = state.scores[1];
let reached = a.max(b) >= state.target_score;
if reached && a != b {
state.phase = PHASE_FINISHED.to_string();
state.winner = Some(if a > b { 0 } else { 1 });
return Ok(());
}
// Pause for the scoring summary instead of dealing immediately.
state.phase = PHASE_HAND_END.to_string();
state.acked = Vec::new();
let deadline = now() + chrono::Duration::seconds(state.hand_ack_timeout);
state.hand_end_deadline = Some(to_iso(deadline));
Ok(())
}
/// Record that `sub` has read the hand-end summary.
///
/// When all four players have acknowledged, the next hand is dealt.
/// Acknowledging twice is a no-op; acknowledging outside the `hand_end` phase
/// returns an error.
pub fn acknowledge_hand<R: Rng + ?Sized>(
state: &mut ScoponeState,
sub: &str,
rng: &mut R,
) -> Result<()> {
if state.phase != PHASE_HAND_END {
return Err(
GameError::illegal_move("no hand summary is waiting for acknowledgement").into(),
);
}
let seat = match state.players.iter().find(|player| player.sub == sub) {
Some(player) => player.seat,
None => return Err(GameError::not_your_turn("you are not seated in this game").into()),
};
if state.acked.contains(&seat) {
return Ok(());
}
state.acked.push(seat);
if state.acked.len() < PLAYERS {
return Ok(());
}
state.hand_number += 1;
state.dealer = (state.dealer + 1) % PLAYERS as i64;
state.acked = Vec::new();
state.hand_end_deadline = None;
state.last_move = None;
state.phase = PHASE_PLAYING.to_string();
deal_hand(state, rng);
Ok(())
}
/// Return the primiera value of a capture pile (0 if a suit is absent).
pub fn primiera_score(captured: &[Card]) -> i64 {
let mut best: [Option<i64>; 4] = [None; 4];
for card in captured {
let value = primiera_value(card.rank);
if let Some(index) = SUITS.iter().position(|&suit| suit == card.suit) {
if best[index].is_none_or(|current| value > current) {
best[index] = Some(value);
}
}
}
if best.iter().any(Option::is_none) {
return 0;
}
best.iter().map(|value| value.unwrap_or(0)).sum()
}
/// 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.
pub fn napola_score(captured: &[Card]) -> i64 {
let ranks: HashSet<u32> = captured
.iter()
.filter(|card| card.suit == 'D')
.map(|card| card.rank)
.collect();
let mut run = 0u32;
while ranks.contains(&(run + 1)) {
run += 1;
}
if run >= 3 {
run as i64
} else {
0
}
}
/// Compute the hand points for both teams (index 0 = team A).
pub fn hand_points(state: &ScoponeState) -> (Vec<i64>, Map<String, Value>) {
let mut piles: [Vec<Card>; 2] = [Vec::new(), Vec::new()];
let mut scope: [i64; 2] = [0, 0];
for player in &state.players {
let team = player.team() as usize;
piles[team].extend(player.captured.iter().cloned());
scope[team] += player.scope;
}
let mut points = [0i64, 0i64];
let mut award: Map<String, Value> = Map::new();
// Carte: most captured cards. Ties award nothing.
let cards = [piles[0].len() as i64, piles[1].len() as i64];
if cards[0] != cards[1] {
let winner = if cards[0] > cards[1] { 0 } else { 1 };
points[winner] += 1;
award.insert(
"carte".to_string(),
Value::String(TEAM_NAMES[winner].to_string()),
);
} else {
award.insert("carte".to_string(), Value::Null);
}
// Denara: most diamond cards. Ties award nothing.
let coins = [
piles[0].iter().filter(|c| c.suit == 'D').count() as i64,
piles[1].iter().filter(|c| c.suit == 'D').count() as i64,
];
if coins[0] != coins[1] {
let winner = if coins[0] > coins[1] { 0 } else { 1 };
points[winner] += 1;
award.insert(
"denara".to_string(),
Value::String(TEAM_NAMES[winner].to_string()),
);
} else {
award.insert("denara".to_string(), Value::Null);
}
// Settebello: the 7 of diamonds always belongs to someone.
let settebello = [
piles[0].iter().any(|c| c.rank == 7 && c.suit == 'D'),
piles[1].iter().any(|c| c.rank == 7 && c.suit == 'D'),
];
if settebello[0] != settebello[1] {
let winner = if settebello[0] { 0 } else { 1 };
points[winner] += 1;
award.insert(
"settebello".to_string(),
Value::String(TEAM_NAMES[winner].to_string()),
);
}
// Primiera: highest value, only if the team holds all four suits.
let primiera = [primiera_score(&piles[0]), primiera_score(&piles[1])];
if primiera[0] != primiera[1] {
let winner = if primiera[0] > primiera[1] { 0 } else { 1 };
points[winner] += 1;
award.insert(
"primiera".to_string(),
Value::String(TEAM_NAMES[winner].to_string()),
);
} else {
award.insert("primiera".to_string(), Value::Null);
}
// Scope: one point each.
points[0] += scope[0];
points[1] += scope[1];
// Napola (optional rule): consecutive denari from the ace. A run of 10
// means the team swept the whole suit and wins the match instantly.
let mut napola_details: Option<Value> = None;
if state.napola {
let napola = [napola_score(&piles[0]), napola_score(&piles[1])];
for team in 0..2 {
points[team] += napola[team];
}
let name = (0..2usize)
.find(|&team| napola[team] > 0)
.map(|team| Value::String(TEAM_NAMES[team].to_string()))
.unwrap_or(Value::Null);
award.insert("napola".to_string(), name);
napola_details = Some(json!({"A": napola[0], "B": napola[1]}));
}
let mut details: Map<String, Value> = Map::new();
details.insert("cards".to_string(), json!({"A": cards[0], "B": cards[1]}));
details.insert("denara".to_string(), json!({"A": coins[0], "B": coins[1]}));
details.insert(
"settebello".to_string(),
json!({"A": settebello[0], "B": settebello[1]}),
);
details.insert(
"primiera".to_string(),
json!({"A": primiera[0], "B": primiera[1]}),
);
details.insert("scope".to_string(), json!({"A": scope[0], "B": scope[1]}));
details.insert("award".to_string(), Value::Object(award));
if let Some(napola) = napola_details {
details.insert("napola".to_string(), napola);
}
(points.to_vec(), details)
}
fn optional_string(value: &Option<String>) -> Value {
value
.as_ref()
.map(|text| Value::String(text.clone()))
.unwrap_or(Value::Null)
}
/// Serialize `state` hiding other players' hands.
///
/// Hands are reduced to a count, except for the requesting player's own hand.
/// Non-seated viewers simply see no hand at all.
pub fn state_for_player(state: &ScoponeState, sub: &str) -> Value {
let viewer = state.player_for(sub);
let viewer_seat = viewer.map(|player| player.seat);
let mut players: Vec<Value> = Vec::new();
for player in &state.players {
let mut view: Map<String, Value> = Map::new();
view.insert("sub".to_string(), json!(player.sub));
view.insert("name".to_string(), json!(player.name));
view.insert("seat".to_string(), json!(player.seat));
view.insert(
"team".to_string(),
json!(TEAM_NAMES[player.team() as usize]),
);
view.insert("cards_left".to_string(), json!(player.hand.len()));
view.insert("captured_count".to_string(), json!(player.captured.len()));
view.insert("scope".to_string(), json!(player.scope));
if viewer_seat == Some(player.seat) {
view.insert(
"hand".to_string(),
Value::Array(player.hand.iter().map(Card::to_json).collect()),
);
}
players.push(Value::Object(view));
}
let mut payload: Map<String, Value> = Map::new();
payload.insert("phase".to_string(), json!(state.phase));
payload.insert("target_score".to_string(), json!(state.target_score));
payload.insert("napola".to_string(), json!(state.napola));
payload.insert(
"asso_pigliatutto".to_string(),
json!(state.asso_pigliatutto),
);
payload.insert("hand_number".to_string(), json!(state.hand_number));
payload.insert("dealer".to_string(), json!(state.dealer));
payload.insert("turn".to_string(), json!(state.turn));
payload.insert(
"scores".to_string(),
json!({"A": state.scores[0], "B": state.scores[1]}),
);
payload.insert(
"winner".to_string(),
match state.winner {
Some(winner) => json!(TEAM_NAMES[winner as usize]),
None => Value::Null,
},
);
payload.insert(
"table".to_string(),
Value::Array(state.table.iter().map(Card::to_json).collect()),
);
payload.insert("players".to_string(), Value::Array(players));
payload.insert(
"last_hand".to_string(),
state.hand_scores.last().cloned().unwrap_or(Value::Null),
);
payload.insert(
"last_move".to_string(),
state
.last_move
.as_ref()
.map(Move::to_json)
.unwrap_or(Value::Null),
);
payload.insert("acknowledged".to_string(), json!(state.acked));
payload.insert(
"hand_end_deadline".to_string(),
optional_string(&state.hand_end_deadline),
);
payload.insert(
"turn_deadline".to_string(),
optional_string(&state.turn_deadline),
);
if let Some(seat) = viewer_seat {
if state.phase == PHASE_PLAYING && seat == state.turn {
payload.insert("your_turn".to_string(), Value::Bool(true));
// Only the player on turn receives their legal captures, so all
// the rule logic stays server-side.
let mut legal_moves: Map<String, Value> = Map::new();
if let Some(viewer) = viewer {
for hand_card in &viewer.hand {
let options =
legal_captures(&state.table, hand_card.clone(), state.asso_pigliatutto);
if !options.is_empty() {
let list: Vec<Value> = options
.iter()
.map(|option| {
Value::Array(option.iter().map(|c| json!(c.code())).collect())
})
.collect();
legal_moves.insert(hand_card.code(), Value::Array(list));
}
}
}
payload.insert("legal_moves".to_string(), Value::Object(legal_moves));
}
}
Value::Object(payload)
}
@@ -0,0 +1,15 @@
//! Scopone scientifico errors.
//!
//! Most failure modes are game-independent and live in
//! [`tavolo_platform::errors`]; only genuinely scopone-specific errors are
//! defined here. The Rust platform folds every game error into a single
//! [`GameError`] value carrying an `"illegal_move"` code, so a Python-style
//! exception hierarchy is unnecessary; this module keeps named constructors so
//! call sites (and tests) stay readable.
use tavolo_platform::errors::GameError;
/// The played card is not held by the player.
pub fn card_not_in_hand(code: &str) -> GameError {
GameError::illegal_move(format!("card {code} is not in your hand"))
}
+19
View File
@@ -0,0 +1,19 @@
//! Scopone scientifico rules engine and platform adapter.
//!
//! [`engine`] holds the pure, deterministic rules; [`state`] the JSON-native
//! game representation; [`plugin`] the [`tavolo_platform::engine::GameEngine`]
//! implementation that plugs the game into the platform.
pub mod engine;
pub mod errors;
pub mod plugin;
pub mod state;
pub use engine::{
DEFAULT_HAND_ACK_TIMEOUT_SECONDS, DEFAULT_TURN_TIMEOUT_SECONDS, HAND_SIZE, PLAYERS,
};
pub use plugin::ScoponeEngine;
pub use state::{
Card, Move, PlayerState, ScoponeState, DEFAULT_TARGET_SCORE, PHASE_FINISHED, PHASE_HAND_END,
PHASE_LOBBY, PHASE_PLAYING, TEAM_NAMES,
};
+428
View File
@@ -0,0 +1,428 @@
//! The platform-facing adapter for scopone scientifico.
//!
//! [`ScoponeEngine`] implements [`GameEngine`] on top of the pure rules in
//! [`crate::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.
use std::any::Any;
use rand::thread_rng;
use serde_json::{json, Map, Value};
use tavolo_platform::engine::{Deadline, GameEngine, GameSession, MatchResult, PlayerResult, Seat};
use tavolo_platform::errors::{GameError, Result};
use crate::engine::{self, DEFAULT_HAND_ACK_TIMEOUT_SECONDS, DEFAULT_TURN_TIMEOUT_SECONDS};
use crate::state::{
ScoponeState, DEFAULT_TARGET_SCORE, PHASE_FINISHED, PHASE_HAND_END, PHASE_LOBBY, PHASE_PLAYING,
TEAM_NAMES,
};
/// Downcast a session's opaque state to [`ScoponeState`].
fn state_ref(session: &GameSession) -> Result<&ScoponeState> {
session
.state
.as_ref()
.and_then(|state| state.downcast_ref::<ScoponeState>())
.ok_or_else(|| GameError::illegal_move("missing scopone state").into())
}
/// Downcast a session's opaque state to [`ScoponeState`], mutably.
fn state_mut(session: &mut GameSession) -> Result<&mut ScoponeState> {
session
.state
.as_mut()
.and_then(|state| state.downcast_mut::<ScoponeState>())
.ok_or_else(|| GameError::illegal_move("missing scopone state").into())
}
/// Epoch milliseconds for an ISO-8601 deadline, `None` when absent or
/// unparseable.
fn deadline_ms(iso: Option<&str>) -> Option<i64> {
iso.and_then(tavolo_platform::datetime::from_iso)
.map(|dt| dt.timestamp_millis())
}
/// Scopone scientifico as a platform game engine.
pub struct ScoponeEngine {
turn_timeout_seconds: i64,
hand_ack_timeout_seconds: i64,
}
impl ScoponeEngine {
pub fn new(turn_timeout_seconds: i64, hand_ack_timeout_seconds: i64) -> Self {
Self {
turn_timeout_seconds,
hand_ack_timeout_seconds,
}
}
}
impl Default for ScoponeEngine {
fn default() -> Self {
Self::new(
DEFAULT_TURN_TIMEOUT_SECONDS,
DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
)
}
}
impl GameEngine for ScoponeEngine {
fn id(&self) -> &'static str {
"scopone_scientifico"
}
fn name(&self) -> &'static str {
"Scopone scientifico"
}
fn description(&self) -> &'static str {
"Four players in fixed partnerships, ten cards each and an empty table. \
First team to the target score wins."
}
fn min_players(&self) -> usize {
4
}
fn max_players(&self) -> usize {
4
}
fn options_schema(&self) -> Value {
json!({
"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",
},
"asso_pigliatutto": {
"type": "boolean",
"default": false,
"description": "Score the asso pigliatutto rule; an ace sweeps the whole table (no scopa)",
},
},
})
}
// -- lobby ------------------------------------------------------------
fn create(&self, session: &mut GameSession, options: &Map<String, Value>) -> Result<()> {
let target_score = match options.get("target_score") {
None => DEFAULT_TARGET_SCORE,
Some(Value::Bool(_)) => {
return Err(GameError::illegal_move("target_score must be an integer").into())
}
Some(value) => value
.as_i64()
.ok_or_else(|| GameError::illegal_move("target_score must be an integer"))?,
};
let napola = match options.get("napola") {
None => true,
Some(Value::Bool(value)) => *value,
Some(_) => return Err(GameError::illegal_move("napola must be a boolean").into()),
};
let asso_pigliatutto = match options.get("asso_pigliatutto") {
None => false,
Some(Value::Bool(value)) => *value,
Some(_) => {
return Err(GameError::illegal_move("asso_pigliatutto must be a boolean").into())
}
};
let creator = session
.players
.first()
.ok_or_else(|| GameError::illegal_move("session has no creator seat"))?;
let creator_sub = creator.user_sub.clone();
let creator_name = creator.display_name.clone();
let state = engine::create_game(
&creator_sub,
&creator_name,
target_score,
self.hand_ack_timeout_seconds,
self.turn_timeout_seconds,
napola,
asso_pigliatutto,
)?;
session.state = Some(Box::new(state));
// The creator takes seat 0, i.e. team A.
if let Some(seat) = session.players.get_mut(0) {
seat.team = Some(TEAM_NAMES[0].to_string());
}
Ok(())
}
fn join(&self, session: &mut GameSession, user_sub: &str, display_name: &str) -> Result<()> {
let seat = session.players.len();
{
let state = state_mut(session)?;
engine::join_game(state, user_sub, display_name, &mut thread_rng())?;
}
// join_game raises before appending on any violation, so the seat list
// stays in sync with the engine's players.
session.players.push(Seat {
user_sub: user_sub.to_string(),
display_name: display_name.to_string(),
team: Some(TEAM_NAMES[seat % 2].to_string()),
});
Ok(())
}
fn in_lobby(&self, session: &GameSession) -> bool {
state_ref(session)
.map(|state| state.phase == PHASE_LOBBY)
.unwrap_or(false)
}
fn lobby_view(&self, session: &GameSession) -> Value {
match state_ref(session) {
Ok(state) => json!({
"phase": state.phase,
"target_score": state.target_score,
"napola": state.napola,
"asso_pigliatutto": state.asso_pigliatutto,
}),
Err(_) => json!({}),
}
}
// -- play -------------------------------------------------------------
fn handle_action(
&self,
session: &mut GameSession,
user_sub: &str,
action: &str,
payload: &Map<String, Value>,
) -> Result<()> {
match action {
"play" => {
let card = payload
.get("card")
.and_then(Value::as_str)
.ok_or_else(|| GameError::illegal_move("'card' must be a card code string"))?;
let capture: Option<Vec<String>> = match payload.get("capture") {
None | Some(Value::Null) => None,
Some(Value::Array(items)) => {
let mut codes = Vec::with_capacity(items.len());
for item in items {
match item.as_str() {
Some(code) => codes.push(code.to_string()),
None => {
return Err(GameError::illegal_move(
"'capture' must be a list of card codes",
)
.into())
}
}
}
Some(codes)
}
Some(_) => {
return Err(GameError::illegal_move(
"'capture' must be a list of card codes",
)
.into())
}
};
let state = state_mut(session)?;
engine::play(state, user_sub, card, capture.as_deref())
}
"ack" => {
let state = state_mut(session)?;
engine::acknowledge_hand(state, user_sub, &mut thread_rng())
}
_ => Err(GameError::illegal_move(format!(
"unknown action: {}",
tavolo_platform::http::py_repr(&serde_json::Value::String(action.to_string()))
))
.into()),
}
}
fn view_for(&self, session: &GameSession, user_sub: &str) -> Value {
match state_ref(session) {
Ok(state) => engine::state_for_player(state, user_sub),
Err(_) => Value::Null,
}
}
fn is_finished(&self, session: &GameSession) -> bool {
state_ref(session)
.map(|state| state.phase == PHASE_FINISHED)
.unwrap_or(false)
}
fn game_over_view(&self, session: &GameSession, _user_sub: &str) -> Value {
match state_ref(session) {
Ok(state) => json!({
"scores": {"A": state.scores[0], "B": state.scores[1]},
"winner": state.winner.map(|winner| TEAM_NAMES[winner as usize]),
}),
Err(_) => json!({}),
}
}
// -- (de)serialization -------------------------------------------------
fn state_to_json(&self, state: &(dyn Any + Send + Sync)) -> Result<Value> {
state
.downcast_ref::<ScoponeState>()
.map(ScoponeState::to_json)
.ok_or_else(|| GameError::illegal_move("state is not a ScoponeState").into())
}
fn state_from_json(&self, data: &Value) -> Result<Box<dyn Any + Send + Sync>> {
Ok(Box::new(ScoponeState::from_json(data)?))
}
// -- deadlines ----------------------------------------------------------
fn next_deadline(&self, session: &GameSession) -> Result<Option<Deadline>> {
let state = state_ref(session)?;
if state.phase == PHASE_PLAYING {
if let Some(iso) = state.turn_deadline.as_deref() {
let due_ms = match deadline_ms(Some(iso)) {
Some(due_ms) => due_ms,
None => return Ok(None),
};
return Ok(Some(Deadline {
kind: "turn".to_string(),
due_at: timestamp_millis(due_ms),
token: format!("turn:{}:{}:{}", state.hand_number, state.turn, due_ms),
}));
}
}
if state.phase == PHASE_HAND_END {
if let Some(iso) = state.hand_end_deadline.as_deref() {
let due_ms = match deadline_ms(Some(iso)) {
Some(due_ms) => due_ms,
None => return Ok(None),
};
return Ok(Some(Deadline {
kind: "hand_end".to_string(),
due_at: timestamp_millis(due_ms),
token: format!("hand_end:{}:{}", state.hand_number, due_ms),
}));
}
}
Ok(None)
}
fn fire_deadline(&self, session: &mut GameSession, kind: &str, token: &str) -> Result<()> {
let current = self.next_deadline(session)?;
let stale = match &current {
Some(deadline) => deadline.kind != kind || deadline.token != token,
None => true,
};
if stale {
// Overtaken by events (a play landed in time, the hand was
// acknowledged, the deadline moved): nothing to do.
return Err(GameError::illegal_move("stale deadline").into());
}
match kind {
"turn" => {
let state = state_mut(session)?;
engine::auto_play(state, &mut thread_rng())?;
let actor = state.last_move.as_ref().map(|move_| move_.seat);
tracing::info!(
actor,
hand = state.hand_number,
"auto-played for the turn timeout"
);
Ok(())
}
"hand_end" => {
let state = state_mut(session)?;
let subs: Vec<String> = state
.players
.iter()
.map(|player| player.sub.clone())
.collect();
let mut rng = thread_rng();
for sub in subs {
engine::acknowledge_hand(state, &sub, &mut rng)?;
}
tracing::info!(
hand = state.hand_number,
"hand auto-advanced after the acknowledgement timeout"
);
Ok(())
}
// next_deadline never emits other kinds.
_ => Err(GameError::illegal_move(format!("unknown deadline kind: {kind:?}")).into()),
}
}
// -- results -------------------------------------------------------------
fn result(&self, session: &GameSession) -> Result<MatchResult> {
let state = state_ref(session)?;
let winner = state
.winner
.ok_or_else(|| GameError::illegal_move("no result: the match is not finished"))?
as usize;
let teams = vec![
state
.players
.iter()
.filter(|player| player.team() == 0)
.map(|player| player.sub.clone())
.collect::<Vec<_>>(),
state
.players
.iter()
.filter(|player| player.team() == 1)
.map(|player| player.sub.clone())
.collect::<Vec<_>>(),
];
let players = state
.players
.iter()
.map(|player| {
let team = player.team() as usize;
PlayerResult {
user_sub: player.sub.clone(),
seat: player.seat,
won: team == winner,
team: Some(TEAM_NAMES[team].to_string()),
score: state.scores[team] as f64,
details: json!({"scope": player.scope}),
}
})
.collect();
let summary = json!({
"team_a_score": state.scores[0],
"team_b_score": state.scores[1],
"winner_team": TEAM_NAMES[winner],
"target_score": state.target_score,
"hands_played": state.hand_number,
"hand_scores": state.hand_scores,
});
Ok(MatchResult::new(teams, winner, players, summary))
}
}
/// Convert epoch milliseconds back to a UTC timestamp, falling back to now
/// when the value is out of range (it never is for parsed deadlines).
fn timestamp_millis(millis: i64) -> chrono::DateTime<chrono::Utc> {
chrono::DateTime::<chrono::Utc>::from_timestamp_millis(millis)
.unwrap_or_else(tavolo_platform::datetime::now)
}
+388
View File
@@ -0,0 +1,388 @@
//! In-memory representation of a scopone scientifico game.
//!
//! The whole mutable game lives in [`ScoponeState`], which is serialized to
//! and from plain JSON for storage inside the platform's session envelope.
//! Keeping the representation JSON-native means the store needs no custom
//! codecs and the state is inspectable.
//!
//! The state contains only game data: the platform owns the session envelope
//! (id, join code, seats, timestamps, stats persistence).
//!
//! Deck convention: a 40-card Italian deck. Suits are `D` (denari), `C`
//! (coppe), `S` (spade) and `B` (bastoni); ranks are `1`..`10`. A card is
//! rendered as `RRSUIT` (e.g. `07D` is the settebello).
use serde_json::{json, Value};
use tavolo_platform::errors::{GameError, Result};
/// The four suits, in canonical order.
pub const SUITS: [char; 4] = ['D', 'C', 'S', 'B'];
/// Teams are derived from the seat: seats 0 and 2 form team A (index 0),
/// seats 1 and 3 form team B (index 1). Team pairs always sit opposite each
/// other, as in real scopone scientifico.
pub const TEAM_NAMES: [&str; 2] = ["A", "B"];
pub const PHASE_LOBBY: &str = "lobby";
pub const PHASE_PLAYING: &str = "playing";
/// Between hands of an unfinished match: scoring summary shown to every
/// player; the next hand is dealt once all four acknowledge (or the hand-end
/// timeout elapses).
pub const PHASE_HAND_END: &str = "hand_end";
pub const PHASE_FINISHED: &str = "finished";
pub const DEFAULT_TARGET_SCORE: i64 = 11;
/// The team index for a seat (0 or 1).
pub fn team_of(seat: i64) -> i64 {
seat.rem_euclid(2)
}
/// A single playing card.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Card {
pub rank: u32,
pub suit: char,
}
impl Card {
/// Build a validated card, or `None` when the suit/rank is out of range.
pub fn new(rank: u32, suit: char) -> Option<Card> {
if SUITS.contains(&suit) && (1..=10).contains(&rank) {
Some(Card { rank, suit })
} else {
None
}
}
/// The `RRSUIT` code (e.g. `07D`).
pub fn code(&self) -> String {
format!("{:02}{}", self.rank, self.suit)
}
/// Parse a card code, uppercasing the input like Python's `Card.parse`.
pub fn parse(code: &str) -> Option<Card> {
let upper = code.to_ascii_uppercase();
let chars: Vec<char> = upper.chars().collect();
if chars.len() != 3 || !chars[0].is_ascii_digit() || !chars[1].is_ascii_digit() {
return None;
}
let rank: u32 = format!("{}{}", chars[0], chars[1]).parse().ok()?;
Card::new(rank, chars[2])
}
pub fn to_json(&self) -> Value {
Value::String(self.code())
}
pub fn from_json(value: &Value) -> Result<Card> {
value
.as_str()
.and_then(Card::parse)
.ok_or_else(|| GameError::illegal_move("invalid card code").into())
}
}
/// Parse a card code, raising an `"invalid card code"` error on malformed
/// input.
pub fn parse_card(code: &str) -> Result<Card> {
Card::parse(code).ok_or_else(|| GameError::illegal_move("invalid card code").into())
}
/// Record of a single play, broadcast so every client can show who played
/// which card and what it captured.
#[derive(Debug, Clone, Default)]
pub struct Move {
pub seat: i64,
pub name: String,
pub card: String,
pub captured: Vec<String>,
pub scopa: bool,
/// The play was an asso pigliatutto capture (the whole table, or the ace
/// itself when the table was empty).
pub pigliatutto: bool,
}
impl Move {
pub fn to_json(&self) -> Value {
json!({
"seat": self.seat,
"name": self.name,
"card": self.card,
"captured": self.captured,
"scopa": self.scopa,
"pigliatutto": self.pigliatutto,
})
}
pub fn from_json(data: &Value) -> Move {
Move {
seat: data.get("seat").and_then(Value::as_i64).unwrap_or(0),
name: data
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
card: data
.get("card")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
captured: data
.get("captured")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.map(|c| c.as_str().unwrap_or_default().to_string())
.collect()
})
.unwrap_or_default(),
scopa: data.get("scopa").and_then(Value::as_bool).unwrap_or(false),
pigliatutto: data
.get("pigliatutto")
.and_then(Value::as_bool)
.unwrap_or(false),
}
}
}
/// One player's private state within a game.
#[derive(Debug, Clone, Default)]
pub struct PlayerState {
pub sub: String,
pub name: String,
pub seat: i64,
pub hand: Vec<Card>,
pub captured: Vec<Card>,
pub scope: i64,
}
impl PlayerState {
pub fn team(&self) -> i64 {
team_of(self.seat)
}
pub fn to_json(&self) -> Value {
json!({
"sub": self.sub,
"name": self.name,
"seat": self.seat,
"hand": self.hand.iter().map(Card::to_json).collect::<Vec<_>>(),
"captured": self.captured.iter().map(Card::to_json).collect::<Vec<_>>(),
"scope": self.scope,
})
}
pub fn from_json(data: &Value) -> Result<PlayerState> {
let parse_cards = |value: Option<&Value>| -> Result<Vec<Card>> {
let items = value.and_then(Value::as_array).cloned().unwrap_or_default();
items.iter().map(Card::from_json).collect()
};
Ok(PlayerState {
sub: data
.get("sub")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
name: data
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
seat: data.get("seat").and_then(Value::as_i64).unwrap_or(0),
hand: parse_cards(data.get("hand"))?,
captured: parse_cards(data.get("captured"))?,
scope: data.get("scope").and_then(Value::as_i64).unwrap_or(0),
})
}
}
/// The whole mutable state of one scopone scientifico game.
#[derive(Debug, Clone)]
pub struct ScoponeState {
pub target_score: i64,
/// Whether the napola rule is scored (denari run from the ace; a full suit
/// wins the match instantly). Default on.
pub napola: bool,
/// Whether the asso pigliatutto rule is scored (an ace sweeps the whole
/// table when no equal rank is on it, or captures itself when the table is
/// empty; the capture never scores a scopa). Default off, also for states
/// serialized before the option existed.
pub asso_pigliatutto: bool,
pub phase: String,
pub players: Vec<PlayerState>,
pub table: Vec<Card>,
pub dealer: i64,
pub turn: i64,
pub hand_number: i64,
pub scores: Vec<i64>,
pub winner: Option<i64>,
pub last_taker: Option<i64>,
/// Per-hand points awarded, for a compact audit trail in the API.
pub hand_scores: Vec<Value>,
/// The most recent play in the current hand, for move announcements.
pub last_move: Option<Move>,
/// While `phase == "hand_end"`: seats that acknowledged the summary, and
/// when the auto-continue timeout fires.
pub acked: Vec<i64>,
pub hand_end_deadline: Option<String>,
/// Seconds the hand-end summary waits before dealing anyway.
pub hand_ack_timeout: i64,
/// While `phase == "playing"`: when the server plays a random legal card
/// for the player on turn.
pub turn_deadline: Option<String>,
pub turn_timeout: i64,
}
impl Default for ScoponeState {
fn default() -> Self {
ScoponeState {
target_score: DEFAULT_TARGET_SCORE,
napola: true,
asso_pigliatutto: false,
phase: PHASE_LOBBY.to_string(),
players: Vec::new(),
table: Vec::new(),
dealer: 0,
turn: 0,
hand_number: 1,
scores: vec![0, 0],
winner: None,
last_taker: None,
hand_scores: Vec::new(),
last_move: None,
acked: Vec::new(),
hand_end_deadline: None,
hand_ack_timeout: 30,
turn_deadline: None,
turn_timeout: 30,
}
}
}
impl ScoponeState {
pub fn to_json(&self) -> Value {
json!({
"target_score": self.target_score,
"napola": self.napola,
"asso_pigliatutto": self.asso_pigliatutto,
"phase": self.phase,
"players": self.players.iter().map(PlayerState::to_json).collect::<Vec<_>>(),
"table": self.table.iter().map(Card::to_json).collect::<Vec<_>>(),
"dealer": self.dealer,
"turn": self.turn,
"hand_number": self.hand_number,
"scores": self.scores,
"winner": self.winner,
"last_taker": self.last_taker,
"hand_scores": self.hand_scores,
"last_move": self.last_move.as_ref().map(Move::to_json),
"acked": self.acked,
"hand_end_deadline": self.hand_end_deadline,
"hand_ack_timeout": self.hand_ack_timeout,
"turn_deadline": self.turn_deadline,
"turn_timeout": self.turn_timeout,
})
}
pub fn from_json(data: &Value) -> Result<ScoponeState> {
let obj = data
.as_object()
.ok_or_else(|| GameError::illegal_move("state must be a JSON object"))?;
let parse_array = |value: Option<&Value>| -> Vec<Value> {
value.and_then(Value::as_array).cloned().unwrap_or_default()
};
let players = parse_array(obj.get("players"))
.iter()
.map(PlayerState::from_json)
.collect::<Result<Vec<_>>>()?;
let table = parse_array(obj.get("table"))
.iter()
.map(Card::from_json)
.collect::<Result<Vec<_>>>()?;
let scores = obj
.get("scores")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.map(|x| x.as_i64().unwrap_or(0))
.collect::<Vec<_>>()
})
.unwrap_or_else(|| vec![0, 0]);
let last_move = match obj.get("last_move") {
Some(Value::Object(map)) if !map.is_empty() => {
Some(Move::from_json(obj.get("last_move").unwrap()))
}
_ => None,
};
Ok(ScoponeState {
target_score: obj
.get("target_score")
.and_then(Value::as_i64)
.unwrap_or(DEFAULT_TARGET_SCORE),
napola: obj.get("napola").and_then(Value::as_bool).unwrap_or(true),
asso_pigliatutto: obj
.get("asso_pigliatutto")
.and_then(Value::as_bool)
.unwrap_or(false),
phase: obj
.get("phase")
.and_then(Value::as_str)
.unwrap_or(PHASE_LOBBY)
.to_string(),
players,
table,
dealer: obj.get("dealer").and_then(Value::as_i64).unwrap_or(0),
turn: obj.get("turn").and_then(Value::as_i64).unwrap_or(0),
hand_number: obj.get("hand_number").and_then(Value::as_i64).unwrap_or(1),
scores,
winner: obj.get("winner").and_then(Value::as_i64),
last_taker: obj.get("last_taker").and_then(Value::as_i64),
hand_scores: parse_array(obj.get("hand_scores")),
last_move,
acked: obj
.get("acked")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.map(|x| x.as_i64().unwrap_or(0))
.collect::<Vec<_>>()
})
.unwrap_or_default(),
hand_end_deadline: obj
.get("hand_end_deadline")
.and_then(Value::as_str)
.map(str::to_string),
hand_ack_timeout: obj
.get("hand_ack_timeout")
.and_then(Value::as_i64)
.unwrap_or(30),
turn_deadline: obj
.get("turn_deadline")
.and_then(Value::as_str)
.map(str::to_string),
turn_timeout: obj
.get("turn_timeout")
.and_then(Value::as_i64)
.unwrap_or(30),
})
}
// -- helpers ----------------------------------------------------------
pub fn player_for(&self, sub: &str) -> Option<&PlayerState> {
self.players.iter().find(|player| player.sub == sub)
}
pub fn seated(&self, sub: &str) -> bool {
self.player_for(sub).is_some()
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,483 @@
//! `ScoponeEngine` adapter tests: the platform contract over the pure rules.
//!
//! Ported from `tavolo-scopone/tests/test_plugin.py`.
use serde_json::{json, Map, Value};
use tavolo_platform::engine::GameSession;
use tavolo_platform::errors::Error;
use tavolo_platform::{GameEngine, Seat};
use tavolo_scopone::engine as rules;
use tavolo_scopone::state::{Card, ScoponeState, PHASE_FINISHED, PHASE_HAND_END, PHASE_PLAYING};
use tavolo_scopone::ScoponeEngine;
fn opts(value: Value) -> Map<String, Value> {
value.as_object().unwrap().clone()
}
fn empty() -> Map<String, Value> {
Map::new()
}
fn capitalize(name: &str) -> String {
let mut chars = name.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
fn base_session(engine: &ScoponeEngine) -> GameSession {
let mut session = GameSession::new("s1", engine.id(), "CODE01");
session.creator_sub = "alice".to_string();
session.players = vec![Seat {
user_sub: "alice".to_string(),
display_name: "Alice".to_string(),
team: None,
}];
session
}
fn make_session(engine: &ScoponeEngine, options: Map<String, Value>) -> Result<GameSession, Error> {
let mut session = base_session(engine);
engine.create(&mut session, &options)?;
Ok(session)
}
fn started(engine: &ScoponeEngine, options: Map<String, Value>) -> GameSession {
let mut session = make_session(engine, options).unwrap();
for name in ["bob", "carol", "dave"] {
engine.join(&mut session, name, &capitalize(name)).unwrap();
}
session
}
fn state(session: &GameSession) -> &ScoponeState {
session
.state
.as_ref()
.unwrap()
.downcast_ref::<ScoponeState>()
.unwrap()
}
fn state_mut(session: &mut GameSession) -> &mut ScoponeState {
session
.state
.as_mut()
.unwrap()
.downcast_mut::<ScoponeState>()
.unwrap()
}
fn err_message(error: &Error) -> String {
error
.as_game()
.map(|game| game.message.clone())
.unwrap_or_default()
}
/// Extract the error from a failed `make_session` without requiring
/// `GameSession: Debug` (which `unwrap_err` would).
fn session_error(result: Result<GameSession, Error>) -> Error {
match result {
Ok(_) => panic!("expected an error"),
Err(error) => error,
}
}
/// Play legal moves (and acknowledge hand ends) until the match finishes.
fn drive_to_finish(engine: &ScoponeEngine, session: &mut GameSession) -> usize {
let mut moves = 0;
while !engine.is_finished(session) && moves < 200_000 {
let (action, sub, payload): (&str, String, Map<String, Value>) = {
let st = state(session);
if st.phase == PHASE_HAND_END {
let player = st
.players
.iter()
.find(|player| !st.acked.contains(&player.seat))
.unwrap();
("ack", player.sub.clone(), Map::new())
} else {
let player = st
.players
.iter()
.find(|player| player.seat == st.turn)
.unwrap();
let played = player.hand[0].clone();
let options = rules::legal_captures(&st.table, played.clone(), st.asso_pigliatutto);
let mut payload = Map::new();
payload.insert("card".to_string(), json!(played.code()));
if let Some(option) = options.first() {
payload.insert(
"capture".to_string(),
json!(option.iter().map(Card::code).collect::<Vec<_>>()),
);
}
("play", player.sub.clone(), payload)
}
};
engine
.handle_action(session, &sub, action, &payload)
.unwrap();
moves += 1;
}
moves
}
// -- create ----------------------------------------------------------------
#[test]
fn create_seats_creator_on_team_a() {
let engine = ScoponeEngine::default();
let session = make_session(&engine, empty()).unwrap();
assert_eq!(Some("A".to_string()), session.players[0].team);
let st = state(&session);
assert_eq!(11, st.target_score);
assert!(st.napola);
}
#[test]
fn create_options() {
let engine = ScoponeEngine::default();
let session =
make_session(&engine, opts(json!({"target_score": 16, "napola": false}))).unwrap();
let st = state(&session);
assert_eq!(16, st.target_score);
assert!(!st.napola);
assert!(!st.asso_pigliatutto);
}
#[test]
fn create_asso_pigliatutto_option() {
let engine = ScoponeEngine::default();
let session = make_session(&engine, opts(json!({"asso_pigliatutto": true}))).unwrap();
assert!(state(&session).asso_pigliatutto);
}
#[test]
fn create_rejects_bad_options() {
let engine = ScoponeEngine::default();
assert_eq!(
"target_score must be between 1 and 100",
err_message(&session_error(make_session(
&engine,
opts(json!({"target_score": 0}))
)))
);
assert_eq!(
"target_score must be an integer",
err_message(&session_error(make_session(
&engine,
opts(json!({"target_score": "eleven"}))
)))
);
assert_eq!(
"napola must be a boolean",
err_message(&session_error(make_session(
&engine,
opts(json!({"napola": "yes"}))
)))
);
assert_eq!(
"asso_pigliatutto must be a boolean",
err_message(&session_error(make_session(
&engine,
opts(json!({"asso_pigliatutto": "yes"}))
)))
);
}
#[test]
fn timeouts_come_from_the_engine() {
let engine = ScoponeEngine::new(7, 9);
let session = make_session(&engine, empty()).unwrap();
let st = state(&session);
assert_eq!(7, st.turn_timeout);
assert_eq!(9, st.hand_ack_timeout);
}
// -- join ------------------------------------------------------------------
#[test]
fn join_assigns_teams_and_starts() {
let engine = ScoponeEngine::default();
let mut session = make_session(&engine, empty()).unwrap();
assert!(engine.in_lobby(&session));
for (name, team) in [("bob", "B"), ("carol", "A"), ("dave", "B")] {
engine.join(&mut session, name, &capitalize(name)).unwrap();
assert_eq!(Some(team.to_string()), session.players.last().unwrap().team);
}
assert!(!engine.in_lobby(&session));
assert_eq!(PHASE_PLAYING, state(&session).phase);
}
#[test]
fn join_errors() {
let engine = ScoponeEngine::default();
let mut session = make_session(&engine, empty()).unwrap();
assert_eq!(
"already joined this game",
err_message(&engine.join(&mut session, "alice", "Alice").unwrap_err())
);
for name in ["bob", "carol", "dave"] {
engine.join(&mut session, name, &capitalize(name)).unwrap();
}
// The lobby filled up and the match started: late joins and even re-joins
// are rejected as "already started".
assert_eq!(
"game has already started",
err_message(&engine.join(&mut session, "erin", "Erin").unwrap_err())
);
assert_eq!(
"game has already started",
err_message(&engine.join(&mut session, "alice", "Alice").unwrap_err())
);
}
// -- actions ---------------------------------------------------------------
#[test]
fn unknown_action_rejected() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
assert!(engine
.handle_action(&mut session, "alice", "dance", &Map::new())
.is_err());
}
#[test]
fn play_validates_payload() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
assert!(engine
.handle_action(&mut session, "bob", "play", &opts(json!({"card": 42})))
.is_err());
assert!(engine
.handle_action(&mut session, "bob", "play", &Map::new())
.is_err());
assert!(engine
.handle_action(
&mut session,
"bob",
"play",
&opts(json!({"card": "01D", "capture": "02C"}))
)
.is_err());
}
#[test]
fn invalid_card_code_is_illegal_move() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
let error = engine
.handle_action(&mut session, "bob", "play", &opts(json!({"card": "nope"})))
.unwrap_err();
assert_eq!("invalid card code", err_message(&error));
}
#[test]
fn finished_match_rejects_actions() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, opts(json!({"target_score": 1})));
drive_to_finish(&engine, &mut session);
assert!(engine.is_finished(&session));
let error = engine
.handle_action(&mut session, "alice", "play", &opts(json!({"card": "01D"})))
.unwrap_err();
assert_eq!("the match is over", err_message(&error));
}
// -- views -----------------------------------------------------------------
#[test]
fn view_for_hides_other_hands() {
let engine = ScoponeEngine::default();
let session = started(&engine, empty());
let view = engine.view_for(&session, "alice");
let players = view["players"].as_array().unwrap();
let alice = players.iter().find(|p| p["seat"] == 0).unwrap();
let bob = players.iter().find(|p| p["seat"] == 1).unwrap();
assert!(alice.get("hand").is_some());
assert!(bob.get("hand").is_none());
// The envelope is the platform's job, not the view's.
assert!(view.get("id").is_none());
assert!(view.get("join_code").is_none());
assert!(view.get("game_type").is_none());
}
#[test]
fn lobby_view() {
let engine = ScoponeEngine::default();
let session = make_session(&engine, opts(json!({"target_score": 16}))).unwrap();
let lobby = engine.lobby_view(&session);
assert_eq!(json!("lobby"), lobby["phase"]);
assert_eq!(json!(16), lobby["target_score"]);
assert_eq!(json!(true), lobby["napola"]);
assert_eq!(json!(false), lobby["asso_pigliatutto"]);
}
// -- serialization ---------------------------------------------------------
#[test]
fn state_roundtrip() {
let engine = ScoponeEngine::default();
let session = started(&engine, empty());
let state_value = engine
.state_to_json(session.state.as_ref().unwrap().as_ref())
.unwrap();
let restored = engine.state_from_json(&state_value).unwrap();
let restored = restored.downcast_ref::<ScoponeState>().unwrap();
assert_eq!(state(&session).phase, restored.phase);
assert_eq!(state(&session).turn, restored.turn);
assert_eq!(
state(&session)
.players
.iter()
.map(|p| p.sub.clone())
.collect::<Vec<_>>(),
restored
.players
.iter()
.map(|p| p.sub.clone())
.collect::<Vec<_>>()
);
}
// -- deadlines -------------------------------------------------------------
#[test]
fn no_deadline_in_lobby() {
let engine = ScoponeEngine::default();
let session = make_session(&engine, empty()).unwrap();
assert!(engine.next_deadline(&session).unwrap().is_none());
}
#[test]
fn turn_deadline_and_revalidation() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
let deadline = engine.next_deadline(&session).unwrap().unwrap();
assert_eq!("turn", deadline.kind);
// A forged token is stale.
assert!(engine
.fire_deadline(&mut session, "turn", "turn:1:1:0")
.is_err());
let turn_before = state(&session).turn;
engine
.fire_deadline(&mut session, &deadline.kind, &deadline.token)
.unwrap();
assert_ne!(turn_before, state(&session).turn);
}
#[test]
fn stale_deadline_after_play() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
let deadline = engine.next_deadline(&session).unwrap().unwrap();
// A play lands in time: the armed deadline is overtaken.
let (sub, payload) = {
let st = state(&session);
let player = st.players.iter().find(|p| p.seat == st.turn).unwrap();
let played = player.hand[0].clone();
let options = rules::legal_captures(&st.table, played.clone(), st.asso_pigliatutto);
let mut payload = Map::new();
payload.insert("card".to_string(), json!(played.code()));
if let Some(option) = options.first() {
payload.insert(
"capture".to_string(),
json!(option.iter().map(Card::code).collect::<Vec<_>>()),
);
}
(player.sub.clone(), payload)
};
engine
.handle_action(&mut session, &sub, "play", &payload)
.unwrap();
assert!(engine
.fire_deadline(&mut session, &deadline.kind, &deadline.token)
.is_err());
}
#[test]
fn hand_end_deadline_advances() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
{
let st = state_mut(&mut session);
st.phase = PHASE_HAND_END.to_string();
st.hand_end_deadline = Some(tavolo_platform::datetime::to_iso(
tavolo_platform::datetime::now() + chrono::Duration::seconds(60),
));
}
let deadline = engine.next_deadline(&session).unwrap().unwrap();
assert_eq!("hand_end", deadline.kind);
engine
.fire_deadline(&mut session, &deadline.kind, &deadline.token)
.unwrap();
let st = state(&session);
assert_eq!(PHASE_PLAYING, st.phase);
assert_eq!(2, st.hand_number);
}
// -- results ---------------------------------------------------------------
#[test]
fn result_of_finished_match() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, opts(json!({"target_score": 1})));
drive_to_finish(&engine, &mut session);
let result = engine.result(&session).unwrap();
assert_eq!(2, result.teams.len());
assert!(result.winner_team == 0 || result.winner_team == 1);
assert_eq!(4, result.players.len());
let winner_name = if result.winner_team == 0 { "A" } else { "B" };
for player in &result.players {
assert_eq!(player.won, player.team.as_deref() == Some(winner_name));
}
assert!(result.summary.get("team_a_score").is_some());
assert!(result.summary.get("hands_played").is_some());
}
#[test]
fn result_requires_finished_match() {
let engine = ScoponeEngine::default();
let session = started(&engine, empty());
assert!(engine.result(&session).is_err());
}
#[test]
fn game_over_view() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
{
let st = state_mut(&mut session);
st.phase = PHASE_FINISHED.to_string();
st.winner = Some(1);
st.scores = vec![3, 11];
}
let over = engine.game_over_view(&session, "alice");
assert_eq!(json!({"A": 3, "B": 11}), over["scores"]);
assert_eq!(json!("B"), over["winner"]);
}
#[test]
fn registry_metadata() {
let engine = ScoponeEngine::default();
assert_eq!("scopone_scientifico", engine.id());
assert_eq!(4, engine.min_players());
assert_eq!(4, engine.max_players());
let schema = engine.options_schema();
assert!(schema["properties"]["target_score"].is_object());
assert!(schema["properties"]["napola"].is_object());
assert!(schema["properties"]["asso_pigliatutto"].is_object());
// Byte-compatible option schema: descriptions and defaults match Python.
assert_eq!(
json!("Score the napola rule; a full denari sweep wins the match"),
schema["properties"]["napola"]["description"]
);
assert_eq!(
json!("Score the asso pigliatutto rule; an ace sweeps the whole table (no scopa)"),
schema["properties"]["asso_pigliatutto"]["description"]
);
}
@@ -1,67 +0,0 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
CREATE TABLE IF NOT EXISTS "match" (
"id" UUID NOT NULL PRIMARY KEY,
"team_a_score" SMALLINT NOT NULL,
"team_b_score" SMALLINT NOT NULL,
"winner_team" VARCHAR(1) NOT NULL,
"target_score" SMALLINT NOT NULL,
"hands_played" SMALLINT NOT NULL,
"started_at" TIMESTAMPTZ NOT NULL,
"finished_at" TIMESTAMPTZ NOT NULL
);
COMMENT ON TABLE "match" IS 'A completed scopone scientifico match.';
CREATE TABLE IF NOT EXISTS "match_player" (
"id" UUID NOT NULL PRIMARY KEY,
"user_sub" VARCHAR(255) NOT NULL,
"display_name" VARCHAR(200) NOT NULL,
"seat" SMALLINT NOT NULL,
"team" VARCHAR(1) NOT NULL,
"won" BOOL NOT NULL,
"match_id" UUID NOT NULL REFERENCES "match" ("id") ON DELETE CASCADE,
CONSTRAINT "uid_match_playe_match_i_f85e3e" UNIQUE ("match_id", "user_sub")
);
COMMENT ON TABLE "match_player" IS 'Participation of one user in one match.';
CREATE INDEX IF NOT EXISTS "idx_match_playe_user_su_914c0d" ON "match_player" ("user_sub");
CREATE TABLE IF NOT EXISTS "aerich" (
"id" SERIAL NOT NULL PRIMARY KEY,
"version" VARCHAR(255) NOT NULL,
"app" VARCHAR(100) NOT NULL,
"content" JSONB NOT NULL
);"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
"""
MODELS_STATE = (
"eJztmW1v2joUx79KlFedtFWFdu10NU0KlGpsBaqS7l5tmiyTGLCa2FnsXIZ6+92v7Tw7Dy"
"3dmErFG0iOfZyTnx2f80/uTJ+6yGOHI8idpfmXcWcS6CNxUG54bZgwCHKzNHA48+KeWZcZ"
"4yF0uDDOoceQMLmIOSEOOKZEdrUMh/qBhzhyDebQgBIk/jEiHM+xQw011KEcy6WOGAyTxW"
"ZuEcE/IgQ4XSC+RKFw/vZdmDFx0U/E0tPgFswx8tzSDWNXDqDsgK8DZbu5GZ5fqJ4ypBlw"
"qBf5JO8drPmSkqx7FGH3UPrItgUiKIQi5AIOEnlegi01xRELAw8jlIXq5gYXzWHkSajm+3"
"lEHMnSUFeSPycfktAK3QAYT2wwHdgAmJU5kCFofBOTQ4mcP0y4BHV3H4+bA1FWU16g/9G6"
"Pjg+faUQUMYXoWpUuMx75Qg5jF0V9JwyR9AHEIhZDFGV99SHnjckvJ657qvRF4E/hXtqyM"
"HnizdFmkLbAmYRr/h7c9w9O30nWlWM8uSshf90ZF1eDse2Yq2xnf0C29mebRPbFSbi1oHE"
"VEXbX8KwHqvmplEVse4iVR/+BB4iCy4zRqeF5RfrWm0VHbVTUJEb4owxThq6skVbwzAUW/"
"cT17Dmu1/DZbZLSFwGAg+uUU2ua2er++7ZltkysfjEfQPIq2TPBRGOfVRPtuypcXUT18P0"
"YAcpt8C0h6PB1LZGV3J4n7EfnuJl2QPZ0lXWtWY9ONV2k2wQ4++h/dGQp8bXyXigVydZP/"
"urKWOCEaeA0BWAbpFJak5NpWmeY4LZ8knzrLnuJ/rZTbSUBvPbQtkqDTPo3K5g6IJSS74i"
"1JYYsupq6CWOF5+vkQcVy+q0F1XWlRppd+c9t5qxeFFAaZc2Ea02+V1ft0ACF+qW5LXllW"
"qQNenWnOgD6jVObOHjROyV2K+xgwM1pwadG1KORgyFBibquEHFbuBXI2O/5Tpb+gAWzczv"
"e237nLRtNi8bCISiz7bUQQX2HxMH3bdvHyEPRK9GgaDayqWWi5l8XIE63wC17vcCxVj36O"
"gxvI+OmnnLNq20RXXFTrtcSH32MqH6imaTNbt/cdCwTisvDlZxfaXVYJR6CJKGdzO1FdlM"
"uOwg2xaYvcnkslR294a2hvVm1BuktEUnzJW5unzjYmmzaqLo8ztriucL/IESoiIzNL5VuB"
"c0RHhBPqO1QjwUgUDi1CUx/avNbkKtKAlhDuEqK2xLa0rcvbhnFC/ZvjXtW+cD875Zum1T"
"lFgoxPXf0ZKW121SBOZ9HhIhzUR/8xewxgxf+0DX5Pdk+n5NHWw/uXc7J2cn745PT7IMn1"
"na0ny6RTaLgn9RyHBdbmpO+gWXF5j3t6IJ5EO1AeGk+wuk23mUAui0KIBOVQGIK3JEakTA"
"p+lkXE+44KK/6cQON/4zPMx2UQu0wJUwSnVWyvRgZP2j4+5fTnp6fSAH6NUVCH8ymd3/D7"
"yE7wc="
)
@@ -1,42 +0,0 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "match" ADD "game_type" VARCHAR(32) NOT NULL DEFAULT 'scopone_scientifico';
CREATE INDEX IF NOT EXISTS "idx_match_game_ty_7d519c" ON "match" ("game_type");"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
DROP INDEX IF EXISTS "idx_match_game_ty_7d519c";
ALTER TABLE "match" DROP COLUMN "game_type";"""
MODELS_STATE = (
"eJztmW1P4zgQgP9KlE+ctIdoYGF1Op2UlqLtLW0RLXenXa0sN3FTi8TO2s51K47/frbTNI"
"3zQgsUAeoXaGY8jv2MX2Ymd3ZEfRTywz4U3sz+zbqzCYyQ/FFUfLBsGMe5WAkEnIRpy1WT"
"CRcMekIKpzDkSIp8xD2GY4EpUU1dy6NRHCKBfEubWXRqUYLUPzFDFkMB5gIxqQ7kOCyxiB"
"E/VH371JOdYxI8rZuE4B8JAoIGSDZksrNv36UYEx/9RDx7jG/BFKPQLwDBvupAy4HqUMlu"
"bnrnF7qlGuIEeDRMIpK3jhdiRsmqeZJg/1DZKF2ACGJQTmENF0nCcIk1E6UjlgLBErQaqp"
"8LfDSFSaig279PE+Ip1pZ+k/pz8sdyaGvNABgMx2DUHQNgl3ykhmDwXoo8SpR/MREK1N19"
"2m8OREtt9YLOZ/f64Pj0F42AchEwrdS47HttCAVMTTX0nLLyV8qrBLszg6wadsHIYC6H/B"
"jamaAJN/doLFcd4B5GROAp9uiuYMtN9hOEiARC7dJjpwH+X+51yt/R/Knckek+HSw1jlYp"
"N+TYBYIRgHImlFWQH0UwDHtEVNM3bQ0HyCk8jwPyMyWDm+HbBfBADeLXY+fs9JPU6jGqh7"
"MG8qO+e3nZG4yr2E6ewHayZ1vHdo6JnDpQmLY5MAyzXR0ZL0u1cES0NjghWrUHRKt0PkAm"
"b8xHrmHDdr+Gi2xnkPgcxCFcoIoQo5mtabtnW2TL5eKT8wZQlMmeSyICR6iabNHS4OovTQ"
"+zH2+QcgPMca/fHY3d/pXqPuL8R6h5ueOu0jhaujCkB6fGabLqxPq7N/5sqUfr63DQNYPC"
"VbvxV1uNCSaCAkLnAPrrTDJxJiq4eYoJ5rNH+dkw3Tv61TlaZWTT27VsQQkm0LudQ+aDgi"
"ZfEfpIZLy8GtpLw4sv1yiEmmXZ7evJ75Xu6e36PZfaaRKjgVKH1hEtqyInMiWQwEBPSb1b"
"vakCWV05ISf6QFEhvdjYZrWFK3leYw/H2qdZQSDhiFmY6N+6y3IxYQu7iurBt7z8oWwATy"
"b2931J4TWVFFZ+2SJBWLd5sYLCiyUHzsePG6QHslVtgqB1xVDLx1xtV6Cft0Bt2r3DZMw5"
"OtqE99FRPW+lM0JbVBXsNKcLmc0+TSiXaLZZs/vCQc06LRUO5ml8ZcRglIYIkpraTGVENp"
"Emb5BtA8z2cHhZCLvbvbGB9abf7ma0ZSMstLi8fNNgabtoYt3mOWOK1wv8gRCilGYYfMtw"
"LyhDOCBf0EIj7smBQOJVXWLmx7S3CbWUSUgxg/NVYFtYU3L2cs4oXbIdd9Rxz7v2fX3qts"
"ukxEUMV3/eXGo+NKUiMG/zUBJST/SZPzzW3vCVG7rifl+672nZwe4vd6d1cnby6fj0ZHXD"
"ryRN13x2RNYnBf8ixnHV3VR/6a+ZvMN7fyc5gdpUWxBeNn+HdFsbZQCthgygVc4A5BsFIh"
"VJwJ+j4aCa8JqJWenEnrD+s0LM32Iu0ABXwSjEWRnTg777j4m7czlsm/GB6qBdFSC85GV2"
"/z8H5XI/"
)
@@ -1,55 +0,0 @@
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="
)
@@ -1,73 +0,0 @@
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=="
)
-31
View File
@@ -1,31 +0,0 @@
# tavolo-app
The deployable tavolo application: the composition root wiring
[`tavolo-platform`](../tavolo-platform/README.md) to the
[`tavolo-scopone`](../tavolo-scopone/README.md) game.
## Contents
- `app.py` — assembles the `KayaApp` (session/OIDC/Tortoise/OpenAPI
mixins plus `PlatformMixin` and the deadline scheduler), registers the
`ScoponeEngine` with its configured timeouts, and exposes
`tavolo.app:app` for granian.
- `config.py` — environment-driven frozen `Settings`.
- `aerich_config.py` — Tortoise ORM configuration consumed by the aerich
CLI (migrations live in `server/migrations/`; aerich itself is
configured via the `[tool.aerich]` section of `server/pyproject.toml`).
- `logging_config.py` — logging setup.
- `static.py` — SPA shell hosting for the compiled frontend.
## Development (from `packages/tavolo-app/`)
```sh
.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
```
End-to-end coverage of the wired stack lives in `server/tests/`.
-47
View File
@@ -1,47 +0,0 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "tavolo-app"
version = "0.1.0"
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",
"asyncpg",
"aerich",
"httpx",
"PyJWT[crypto]",
"pwo",
"PyYAML",
"redis",
]
[project.optional-dependencies]
dev = [
"mypy",
"httpx-ws",
]
otel = [
"kaya-otel>=0.0.4",
]
[tool.setuptools.packages.find]
where = ["src"]
namespaces = true
[tool.mypy]
python_version = "3.12"
ignore_missing_imports = true
plugins = []
@@ -1,23 +0,0 @@
"""Tortoise ORM configuration consumed by the aerich CLI.
Kept separate from :mod:`tavolo.app` so ``aerich`` can import it without
assembling the whole application (mixins, routes). The database URL comes
from the same :class:`~tavolo.config.Settings` the app uses, so the CLI
and the app always point at the same database.
``aerich.models`` is required alongside the app models: it provides the
table aerich uses to track applied migrations.
"""
from __future__ import annotations
from .config import settings
TORTOISE_ORM = {
"connections": {"default": settings.database_url},
"apps": {
"models": {
"models": ["tavolo.platform.models", "aerich.models"],
"default_connection": "default",
}
},
}
@@ -1,209 +0,0 @@
"""Application entry point.
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.platform.tortoise_mixin.TortoiseMixin` (Postgres match
statistics; skipped for ``/api/health`` and the OpenAPI documentation
endpoints)
- :class:`~kaya.openapi.OpenAPIMixin` (serves the OpenAPI document at
``/api/openapi.json`` and a Swagger UI at ``/api/docs``)
- :class:`~tavolo.platform.mixin.PlatformMixin` (game lobby, match
history, leaderboards and the live-play websocket, served by the
registered game engines)
- :class:`~tavolo.platform.deadlines.DeadlineSchedulerMixin` (fires the
engines' timeouts)
A :class:`~kaya.cors.CorsMixin` is prepended when CORS is configured via the
``CORS_*`` environment variables (see :mod:`tavolo.config`). A
:class:`~kaya.otel.OTelMixin` (optional ``otel`` extra) is prepended when
``OTEL_ENABLED`` is set, adding OpenTelemetry traces and metrics.
Live games are kept in :data:`game_store` (Redis when configured,
in-memory otherwise). 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, 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, settings
from .logging_config import configure_logging
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 = 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(registry)
log.info("REDIS_URL unset: using in-memory stores (sessions + live games)")
session_mixin = SessionMixin(session_store)
oidc_mixin = OIDCMixin(
OIDCConfig(
issuer=settings.oidc_issuer,
client_id=settings.oidc_client_id,
client_secret=settings.oidc_client_secret,
redirect_uri=settings.oidc_redirect_uri,
post_login_redirect=settings.oidc_post_login_redirect,
post_logout_redirect=settings.oidc_post_logout_redirect,
fetch_userinfo=True,
),
session=session_mixin,
)
openapi_mixin = OpenAPIMixin(
title="tavolo",
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.platform.models"],
skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}),
)
scheduler = DeadlineScheduler(
game_store,
registry,
heartbeat_ms=settings.deadline_heartbeat_ms,
# The consumer task has no request context; bind the database context
# before it persists a match that a timeout finished.
context_binder=tortoise_mixin.ensure_context,
)
platform = Platform(
registry=registry,
game_store=game_store,
scheduler=scheduler,
oidc=oidc_mixin,
)
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
@@ -1,185 +0,0 @@
"""Environment-driven configuration for the tavolo application.
Mirrors kaya's own pattern: read ``os.environ`` directly into a plain
dataclass. No pydantic-settings, no settings module.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Optional, Tuple
from urllib.parse import quote
def _env(name: str, default: Optional[str] = None) -> str:
value = os.environ.get(name)
if value is None or value == "":
if default is None:
raise RuntimeError(f"Missing required environment variable: {name}")
return default
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],
host: str,
port: str,
name: str,
options: str) -> str:
"""Assemble a database DSN from individual components.
``user`` and ``password`` are percent-encoded so credentials containing
URL-reserved characters (``@``, ``:``, ``/``, ...) do not corrupt the
DSN. ``port`` and ``options`` are omitted when empty: a missing port
lets the driver pick its default (5432 for asyncpg). ``options`` is a
raw query string (e.g. ``ssl=require``) appended after a ``?``.
"""
netloc = quote(user, safe="")
if password:
netloc += ":" + quote(password, safe="")
netloc += "@" + host
if port:
netloc += ":" + port
url = f"{engine}://{netloc}/{name}"
options = options.lstrip("?")
if options:
url += "?" + options
return url
@dataclass(frozen=True)
class Settings:
database_url: str
oidc_issuer: str
oidc_client_id: str
oidc_client_secret: Optional[str]
oidc_redirect_uri: str
# Where the browser is sent after login/logout. In production the SPA is
# served by this app ("/"); in development point these at the trunk dev
# server (e.g. "http://localhost:8000/").
oidc_post_login_redirect: str
oidc_post_logout_redirect: str
app_host: str
app_port: int
redis_url: Optional[str]
# How long a live game (and its join-code index) survives in Redis
# without activity, in seconds. Defaults to 24h.
game_ttl_seconds: int
# Directory holding the compiled frontend (trunk's dist output). Only
# used to locate index.html for the SPA shell; the assets themselves
# 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 (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 (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":
return Settings(
# DATABASE_URL, when set, is used verbatim and the DATABASE_*
# parts below are ignored (sqlite in tests, managed-DB DSNs).
database_url=os.environ.get("DATABASE_URL") or _database_url_from_parts(
engine=_env("DATABASE_ENGINE", "postgres"),
user=_env("DATABASE_USER", "tavolo"),
password=_env("DATABASE_PASSWORD", "password"),
host=_env("DATABASE_HOST", "localhost"),
# Unset: the port segment is omitted and the driver default
# (5432 for asyncpg) applies.
port=os.environ.get("DATABASE_PORT", ""),
name=_env("DATABASE_NAME", "tavolo"),
# Raw DSN query string (e.g. "ssl=require"); empty = none.
options=os.environ.get("DATABASE_OPTIONS", ""),
),
oidc_issuer=_env("OIDC_ISSUER", "http://localhost:8180/tavolo"),
oidc_client_id=_env("OIDC_CLIENT_ID", "tavolo"),
oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET"),
oidc_redirect_uri=_env("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback"),
oidc_post_login_redirect=_env("OIDC_POST_LOGIN_REDIRECT", "/"),
oidc_post_logout_redirect=_env("OIDC_POST_LOGOUT_REDIRECT", "/"),
app_host=_env("APP_HOST", "0.0.0.0"),
app_port=int(_env("APP_PORT", "8080")),
# When unset, sessions and live games fall back to in-memory
# stores (tests, ephemeral dev). Set to e.g.
# redis://localhost:6379/0 to persist both in Redis.
redis_url=os.environ.get("REDIS_URL"),
game_ttl_seconds=int(_env("GAME_TTL_SECONDS", "86400")),
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",),
)
settings: Settings = Settings.from_env()
@@ -1,74 +0,0 @@
"""Logging setup for the tavolo application.
Configured once at app import time (see :mod:`tavolo.app`). By default a
single console handler at DEBUG level is installed, formatting records as
``{asctime} [{levelname}] ({processName}/{threadName}) - {name} - {message}``.
Point the ``LOGGING_CONFIG`` environment variable at a YAML file to take
over the configuration entirely; the file follows the
:data:`logging.config.dictConfig` schema, e.g.::
version: 1
disable_existing_loggers: false
formatters:
default:
format: "{asctime} [{levelname}] ({processName:s}/{threadName:s}) - {name} - {message}"
style: "{"
handlers:
console:
class: logging.StreamHandler
formatter: default
root:
level: WARNING
handlers: [console]
loggers:
tavolo:
level: INFO
``disable_existing_loggers`` should stay ``false``: Granian configures its
own loggers before importing the application, and disabling them would
silence the server and Tortoise.
"""
from __future__ import annotations
from logging.config import dictConfig
from typing import Optional
import yaml
DEFAULT_CONFIG = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {
"format": "{asctime} [{levelname}] ({processName:s}/{threadName:s}) - {name} - {message}",
"style": "{",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "default",
"level": "DEBUG",
},
},
"root": {
"level": "DEBUG",
"handlers": ["console"],
},
}
def configure_logging(config_path: Optional[str]) -> None:
"""Apply the YAML logging configuration at ``config_path``, or the
built-in default when unset."""
if config_path is None:
dictConfig(DEFAULT_CONFIG)
return
try:
with open(config_path, "rb") as handle:
config = yaml.safe_load(handle)
except OSError as exc:
raise RuntimeError(f"Cannot read LOGGING_CONFIG file: {config_path}") from exc
if not isinstance(config, dict):
raise RuntimeError(f"LOGGING_CONFIG file is not a YAML mapping: {config_path}")
dictConfig(config)
@@ -1,46 +0,0 @@
"""SPA shell hosting for the compiled single-page application.
Static assets (wasm, js, css, card images) are served by Granian itself
under the ``/static`` prefix (``GRANIAN_STATIC_PATH_*`` env vars; see the
Dockerfile) and never reach Python. This module only serves ``index.html``
from ``STATIC_DIR``: at the site root and — via the glob catch-all — for
every path no API or auth route claimed, so client-side routes
(``/game/<id>`` etc.) work on direct loads and refreshes.
kaya-openapi deliberately skips glob routes, so this handler never appears
in the API specification.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from kaya.core import HttpContext
from .app import app
from .config import settings
async def _send_shell(ctx: HttpContext) -> None:
"""Serve the SPA shell, or 404 when the frontend build is missing."""
index = Path(settings.static_dir) / "index.html"
if not index.is_file():
await ctx.send_empty(404)
return
body = await asyncio.to_thread(index.read_bytes)
await ctx.send_bytes(200, body, {"content-type": ("text/html; charset=utf-8",)})
@app.GET("/")
async def index(ctx: HttpContext) -> None:
"""Serve the SPA shell at the site root (the glob below cannot match
an empty path)."""
await _send_shell(ctx)
@app.GET("/*", recursive=True)
async def spa(ctx: HttpContext, _matched: object = None) -> None:
"""SPA fallback: any path that matched no other route renders the app
shell. Requests under ``/static`` are answered by Granian before the
app is ever called, so they never arrive here."""
await _send_shell(ctx)
-64
View File
@@ -1,64 +0,0 @@
# 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.
@@ -1,38 +0,0 @@
[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"]
@@ -1,53 +0,0 @@
"""The game-independent half of tavolo: lobby, live play, history, ratings.
This package hosts every concern that does not depend on the rules of a
specific game: the lobby HTTP API, the live-game store, the websocket
endpoint, the deadline scheduler, match persistence and the Elo
leaderboards. A game plugs in by implementing
:class:`~tavolo.platform.engine.GameEngine` and registering it in a
:class:`~tavolo.platform.registry.GameRegistry`; the application then
mounts everything with :class:`~tavolo.platform.mixin.PlatformMixin`.
"""
from __future__ import annotations
from .engine import (
Deadline,
GameEngine,
GameSession,
MatchResult,
PlayerResult,
Seat,
)
from .errors import (
AlreadyJoined,
GameError,
GameFinished,
GameNotFound,
GameNotStarted,
IllegalMove,
LobbyFull,
NotYourTurn,
)
from .mixin import Platform, PlatformMixin
from .registry import GameRegistry, UnknownGameType
__all__ = [
"AlreadyJoined",
"Deadline",
"GameEngine",
"GameError",
"GameFinished",
"GameNotFound",
"GameNotStarted",
"GameRegistry",
"GameSession",
"IllegalMove",
"LobbyFull",
"MatchResult",
"NotYourTurn",
"Platform",
"PlatformMixin",
"PlayerResult",
"Seat",
"UnknownGameType",
]
@@ -1,60 +0,0 @@
"""Authentication helpers on top of the kaya-oidc mixin.
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 OIDCMixin, OIDCUser
def get_ws_user(ws: WebSocket) -> Optional[OIDCUser]:
"""Return the authenticated user of a WebSocket connection, if any.
The session mixin injects ``session`` into the websocket wrapper; the
OIDC mixin stores the userinfo there at login. Patched in tests.
"""
session = getattr(ws, "session", None)
if session is None:
return None
claims = session.get("oidc_user")
if not isinstance(claims, Mapping):
return None
return OIDCUser(claims)
def display_name(user: OIDCUser) -> str:
"""Best-effort human-readable name for a user."""
for key in ("name", "preferred_username", "email"):
value = user.get(key)
if isinstance(value, str) and value:
return value
return user.sub
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).
"""
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 decorator
@@ -1,81 +0,0 @@
"""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())
@@ -1,258 +0,0 @@
"""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, Awaitable, Callable, 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.
``context_binder`` is an optional async callable the consumer awaits
before persisting a finished match. The consumer is a long-lived task
of its own, outside any request, so it must bind whatever ambient
context the database layer needs (see
:meth:`~tavolo.platform.tortoise_mixin.TortoiseMixin.ensure_context`).
"""
def __init__(
self,
store: GameStore,
registry: GameRegistry,
heartbeat_ms: int = 1000,
context_binder: Optional[Callable[[], Awaitable[None]]] = None,
) -> None:
self._store = store
self._registry = registry
self._heartbeat = heartbeat_ms / 1000
self._context_binder = context_binder
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)
# A deadline can finish the match in the consumer task, which
# has no request context: bind the database context first.
if self._context_binder is not None:
await self._context_binder()
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
failed = False
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)
failed = True
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")
failed = True
if failed:
# A failed entry is still due: back off to the heartbeat
# instead of polling it again immediately.
delay = self._heartbeat
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)
@@ -1,49 +0,0 @@
"""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))
@@ -1,253 +0,0 @@
"""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).
Echo the creation options under the same keys as
:attr:`options_schema`, plus ``phase``, so clients can render a
generic pre-join preview (``GET /api/games/preview``) from the
schema without game-specific code.
"""
@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 {}
@@ -1,43 +0,0 @@
"""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."""
@@ -1,73 +0,0 @@
"""JSON helpers for kaya HTTP handlers.
Kaya has no built-in request/response JSON helpers: the request body is an
async byte stream on ``ctx.request_body`` and responses are sent with
``ctx.send_*``. These wrappers handle the boilerplate of draining the body,
parsing JSON, and sending JSON responses.
"""
from __future__ import annotations
import json
from typing import Any, List, Mapping
from kaya.core import HttpContext
JSON_HEADERS = {"content-type": ("application/json",)}
class JsonRequestError(ValueError):
"""Raised by :func:`read_json` when the request body is not valid JSON
or is not a JSON object."""
def extract_query_params(query_string: str) -> Mapping[str, List[str]]:
"""Parse a raw query string into a mapping of param name to list of
values.
Wraps :func:`urllib.parse.parse_qs` so callers don't repeat the
incantation; always returns a mapping (never None).
"""
from urllib.parse import parse_qs
return parse_qs(query_string, keep_blank_values=True)
async def read_json(ctx: HttpContext) -> dict:
"""Drain and parse the request body as JSON.
Returns the parsed ``dict`` on success. Raises :class:`JsonRequestError`
with a short human-readable reason on failure.
"""
body = b""
async for chunk in ctx.request_body:
body += chunk
if not body:
raise JsonRequestError("empty body")
try:
parsed = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise JsonRequestError("invalid JSON") from exc
if not isinstance(parsed, dict):
raise JsonRequestError("JSON body must be an object")
return parsed
async def read_json_optional(ctx: HttpContext) -> dict:
"""Like :func:`read_json` but treats an empty body as ``{}``."""
try:
return await read_json(ctx)
except JsonRequestError as exc:
if str(exc) == "empty body":
return {}
raise
async def send_json(ctx: HttpContext, status: int, payload: Any) -> None:
"""Send ``payload`` as a JSON response."""
body = json.dumps(payload).encode("utf-8")
await ctx.send_bytes(status, body, JSON_HEADERS)
async def send_error(ctx: HttpContext, status: int, message: str) -> None:
"""Send a JSON error envelope."""
await send_json(ctx, status, {"error": message})
@@ -1,50 +0,0 @@
"""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)
@@ -1,86 +0,0 @@
"""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"),)
@@ -1,21 +0,0 @@
"""Shared OpenAPI fragments for the ``@operation`` decorators in ``routes/``."""
from __future__ import annotations
from typing import Any, Dict, List
PAGINATION_PARAMETERS: List[Dict[str, Any]] = [
{
"name": "limit",
"in": "query",
"required": False,
"schema": {"type": "integer", "minimum": 1, "maximum": 100, "default": 20},
"description": "Maximum number of results per page (clamped to [1, 100]).",
},
{
"name": "cursor",
"in": "query",
"required": False,
"schema": {"type": "string"},
"description": "Opaque pagination cursor from a previous response's next_cursor.",
},
]
@@ -1,128 +0,0 @@
"""Cursor-based pagination for listing endpoints.
Uses keyset pagination (not OFFSET/LIMIT): each page ends with an opaque
cursor encoding the sort key tuple of the last item on that page; the next
request passes that cursor and the query continues from the point it left
off. This is stable under concurrent inserts and cheaper than OFFSET for
large result sets.
Cursor is a base64-encoded JSON object mapping the sort-field names to the
values of the last item on the previous page. It's opaque to callers and
must be treated as a black box.
"""
from __future__ import annotations
import base64
import json
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
from tortoise.queryset import QuerySet
from .http import extract_query_params
DEFAULT_LIMIT = 20
MAX_LIMIT = 100
MIN_LIMIT = 1
CURSOR_PARAM = "cursor"
LIMIT_PARAM = "limit"
class CursorDecodeError(ValueError):
"""Raised when the ``cursor`` query param cannot be decoded."""
def encode_cursor(values: Dict[str, Any]) -> str:
# ``default=str`` handles datetimes (ISO) so keyset cursors can carry
# datetime-typed sort fields (e.g. finished_at for match history).
raw = json.dumps(values, separators=(",", ":"), default=str).encode("utf-8")
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
def decode_cursor(s: Optional[str]) -> Optional[Dict[str, Any]]:
if s is None or s == "":
return None
try:
# Tolerate missing padding.
padded = s + "=" * (-len(s) % 4)
raw = base64.urlsafe_b64decode(padded.encode("ascii"))
obj = json.loads(raw.decode("utf-8"))
except (ValueError, UnicodeDecodeError) as exc:
raise CursorDecodeError("invalid cursor") from exc
if not isinstance(obj, dict):
raise CursorDecodeError("invalid cursor")
return obj
@dataclass(frozen=True)
class Cursor:
limit: int
after: Optional[Dict[str, Any]]
def parse_cursor_params(query_string: str) -> Cursor:
params = extract_query_params(query_string)
limit_raw = params.get(LIMIT_PARAM)
if limit_raw:
try:
limit = int(limit_raw[0])
except ValueError as exc:
raise CursorDecodeError("invalid limit") from exc
else:
limit = DEFAULT_LIMIT
limit = max(MIN_LIMIT, min(MAX_LIMIT, limit))
after = decode_cursor(params.get(CURSOR_PARAM, [None])[0])
return Cursor(limit=limit, after=after)
# A sort field: (model field name, "ASC" or "DESC"). The tuple is the full
# keyset; the cursor encodes exactly these fields.
Sort = List[Tuple[str, str]]
def _keyset_where(sort: Sort, after: Dict[str, Any]):
"""Build a Tortoise ``Q`` filter from a cursor."""
from tortoise.queryset import Q # local to keep import edge narrow
clauses = []
for i, (field, direction) in enumerate(sort):
key = f"{field}__{'gt' if direction == 'ASC' else 'lt'}"
value = after.get(field)
if value is None:
return Q()
clause = Q(**{key: value})
for j in range(i):
prev_field, _ = sort[j]
prev_value = after.get(prev_field)
if prev_value is None:
return Q()
clause = clause & Q(**{prev_field: prev_value})
clauses.append(clause)
result = clauses[0]
for clause in clauses[1:]:
result = result | clause
return result
async def paginate(
queryset: QuerySet,
sort: Sort,
cursor: Cursor,
) -> Tuple[List[Any], Optional[str]]:
"""Return one page of ``queryset`` plus the opaque cursor to continue."""
order_by: List[str] = []
for field, direction in sort:
order_by.append(field if direction == "ASC" else f"-{field}")
qs = queryset.order_by(*order_by)
if cursor.after:
qs = qs.filter(_keyset_where(sort, cursor.after))
rows = await qs.limit(cursor.limit + 1)
if len(rows) <= cursor.limit:
return rows, None
page = rows[: cursor.limit]
last = page[-1]
key: Dict[str, Any] = {}
for field, _ in sort:
key[field] = getattr(last, field)
return page, encode_cursor(key)
@@ -1,60 +0,0 @@
"""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())
@@ -1 +0,0 @@
"""HTTP endpoints of the platform: health, identity, lobby, statistics."""
@@ -1,333 +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 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,
extract_query_params,
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 _preview_payload(platform: "Platform", session: GameSession) -> Dict[str, Any]:
"""The join-code preview: the lobby payload plus the game's name and
description, so a client can render a pre-join summary without a
second lookup."""
engine = platform.registry.require(session.game_type)
return {
**_lobby_payload(platform, session),
"game_name": engine.name,
"game_description": engine.description,
}
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/preview")
@operation(summary="Preview a game by join code",
description="Looks up a game by its join code without "
"joining, for the lobby's pre-join summary: game "
"name and description, seated players, open "
"seats and the game-specific options echoed by "
"its engine.",
tags=["games"],
responses={
200: {"description": "The lobby preview"},
400: {"description": "Missing code"},
401: {"description": "Authentication required"},
404: {"description": "Unknown join code"},
})
@require_auth(platform.oidc)
async def preview_game(ctx: HttpContext) -> None:
values = extract_query_params(ctx.query_string).get("code")
code = values[0].strip() if values else ""
if not code:
await send_error(ctx, 400, "code is required")
return
session = await platform.game_store.find_by_code(code)
if session is None:
await send_error(ctx, 404, "unknown join code")
return
await send_json(ctx, 200, _preview_payload(platform, session))
@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))
@@ -1,23 +0,0 @@
"""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",)},
)
@@ -1,30 +0,0 @@
"""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)})
@@ -1,191 +0,0 @@
"""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
]
})
@@ -1,110 +0,0 @@
"""Copy finished match results from the live store into Postgres.
Called once when a session reaches the finished state (guarded by the
``stats_saved`` flag on the session). The write is transactional so a
match never appears with only some of its players. The same transaction
also updates the participants' Elo ratings (see
:mod:`tavolo.platform.elo`).
This module is game-independent: everything it persists comes from the
engine's :class:`~tavolo.platform.engine.MatchResult`.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from logging import getLogger
from typing import Dict, List
from tortoise.transactions import in_transaction
from .elo import match_delta
from .engine import GameEngine, GameSession
log = getLogger(__name__)
async def apply_elo(
game_type: str, teams: List[List[str]], winner_team: int
) -> Dict[str, int]:
"""Update the Elo ratings of ``teams`` for ``game_type``.
``teams`` groups the participants' subs into the two opposing sides;
``winner_team`` is the index of the winning side. Ratings are read
from (and written back to) the ``player_rating`` table; unrated
players start at the initial rating. Returns the per-player delta.
Must be called inside a transaction.
"""
if len(teams) != 2:
raise ValueError("Elo rating requires exactly two teams")
from .models import PlayerRating
ratings: Dict[str, "PlayerRating"] = {}
for subs in teams:
for sub in subs:
rating = await PlayerRating.get_or_none(
user_sub=sub, game_type=game_type
)
if rating is None:
rating = await PlayerRating.create(
id=uuid.uuid4(), user_sub=sub, game_type=game_type
)
ratings[sub] = rating
delta_a = match_delta(
[ratings[sub].rating for sub in teams[0]],
[ratings[sub].rating for sub in teams[1]],
winner_team,
)
deltas: Dict[str, int] = {
**{sub: delta_a for sub in teams[0]},
**{sub: -delta_a for sub in teams[1]},
}
for sub, delta in deltas.items():
rating = ratings[sub]
rating.rating += delta
rating.matches_played += 1
await rating.save()
return deltas
async def save_match_result(session: GameSession, engine: GameEngine) -> None:
"""Persist ``session`` to Postgres if it is finished and not yet saved."""
if session.stats_saved or not engine.is_finished(session):
return
result = engine.result(session)
from .models import Match, MatchPlayer
started_at = session.created_at or datetime.now(timezone.utc)
finished_at = session.finished_at or datetime.now(timezone.utc)
display_names = {seat.user_sub: seat.display_name for seat in session.players}
async with in_transaction():
match = await Match.create(
id=uuid.uuid4(),
game_type=session.game_type,
started_at=started_at,
finished_at=finished_at,
result=result.summary,
)
deltas = await apply_elo(session.game_type, result.teams, result.winner_team)
for player in result.players:
await MatchPlayer.create(
id=uuid.uuid4(),
match=match,
user_sub=player.user_sub,
display_name=display_names.get(player.user_sub, player.user_sub),
seat=player.seat,
team=player.team,
won=player.won,
score=player.score,
elo_delta=deltas[player.user_sub],
details=player.details,
)
session.stats_saved = True
log.info(
"match result persisted: game %s (%s), team %d of %d won",
session.id,
session.game_type,
result.winner_team,
len(result.teams),
)
@@ -1,313 +0,0 @@
"""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
@@ -1,157 +0,0 @@
"""A :class:`~kaya.core.KayaMixin` that drives the TortoiseORM lifecycle.
kaya calls ``KayaMixin.setup`` / ``shutdown`` synchronously from inside a
running event loop. Tortoise 1.1.7 binds database connections to a
:class:`~tortoise.context.TortoiseContext` looked up via a contextvar, and
kaya dispatches each HTTP request (and WebSocket connection) as a separate
``loop.create_task``, so a context set by an early request does not
automatically reach later requests.
This mixin therefore:
1. Lazily builds a :class:`TortoiseContext` for the active event loop
(rebuilding it if the running loop changes, which happens in tests that
use a fresh ``asyncio.run`` per test).
2. Per HTTP request, binds that context to the current task via the
``_current_context`` contextvar so the handler — running in the same
task as the ``before_request`` hook — sees an active context.
3. Binds the same context at the start of every WebSocket connection
(``before_websocket`` hook), because the match-result write happens at
the end of a WebSocket match. The long-lived connection task keeps the
context for its whole lifetime.
4. Exposes :meth:`ensure_context` for background work that belongs to no
request: the deadline scheduler calls it before persisting a match that
a timeout finished, binding the context to its own long-lived task.
It deliberately avoids the global-fallback singleton
(``_enable_global_fallback``), which Tortoise only allows to be set once
per process and would therefore break across event loops.
Schema management is split by backend: in-memory sqlite databases (the
test suite) get ``generate_schemas`` on every fresh context; Postgres
schemas are owned by aerich migrations and must be applied externally
(``aerich upgrade``, run by the ``db-migrate`` compose service) before
the app serves requests.
"""
from __future__ import annotations
from asyncio import AbstractEventLoop, get_running_loop
from logging import getLogger
from typing import AbstractSet, Optional, Sequence
from urllib.parse import urlsplit, urlunsplit
from kaya.core import HttpContext, KayaApp, KayaMixin, WebSocket
from tortoise.context import TortoiseContext, _current_context
log = getLogger(__name__)
def _safe_url(database_url: str) -> str:
"""The database URL with any credentials stripped, for logging."""
parts = urlsplit(database_url)
host = parts.hostname or ""
try:
if parts.port:
host = f"{host}:{parts.port}"
except ValueError:
# Non-numeric netloc (e.g. sqlite://:memory:): keep the host only.
pass
return urlunsplit((parts.scheme, host, parts.path, "", ""))
class TortoiseMixin(KayaMixin):
"""Initialize and tear down a per-loop :class:`TortoiseContext`."""
def __init__(self,
database_url: str,
models_modules: Sequence[str],
skip_paths: AbstractSet[str] = frozenset({"/api/health"})) -> None:
self._database_url = database_url
self._models_modules = list(models_modules)
self._skip_paths = skip_paths
self._ctx: "Optional[TortoiseContext]" = None
self._init_loop: "Optional[AbstractEventLoop]" = None
def apply(self, app: KayaApp) -> None:
app.add_before_request_hook(self._ensure_context)
app.add_before_websocket_hook(self._ensure_ws_context)
def setup(self, loop: AbstractEventLoop) -> None:
pass
def shutdown(self, loop: AbstractEventLoop) -> None:
if self._init_loop is loop and self._ctx is not None:
log.info("closing database connections")
loop.create_task(self._ctx.close_connections())
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:
await ctx.init(
db_url=self._database_url,
modules={"models": self._models_modules},
)
log.info("database context initialized (%s)", _safe_url(self._database_url))
# Schema creation is only done for sqlite (in-memory test
# databases). Postgres schemas are managed by aerich migrations
# (applied by the db-migrate compose service / `aerich upgrade`).
if self._database_url.startswith("sqlite"):
await ctx.generate_schemas()
log.info("sqlite schemas generated")
return ctx
async def ensure_context(self) -> None:
"""Bind this mixin's context to the calling task.
Background work with no request of its own (the deadline
scheduler's finished-match write) calls this before touching the
database. It is cheap when the context is already built for the
running loop and idempotent for callers that already have it
bound (HTTP requests, WebSocket connections).
"""
await self._bind()
async def _bind(self) -> None:
loop = get_running_loop()
if self._init_loop is not loop:
if self._ctx is not None:
# A previous test loop went away; drop its context.
self._ctx = None
log.debug("building a Tortoise context for a new event loop")
self._ctx = await self._build_context()
self._init_loop = loop
assert self._ctx is not None
_current_context.set(self._ctx)
async def _ensure_context(self, ctx: HttpContext):
if ctx.path in self._skip_paths:
return None
# Only API endpoints touch the database: auth callbacks, websocket
# handshakes (handled by _ensure_ws_context) and the static SPA
# catch-all must not pay for a Tortoise context.
if not ctx.path.startswith("/api/"):
return None
await self._bind()
return None
async def _ensure_ws_context(self, ws: WebSocket):
await self._bind()
return None
@@ -1,210 +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.
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)
@@ -1,353 +0,0 @@
"""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,
context_binder=tortoise_mixin.ensure_context,
)
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()
@@ -1,218 +0,0 @@
"""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_test
async def test_tick_finishing_match_persists_result_with_no_connections(self) -> None:
# A deadline that ends the match must persist the result from the
# consumer task, which has no request context of its own.
from tavolo.platform.models import Match
_, platform, tortoise_mixin = make_platform()
store = platform.game_store
scheduler = platform.scheduler
session = _started_session(target=1, deadline_in_seconds=0.05)
await store.save(session)
await scheduler.sync_deadline(session)
# Nobody ever connects: the consumer must finish the match and
# write it to Postgres.
result = await _wait_for(lambda: _finished(store, session.id))
self.assertIsNotNone(result, "deadline never finished the match")
ctx = await use_db(tortoise_mixin)
with ctx:
self.assertEqual(1, await Match.all().count())
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
async def _finished(store, game_id: str) -> Optional[GameSession]:
session = await store.load(game_id)
if session is not None and session.state["finished"]:
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.
# No context is bound in this task: the scheduler must bind its
# own, exactly like its consumer task at app startup.
from tavolo.platform.models import Match
_, platform, tortoise_mixin = make_platform()
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,
})
await scheduler.process_due(member)
ctx = await use_db(tortoise_mixin)
with ctx:
self.assertEqual(1, await Match.all().count())
if __name__ == "__main__":
unittest.main()
@@ -1,73 +0,0 @@
"""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()

Some files were not shown because too many files have changed in this diff Show More