From 7b3b38ccbcebc4387f1ccef0ec52f766961df9a5 Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Fri, 2 Oct 2026 13:54:55 +0000 Subject: [PATCH] Rewrite the backend in Rust with Axum 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. --- .dockerignore | 2 +- README.md | 26 +- deploy/k8s/tavolo.yaml | 23 +- docker-compose.yml | 18 +- server/.env.example | 20 +- server/Cargo.lock | 3534 +++++++++++++++++ server/Cargo.toml | 45 + server/Dockerfile | 113 +- server/README.md | 434 +- server/conftest.py | 17 - server/crates/tavolo-app/Cargo.toml | 63 + .../tavolo-app/migrations/0001_init.sql | 41 + server/crates/tavolo-app/src/app.rs | 191 + server/crates/tavolo-app/src/config.rs | 308 ++ server/crates/tavolo-app/src/cors.rs | 354 ++ server/crates/tavolo-app/src/lib.rs | 6 + server/crates/tavolo-app/src/logging.rs | 58 + server/crates/tavolo-app/src/main.rs | 160 + server/crates/tavolo-app/src/static_files.rs | 74 + server/crates/tavolo-app/src/telemetry.rs | 389 ++ server/crates/tavolo-app/tests/auth_flow.rs | 319 ++ server/crates/tavolo-app/tests/config_cors.rs | 380 ++ server/crates/tavolo-app/tests/scopone_e2e.rs | 320 ++ server/crates/tavolo-platform/Cargo.toml | 36 + server/crates/tavolo-platform/src/auth.rs | 79 + server/crates/tavolo-platform/src/datetime.rs | 64 + .../crates/tavolo-platform/src/deadlines.rs | 250 ++ server/crates/tavolo-platform/src/elo.rs | 65 + server/crates/tavolo-platform/src/engine.rs | 335 ++ server/crates/tavolo-platform/src/errors.rs | 101 + server/crates/tavolo-platform/src/http.rs | 138 + server/crates/tavolo-platform/src/lib.rs | 34 + server/crates/tavolo-platform/src/models.rs | 43 + server/crates/tavolo-platform/src/oidc.rs | 551 +++ .../crates/tavolo-platform/src/pagination.rs | 122 + server/crates/tavolo-platform/src/platform.rs | 22 + server/crates/tavolo-platform/src/registry.rs | 64 + .../crates/tavolo-platform/src/routes/auth.rs | 175 + .../tavolo-platform/src/routes/games.rs | 421 ++ .../tavolo-platform/src/routes/health.rs | 20 + .../crates/tavolo-platform/src/routes/me.rs | 32 + .../crates/tavolo-platform/src/routes/mod.rs | 33 + .../tavolo-platform/src/routes/openapi.rs | 314 ++ .../tavolo-platform/src/routes/stats.rs | 165 + server/crates/tavolo-platform/src/session.rs | 531 +++ server/crates/tavolo-platform/src/stats.rs | 825 ++++ server/crates/tavolo-platform/src/store.rs | 588 +++ server/crates/tavolo-platform/src/ws.rs | 324 ++ .../tavolo-platform/tests/helpers/mod.rs | 469 +++ .../crates/tavolo-platform/tests/http_ws.rs | 410 ++ .../tavolo-platform/tests/postgres_stats.rs | 160 + .../tavolo-platform/tests/redis_store.rs | 138 + .../tests/store_deadlines_stats.rs | 317 ++ server/crates/tavolo-scopone/Cargo.toml | 18 + server/crates/tavolo-scopone/src/engine.rs | 791 ++++ server/crates/tavolo-scopone/src/errors.rs | 15 + server/crates/tavolo-scopone/src/lib.rs | 19 + server/crates/tavolo-scopone/src/plugin.rs | 428 ++ server/crates/tavolo-scopone/src/state.rs | 388 ++ server/crates/tavolo-scopone/tests/engine.rs | 1310 ++++++ server/crates/tavolo-scopone/tests/plugin.rs | 483 +++ .../models/0_20260915224544_init.py | 67 - .../models/1_20260916235833_update.py | 42 - .../migrations/models/2_20260918120620_elo.py | 55 - .../3_20260919065917_generic_results.py | 73 - server/packages/tavolo-app/README.md | 38 - server/packages/tavolo-app/pyproject.toml | 50 - .../tavolo-app/src/tavolo/aerich_config.py | 23 - server/packages/tavolo-app/src/tavolo/app.py | 247 -- .../packages/tavolo-app/src/tavolo/config.py | 315 -- .../tavolo-app/src/tavolo/logging_config.py | 83 - .../packages/tavolo-app/src/tavolo/static.py | 46 - .../tavolo-app/src/tavolo/telemetry.py | 375 -- server/packages/tavolo-platform/README.md | 64 - .../packages/tavolo-platform/pyproject.toml | 38 - .../src/tavolo/platform/__init__.py | 53 - .../src/tavolo/platform/auth.py | 88 - .../src/tavolo/platform/backfill_elo.py | 81 - .../src/tavolo/platform/deadlines.py | 310 -- .../src/tavolo/platform/elo.py | 49 - .../src/tavolo/platform/engine.py | 253 -- .../src/tavolo/platform/errors.py | 43 - .../src/tavolo/platform/http.py | 73 - .../src/tavolo/platform/mixin.py | 51 - .../src/tavolo/platform/models.py | 86 - .../src/tavolo/platform/openapi.py | 21 - .../src/tavolo/platform/pagination.py | 154 - .../src/tavolo/platform/py.typed | 0 .../src/tavolo/platform/registry.py | 60 - .../src/tavolo/platform/routes/__init__.py | 1 - .../src/tavolo/platform/routes/auth.py | 62 - .../src/tavolo/platform/routes/games.py | 333 -- .../src/tavolo/platform/routes/health.py | 23 - .../src/tavolo/platform/routes/me.py | 30 - .../src/tavolo/platform/routes/stats.py | 208 - .../src/tavolo/platform/stats.py | 110 - .../src/tavolo/platform/store.py | 403 -- .../src/tavolo/platform/tortoise_mixin.py | 157 - .../tavolo-platform/src/tavolo/platform/ws.py | 231 -- .../packages/tavolo-platform/tests/helpers.py | 361 -- .../tavolo-platform/tests/test_auth.py | 152 - .../tavolo-platform/tests/test_deadlines.py | 250 -- .../tavolo-platform/tests/test_elo.py | 73 - .../tavolo-platform/tests/test_pagination.py | 81 - .../tavolo-platform/tests/test_routes.py | 254 -- .../tavolo-platform/tests/test_routes_auth.py | 67 - .../tavolo-platform/tests/test_stats.py | 415 -- .../tavolo-platform/tests/test_store.py | 334 -- .../packages/tavolo-platform/tests/test_ws.py | 306 -- server/packages/tavolo-scopone/README.md | 38 - server/packages/tavolo-scopone/pyproject.toml | 22 - .../src/tavolo/scopone/__init__.py | 13 - .../src/tavolo/scopone/engine.py | 587 --- .../src/tavolo/scopone/errors.py | 34 - .../src/tavolo/scopone/plugin.py | 277 -- .../src/tavolo/scopone/py.typed | 0 .../src/tavolo/scopone/state.py | 249 -- .../tavolo-scopone/tests/test_engine.py | 750 ---- .../tavolo-scopone/tests/test_plugin.py | 299 -- server/pyproject.toml | 16 - server/requirements.txt | 181 - server/tests/__init__.py | 18 - server/tests/helpers/__init__.py | 5 - server/tests/helpers/asynctest.py | 35 - server/tests/helpers/oidc.py | 60 - server/tests/test_config.py | 317 -- server/tests/test_cors.py | 129 - server/tests/test_deadlines.py | 242 -- server/tests/test_logging_config.py | 75 - server/tests/test_otel.py | 116 - server/tests/test_routes_auth.py | 35 - server/tests/test_routes_games.py | 278 -- server/tests/test_routes_me.py | 69 - server/tests/test_stats.py | 140 - server/tests/test_telemetry.py | 427 -- server/tests/test_websocket.py | 324 -- 136 files changed, 16821 insertions(+), 11777 deletions(-) create mode 100644 server/Cargo.lock create mode 100644 server/Cargo.toml delete mode 100644 server/conftest.py create mode 100644 server/crates/tavolo-app/Cargo.toml create mode 100644 server/crates/tavolo-app/migrations/0001_init.sql create mode 100644 server/crates/tavolo-app/src/app.rs create mode 100644 server/crates/tavolo-app/src/config.rs create mode 100644 server/crates/tavolo-app/src/cors.rs create mode 100644 server/crates/tavolo-app/src/lib.rs create mode 100644 server/crates/tavolo-app/src/logging.rs create mode 100644 server/crates/tavolo-app/src/main.rs create mode 100644 server/crates/tavolo-app/src/static_files.rs create mode 100644 server/crates/tavolo-app/src/telemetry.rs create mode 100644 server/crates/tavolo-app/tests/auth_flow.rs create mode 100644 server/crates/tavolo-app/tests/config_cors.rs create mode 100644 server/crates/tavolo-app/tests/scopone_e2e.rs create mode 100644 server/crates/tavolo-platform/Cargo.toml create mode 100644 server/crates/tavolo-platform/src/auth.rs create mode 100644 server/crates/tavolo-platform/src/datetime.rs create mode 100644 server/crates/tavolo-platform/src/deadlines.rs create mode 100644 server/crates/tavolo-platform/src/elo.rs create mode 100644 server/crates/tavolo-platform/src/engine.rs create mode 100644 server/crates/tavolo-platform/src/errors.rs create mode 100644 server/crates/tavolo-platform/src/http.rs create mode 100644 server/crates/tavolo-platform/src/lib.rs create mode 100644 server/crates/tavolo-platform/src/models.rs create mode 100644 server/crates/tavolo-platform/src/oidc.rs create mode 100644 server/crates/tavolo-platform/src/pagination.rs create mode 100644 server/crates/tavolo-platform/src/platform.rs create mode 100644 server/crates/tavolo-platform/src/registry.rs create mode 100644 server/crates/tavolo-platform/src/routes/auth.rs create mode 100644 server/crates/tavolo-platform/src/routes/games.rs create mode 100644 server/crates/tavolo-platform/src/routes/health.rs create mode 100644 server/crates/tavolo-platform/src/routes/me.rs create mode 100644 server/crates/tavolo-platform/src/routes/mod.rs create mode 100644 server/crates/tavolo-platform/src/routes/openapi.rs create mode 100644 server/crates/tavolo-platform/src/routes/stats.rs create mode 100644 server/crates/tavolo-platform/src/session.rs create mode 100644 server/crates/tavolo-platform/src/stats.rs create mode 100644 server/crates/tavolo-platform/src/store.rs create mode 100644 server/crates/tavolo-platform/src/ws.rs create mode 100644 server/crates/tavolo-platform/tests/helpers/mod.rs create mode 100644 server/crates/tavolo-platform/tests/http_ws.rs create mode 100644 server/crates/tavolo-platform/tests/postgres_stats.rs create mode 100644 server/crates/tavolo-platform/tests/redis_store.rs create mode 100644 server/crates/tavolo-platform/tests/store_deadlines_stats.rs create mode 100644 server/crates/tavolo-scopone/Cargo.toml create mode 100644 server/crates/tavolo-scopone/src/engine.rs create mode 100644 server/crates/tavolo-scopone/src/errors.rs create mode 100644 server/crates/tavolo-scopone/src/lib.rs create mode 100644 server/crates/tavolo-scopone/src/plugin.rs create mode 100644 server/crates/tavolo-scopone/src/state.rs create mode 100644 server/crates/tavolo-scopone/tests/engine.rs create mode 100644 server/crates/tavolo-scopone/tests/plugin.rs delete mode 100644 server/migrations/models/0_20260915224544_init.py delete mode 100644 server/migrations/models/1_20260916235833_update.py delete mode 100644 server/migrations/models/2_20260918120620_elo.py delete mode 100644 server/migrations/models/3_20260919065917_generic_results.py delete mode 100644 server/packages/tavolo-app/README.md delete mode 100644 server/packages/tavolo-app/pyproject.toml delete mode 100644 server/packages/tavolo-app/src/tavolo/aerich_config.py delete mode 100644 server/packages/tavolo-app/src/tavolo/app.py delete mode 100644 server/packages/tavolo-app/src/tavolo/config.py delete mode 100644 server/packages/tavolo-app/src/tavolo/logging_config.py delete mode 100644 server/packages/tavolo-app/src/tavolo/static.py delete mode 100644 server/packages/tavolo-app/src/tavolo/telemetry.py delete mode 100644 server/packages/tavolo-platform/README.md delete mode 100644 server/packages/tavolo-platform/pyproject.toml delete mode 100644 server/packages/tavolo-platform/src/tavolo/platform/__init__.py delete mode 100644 server/packages/tavolo-platform/src/tavolo/platform/auth.py delete mode 100644 server/packages/tavolo-platform/src/tavolo/platform/backfill_elo.py delete mode 100644 server/packages/tavolo-platform/src/tavolo/platform/deadlines.py delete mode 100644 server/packages/tavolo-platform/src/tavolo/platform/elo.py delete mode 100644 server/packages/tavolo-platform/src/tavolo/platform/engine.py delete mode 100644 server/packages/tavolo-platform/src/tavolo/platform/errors.py delete mode 100644 server/packages/tavolo-platform/src/tavolo/platform/http.py delete mode 100644 server/packages/tavolo-platform/src/tavolo/platform/mixin.py delete mode 100644 server/packages/tavolo-platform/src/tavolo/platform/models.py delete mode 100644 server/packages/tavolo-platform/src/tavolo/platform/openapi.py delete mode 100644 server/packages/tavolo-platform/src/tavolo/platform/pagination.py delete mode 100644 server/packages/tavolo-platform/src/tavolo/platform/py.typed delete mode 100644 server/packages/tavolo-platform/src/tavolo/platform/registry.py delete mode 100644 server/packages/tavolo-platform/src/tavolo/platform/routes/__init__.py delete mode 100644 server/packages/tavolo-platform/src/tavolo/platform/routes/auth.py delete mode 100644 server/packages/tavolo-platform/src/tavolo/platform/routes/games.py delete mode 100644 server/packages/tavolo-platform/src/tavolo/platform/routes/health.py delete mode 100644 server/packages/tavolo-platform/src/tavolo/platform/routes/me.py delete mode 100644 server/packages/tavolo-platform/src/tavolo/platform/routes/stats.py delete mode 100644 server/packages/tavolo-platform/src/tavolo/platform/stats.py delete mode 100644 server/packages/tavolo-platform/src/tavolo/platform/store.py delete mode 100644 server/packages/tavolo-platform/src/tavolo/platform/tortoise_mixin.py delete mode 100644 server/packages/tavolo-platform/src/tavolo/platform/ws.py delete mode 100644 server/packages/tavolo-platform/tests/helpers.py delete mode 100644 server/packages/tavolo-platform/tests/test_auth.py delete mode 100644 server/packages/tavolo-platform/tests/test_deadlines.py delete mode 100644 server/packages/tavolo-platform/tests/test_elo.py delete mode 100644 server/packages/tavolo-platform/tests/test_pagination.py delete mode 100644 server/packages/tavolo-platform/tests/test_routes.py delete mode 100644 server/packages/tavolo-platform/tests/test_routes_auth.py delete mode 100644 server/packages/tavolo-platform/tests/test_stats.py delete mode 100644 server/packages/tavolo-platform/tests/test_store.py delete mode 100644 server/packages/tavolo-platform/tests/test_ws.py delete mode 100644 server/packages/tavolo-scopone/README.md delete mode 100644 server/packages/tavolo-scopone/pyproject.toml delete mode 100644 server/packages/tavolo-scopone/src/tavolo/scopone/__init__.py delete mode 100644 server/packages/tavolo-scopone/src/tavolo/scopone/engine.py delete mode 100644 server/packages/tavolo-scopone/src/tavolo/scopone/errors.py delete mode 100644 server/packages/tavolo-scopone/src/tavolo/scopone/plugin.py delete mode 100644 server/packages/tavolo-scopone/src/tavolo/scopone/py.typed delete mode 100644 server/packages/tavolo-scopone/src/tavolo/scopone/state.py delete mode 100644 server/packages/tavolo-scopone/tests/test_engine.py delete mode 100644 server/packages/tavolo-scopone/tests/test_plugin.py delete mode 100644 server/pyproject.toml delete mode 100644 server/requirements.txt delete mode 100644 server/tests/__init__.py delete mode 100644 server/tests/helpers/__init__.py delete mode 100644 server/tests/helpers/asynctest.py delete mode 100644 server/tests/helpers/oidc.py delete mode 100644 server/tests/test_config.py delete mode 100644 server/tests/test_cors.py delete mode 100644 server/tests/test_deadlines.py delete mode 100644 server/tests/test_logging_config.py delete mode 100644 server/tests/test_otel.py delete mode 100644 server/tests/test_routes_auth.py delete mode 100644 server/tests/test_routes_games.py delete mode 100644 server/tests/test_routes_me.py delete mode 100644 server/tests/test_stats.py delete mode 100644 server/tests/test_telemetry.py delete mode 100644 server/tests/test_websocket.py diff --git a/.dockerignore b/.dockerignore index bac31ec..b634d88 100644 --- a/.dockerignore +++ b/.dockerignore @@ -14,6 +14,6 @@ .pytest_cache/ web/target/ web/dist/ -server/tests/ +server/target/ docker-compose.yml opencode.json diff --git a/README.md b/README.md index 1f5a2fa..95b9867 100644 --- a/README.md +++ b/README.md @@ -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 @@ -84,7 +86,7 @@ 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/ \ - .venv/bin/granian --host 127.0.0.1 --port 8080 tavolo.app:app + cargo run -p tavolo-app ``` Card images are committed under `web/assets/cards/`; `web/fetch-cards.sh` diff --git a/deploy/k8s/tavolo.yaml b/deploy/k8s/tavolo.yaml index ba90b37..fa07cd3 100644 --- a/deploy/k8s/tavolo.yaml +++ b/deploy/k8s/tavolo.yaml @@ -13,9 +13,10 @@ # # 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, @@ -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,9 +73,9 @@ data: # CORS_ALLOW_CREDENTIALS: "false" # CORS_EXPOSE_HEADERS: "" # CORS_MAX_AGE: "600" - # OpenTelemetry (kaya-otel + contrib instrumentations): traces - # (HTTP/WebSocket, Postgres, Redis), metrics and logs 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" @@ -200,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. @@ -233,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: diff --git a/docker-compose.yml b/docker-compose.yml index bd58828..6997f45 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 @@ -124,11 +122,9 @@ services: CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-} CORS_EXPOSE_HEADERS: ${CORS_EXPOSE_HEADERS:-} CORS_MAX_AGE: ${CORS_MAX_AGE:-} - # OpenTelemetry (kaya-otel + contrib instrumentations): traces - # (HTTP/WebSocket, Postgres, Redis), metrics and logs via OTLP/HTTP, - # 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:-} diff --git a/server/.env.example b/server/.env.example index 4301628..7f0747d 100644 --- a/server/.env.example +++ b/server/.env.example @@ -9,8 +9,8 @@ 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 providers. Each provider is an indexed block: OIDC__PROVIDER @@ -52,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 @@ -78,12 +79,11 @@ TURN_TIMEOUT_SECONDS=30 # Seconds browsers may cache the preflight response (default 600): #CORS_MAX_AGE=600 -# OpenTelemetry (via kaya-otel + the contrib instrumentation packages). -# Off unless OTEL_ENABLED is truthy; exports traces (HTTP/WebSocket, -# Postgres via asyncpg, Redis), metrics and logs over OTLP/HTTP (requires -# the tavolo-app "otel" extra, bundled in the Docker image). The endpoint -# defaults to the standard OTEL_EXPORTER_OTLP_ENDPOINT (localhost:4318) -# when unset. +# 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 diff --git a/server/Cargo.lock b/server/Cargo.lock new file mode 100644 index 0000000..4c882b9 --- /dev/null +++ b/server/Cargo.lock @@ -0,0 +1,3534 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "base64 0.22.1", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite 0.29.0", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.9", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.9.4", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-open-directory" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d" +dependencies = [ + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opentelemetry" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cdb0b1b267eb9db3331b434ed9ddab10d50e280a9adf9d13e5233e2002b61b5" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror 2.0.21", +] + +[[package]] +name = "opentelemetry-appender-tracing" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2b304e8d8f11326ba366a99ca5c7aaf7cd397b67268a332437175aeb42a40db" +dependencies = [ + "opentelemetry", + "tracing", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "opentelemetry-http" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee2c3625b8aa04209f7e01e513bc8044da9687fa38a9eacf59628ca5f3b87300" +dependencies = [ + "async-trait", + "bytes", + "http", + "opentelemetry", + "reqwest 0.13.5", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "699a67345e21962a231955b9059a157e428b219fa5df8efe11f08346fb23a344" +dependencies = [ + "http", + "httpdate", + "opentelemetry", + "opentelemetry-http", + "opentelemetry-proto", + "opentelemetry_sdk", + "prost", + "reqwest 0.13.5", + "thiserror 2.0.21", + "tokio", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25da1ac11a0aeccf38d7f77ee0348715adaf8340f65ad46c94a02c6b20e2f65d" +dependencies = [ + "opentelemetry", + "opentelemetry_sdk", + "prost", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb39533d9d1c912123efd7d41d7e0c29d16917b60ce15b4c8d87cb1af7f67520" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "portable-atomic", + "rand 0.9.5", + "thiserror 2.0.21", + "tokio", + "tokio-stream", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redis" +version = "0.32.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "014cc767fefab6a3e798ca45112bccad9c6e0e218fbd49720042716c73cfef44" +dependencies = [ + "arc-swap", + "backon", + "bytes", + "cfg-if", + "combine", + "futures-channel", + "futures-util", + "itoa", + "num-bigint", + "percent-encoding", + "pin-project-lite", + "ryu", + "sha1_smol", + "socket2", + "tokio", + "tokio-util", + "url", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.9", +] + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.21", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.21", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.8", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.21", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.8", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.21", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.21", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "sysinfo" +version = "0.39.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "objc2-open-directory", + "windows", +] + +[[package]] +name = "tavolo-app" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "chrono", + "clap", + "futures-util", + "hex", + "http-body-util", + "jsonwebtoken", + "opentelemetry", + "opentelemetry-appender-tracing", + "opentelemetry-otlp", + "opentelemetry_sdk", + "percent-encoding", + "rand 0.8.8", + "redis", + "regex", + "reqwest 0.12.28", + "serde", + "serde_json", + "sha2", + "sqlx", + "sysinfo", + "tavolo-platform", + "tavolo-scopone", + "thiserror 2.0.21", + "tokio", + "tokio-tungstenite 0.24.0", + "tower", + "tower-http", + "tracing", + "tracing-opentelemetry", + "tracing-subscriber", + "url", + "uuid", +] + +[[package]] +name = "tavolo-platform" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "chrono", + "futures-util", + "http-body-util", + "jsonwebtoken", + "rand 0.8.8", + "redis", + "regex", + "reqwest 0.12.28", + "serde", + "serde_json", + "sha2", + "sqlx", + "thiserror 2.0.21", + "tokio", + "tokio-tungstenite 0.24.0", + "tower", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "tavolo-scopone" +version = "0.1.0" +dependencies = [ + "chrono", + "rand 0.8.8", + "serde", + "serde_json", + "tavolo-platform", + "thiserror 2.0.21", + "tokio", + "tracing", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.24.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.29.0", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-opentelemetry" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a904802a1b902f43638b677ff2a650847e3b4404101b6c586d648e8c1e3e8fe" +dependencies = [ + "js-sys", + "opentelemetry", + "tracing", + "tracing-core", + "tracing-subscriber", + "web-time", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.8", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1", + "thiserror 2.0.21", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 0000000..dd0213d --- /dev/null +++ b/server/Cargo.toml @@ -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 diff --git a/server/Dockerfile b/server/Dockerfile index 8522511..90f4281 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -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"] diff --git a/server/README.md b/server/README.md index dec9e6f..b7c44b0 100644 --- a/server/README.md +++ b/server/README.md @@ -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: @@ -57,14 +56,14 @@ 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__PROVIDER` | `default` ¹ | Provider name (required when OIDC is configured): used in the login URL `/auth//login` and to namespace its session keys. Unique, `[A-Za-z0-9_-]+` | | `OIDC__ISSUER` | `http://localhost:8180/tavolo` ¹ | OIDC issuer URL | @@ -77,7 +76,8 @@ All configuration comes from environment variables (see `.env.example`): | `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` | `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 | Path to a YAML logging configuration file (see below). Unset logs DEBUG to the console | +| `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 | @@ -85,7 +85,7 @@ All configuration comes from environment variables (see `.env.example`): | `CORS_ALLOW_CREDENTIALS` | `false` | `1`/`true`/`yes`/`on` allow cookies/credentials on cross-origin requests | | `CORS_EXPOSE_HEADERS` | unset | Comma-separated response headers exposed to the browser | | `CORS_MAX_AGE` | `600` | Seconds browsers may cache the preflight response | -| `OTEL_ENABLED` | `false` | `1`/`true`/`yes`/`on` enable OpenTelemetry traces, metrics and logs (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) | @@ -93,6 +93,7 @@ All configuration comes from environment variables (see `.env.example`): | `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__*` variable is set at all, a single provider named `default` pointing at the dev mock IdP is used. Indices must be contiguous @@ -100,9 +101,8 @@ from 0 (`OIDC_0_*`, `OIDC_1_*`, ...). ### Multiple OIDC providers -Providers are configured in indexed blocks; each becomes its own login -route `/auth//login` with its own callback URL and session -namespace: +Providers are configured in indexed blocks; each becomes its own login route +`/auth//login` with its own callback URL and session namespace: ```sh OIDC_0_PROVIDER=google @@ -120,83 +120,46 @@ 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. Each provider's `sub` is a separate account, -so match history and Elo ratings are per provider identity (someone using -two providers has two accounts). Migrating from the pre-0.0.6 unindexed -variables: rename `OIDC_ISSUER`/`OIDC_CLIENT_ID`/... to `OIDC_0_...`, add -`OIDC_0_PROVIDER`, and register the new callback path -(`/auth//callback`) at the provider. Sessions survive only when -the provider is named `default`; otherwise users log in once more. +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): - -```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 -``` +Lifecycle and business events are logged at INFO (game created/joined, +websocket connections, match results, auto-plays); per-move and store detail +at DEBUG. ## OpenTelemetry -With `OTEL_ENABLED` set, the app exports over OTLP/HTTP (kaya-otel plus -`tavolo.telemetry`): +With `OTEL_ENABLED` set, the app exports over OTLP/HTTP: -- **Traces**: HTTP server spans, one span per WebSocket connection, plus - database spans for the Postgres (asyncpg, through Tortoise) and Redis - clients. Redis command values are redacted by the instrumentation - (`GET ? ?`) and asyncpg spans carry the parameterized SQL only — no - parameter values. Each HTTP/WebSocket span is tagged with the request's - session and user, so all traces of a session can be found together: - `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. -- **Metrics**: the HTTP server metrics from kaya-otel, plus +- **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, maintained by the - game store), and process/CPython metrics from - `opentelemetry-instrumentation-system-metrics`: `process.cpu.time`, - `process.cpu.utilization`, `process.memory.usage`, - `process.memory.virtual`, `cpython.gc.collections`, - `cpython.gc.collected_objects` and `cpython.gc.uncollectable_objects`. -- **Logs**: forwarded from the root logger at the configured console level - (OpenTelemetry's own records are filtered out to avoid export loops). + `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). Note that with -multiple workers or replicas each process reports the same global counts: -aggregate with `max`, not `sum`. +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 @@ -204,59 +167,64 @@ aggregate with `max`, not `sum`. - `tavolo:game:` — the whole session as JSON: the platform-owned envelope (`id`, `game_type`, `join_code`, seats, timestamps) plus the - game-specific `state` blob, which only the registered engine - interprets. Sliding TTL (`GAME_TTL_SECONDS`). + 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:` — the 6-character join code → game id index. - `tavolo:game::lock` — a short-lived lock serializing every mutation. - `tavolo:game::events` — a pub/sub channel carrying "state changed" signals; every open WebSocket reloads the 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:` 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 @@ -278,24 +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; 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.) +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"]} @@ -309,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. @@ -319,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 @@ -364,84 +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 - ├── telemetry.py # OTLP metric/log providers, app gauges, count poller - ├── 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`. | diff --git a/server/conftest.py b/server/conftest.py deleted file mode 100644 index c72207a..0000000 --- a/server/conftest.py +++ /dev/null @@ -1,17 +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_0_PROVIDER", "default") -os.environ.setdefault("OIDC_0_ISSUER", "http://localhost:8180/tavolo") -os.environ.setdefault("OIDC_0_CLIENT_ID", "tavolo") -os.environ.setdefault("OIDC_0_REDIRECT_URI", "http://localhost:8080/auth/default/callback") -os.environ.pop("REDIS_URL", None) diff --git a/server/crates/tavolo-app/Cargo.toml b/server/crates/tavolo-app/Cargo.toml new file mode 100644 index 0000000..9d5d4d1 --- /dev/null +++ b/server/crates/tavolo-app/Cargo.toml @@ -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", +] diff --git a/server/crates/tavolo-app/migrations/0001_init.sql b/server/crates/tavolo-app/migrations/0001_init.sql new file mode 100644 index 0000000..554f8ce --- /dev/null +++ b/server/crates/tavolo-app/migrations/0001_init.sql @@ -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"); diff --git a/server/crates/tavolo-app/src/app.rs b/server/crates/tavolo-app/src/app.rs new file mode 100644 index 0000000..3b2abf7 --- /dev/null +++ b/server/crates/tavolo-app/src/app.rs @@ -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, 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, +) -> 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, + game_store: SharedGameStore, + stats: SharedStatsRepo, +) -> Result { + 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 = 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 { + 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 { + 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, + }) +} diff --git a/server/crates/tavolo-app/src/config.rs b/server/crates/tavolo-app/src/config.rs new file mode 100644 index 0000000..f042829 --- /dev/null +++ b/server/crates/tavolo-app/src/config.rs @@ -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 { + 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> { + 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, String> { + let legacy: Vec<&str> = LEGACY_OIDC_VARIABLES + .iter() + .copied() + .filter(|name| env(name).is_some()) + .collect(); + if !legacy.is_empty() { + let renamed: Vec = 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 = 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__{variable}")) + .collect::>() + .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 = (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::>() + .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 { + 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("e(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, + pub app_host: String, + pub app_port: u16, + pub redis_url: Option, + 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, + pub cors_allow_origins: Option>, + pub cors_allow_origin_regex: Option, + pub cors_allow_methods: Option>, + pub cors_allow_headers: Option>, + pub cors_allow_credentials: bool, + pub cors_expose_headers: Option>, + pub cors_max_age: i64, + pub otel_enabled: bool, + pub otel_service_name: String, + pub otel_exporter_endpoint: Option, + pub otel_exporter_headers: Option>, + pub otel_excluded_paths: Vec, + pub otel_metric_export_interval_millis: u64, + pub otel_metrics_poll_interval_seconds: u64, +} + +impl Settings { + pub fn from_env() -> Result { + 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::() + .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())?, + }) + } +} diff --git a/server/crates/tavolo-app/src/cors.rs b/server/crates/tavolo-app/src/cors.rs new file mode 100644 index 0000000..6c9d0ab --- /dev/null +++ b/server/crates/tavolo-app/src/cors.rs @@ -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, + allow_origin_regex: Option, + allow_methods: Vec, + allow_all_headers: bool, + /// Lowercased set checked against requested headers. + allow_headers_lower: HashSet, + 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 { + 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 = 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 = SAFELISTED_HEADERS + .iter() + .map(|header| header.to_string()) + .chain(configured.iter().cloned()) + .collect::>() + .into_iter() + .collect(); + sorted_allow_headers.sort(); + let allow_headers_lower: HashSet = 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>, + 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 +} diff --git a/server/crates/tavolo-app/src/lib.rs b/server/crates/tavolo-app/src/lib.rs new file mode 100644 index 0000000..eff45a7 --- /dev/null +++ b/server/crates/tavolo-app/src/lib.rs @@ -0,0 +1,6 @@ +pub mod app; +pub mod config; +pub mod cors; +pub mod logging; +pub mod static_files; +pub mod telemetry; diff --git a/server/crates/tavolo-app/src/logging.rs b/server/crates/tavolo-app/src/logging.rs new file mode 100644 index 0000000..c4147a8 --- /dev/null +++ b/server/crates/tavolo-app/src/logging.rs @@ -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(); +} diff --git a/server/crates/tavolo-app/src/main.rs b/server/crates/tavolo-app/src/main.rs new file mode 100644 index 0000000..cb43024 --- /dev/null +++ b/server/crates/tavolo-app/src/main.rs @@ -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, +} + +#[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, + }, +} + +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::>() + .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 { + 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(()) +} diff --git a/server/crates/tavolo-app/src/static_files.rs b/server/crates/tavolo-app/src/static_files.rs new file mode 100644 index 0000000..e387803 --- /dev/null +++ b/server/crates/tavolo-app/src/static_files.rs @@ -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 `/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/`) 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, +} + +impl SpaFallback { + pub fn new(static_dir: &str) -> Self { + Self { + index: Arc::new(Path::new(static_dir).join("index.html")), + } + } +} + +impl Service for SpaFallback { + type Response = Response; + type Error = Infallible; + type Future = std::pin::Pin< + Box> + Send>, + >; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + 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() +} diff --git a/server/crates/tavolo-app/src/telemetry.rs b/server/crates/tavolo-app/src/telemetry.rs new file mode 100644 index 0000000..6d99e71 --- /dev/null +++ b/server/crates/tavolo-app/src/telemetry.rs @@ -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> = + 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 { + base.map(|base| format!("{base}/{signal}")) + } + + fn parse_headers(raw: Option<&[String]>) -> HashMap { + 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, 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, 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, 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, Option) { + 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>, + 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::() + .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); + } +} diff --git a/server/crates/tavolo-app/tests/auth_flow.rs b/server/crates/tavolo-app/tests/auth_flow.rs new file mode 100644 index 0000000..bda2e79 --- /dev/null +++ b/server/crates/tavolo-app/tests/auth_flow.rs @@ -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>, +} + +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, + Query(params): Query>| 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| 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::(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 = 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}" + ); +} diff --git a/server/crates/tavolo-app/tests/config_cors.rs b/server/crates/tavolo-app/tests/config_cors.rs new file mode 100644 index 0000000..e7f8811 --- /dev/null +++ b/server/crates/tavolo-app/tests/config_cors.rs @@ -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(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, + } +} diff --git a/server/crates/tavolo-app/tests/scopone_e2e.rs b/server/crates/tavolo-app/tests/scopone_e2e.rs new file mode 100644 index 0000000..475860a --- /dev/null +++ b/server/crates/tavolo-app/tests/scopone_e2e.rs @@ -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 { + 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 { + 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, + >, +) -> 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"), "app shell").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(); +} diff --git a/server/crates/tavolo-platform/Cargo.toml b/server/crates/tavolo-platform/Cargo.toml new file mode 100644 index 0000000..710cbd2 --- /dev/null +++ b/server/crates/tavolo-platform/Cargo.toml @@ -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 } diff --git a/server/crates/tavolo-platform/src/auth.rs b/server/crates/tavolo-platform/src/auth.rs new file mode 100644 index 0000000..329b2ed --- /dev/null +++ b/server/crates/tavolo-platform/src/auth.rs @@ -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, +} + +/// Return the authenticated user stored in a session, if any. +pub fn session_user(session: &SessionData) -> Option { + 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 { + 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 { + 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 +} diff --git a/server/crates/tavolo-platform/src/datetime.rs b/server/crates/tavolo-platform/src/datetime.rs new file mode 100644 index 0000000..3839dbd --- /dev/null +++ b/server/crates/tavolo-platform/src/datetime.rs @@ -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) -> 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> { + 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> { + value.as_str().and_then(from_iso) +} + +/// Serde `serialize_with` helper emitting Python-compatible ISO-8601. +pub fn serialize_iso(dt: &DateTime, serializer: S) -> Result +where + S: serde::Serializer, +{ + serializer.serialize_str(&to_iso(*dt)) +} + +/// The current UTC instant, with microsecond precision. +pub fn now() -> DateTime { + 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)); + } +} diff --git a/server/crates/tavolo-platform/src/deadlines.rs b/server/crates/tavolo-platform/src/deadlines.rs new file mode 100644 index 0000000..26a3acc --- /dev/null +++ b/server/crates/tavolo-platform/src/deadlines.rs @@ -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, + stats: SharedStatsRepo, + heartbeat: Duration, + retry_delay: Duration, + wake: Notify, + started: AtomicBool, +} + +impl DeadlineScheduler { + pub fn new( + store: SharedGameStore, + registry: Arc, + stats: SharedStatsRepo, + heartbeat_ms: u64, + retry_ms: u64, + ) -> Arc { + 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) { + 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, 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, 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, member: &str) -> Result<()> { + let Ok(entry) = serde_json::from_str::(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) { + 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) { + 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()) +} diff --git a/server/crates/tavolo-platform/src/elo.rs b/server/crates/tavolo-platform/src/elo.rs new file mode 100644 index 0000000..f654c3e --- /dev/null +++ b/server/crates/tavolo-platform/src/elo.rs @@ -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 { + if ratings.is_empty() { + return Err("a team needs at least one rating"); + } + Ok(ratings.iter().sum::() / 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); + } +} diff --git a/server/crates/tavolo-platform/src/engine.rs b/server/crates/tavolo-platform/src/engine.rs new file mode 100644 index 0000000..0f5fcbf --- /dev/null +++ b/server/crates/tavolo-platform/src/engine.rs @@ -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, +} + +impl Seat { + pub fn new(user_sub: impl Into, display_name: impl Into) -> 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 { + 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, + pub created_at: Option>, + pub finished_at: Option>, + /// Set once the finished match has been copied to Postgres. + pub stats_saved: bool, + pub state: Option>, +} + +impl GameSession { + /// A fresh lobby session created by [`GameEngine::create`]. + pub fn new( + id: impl Into, + game_type: impl Into, + join_code: impl Into, + ) -> 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 { + 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, + 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, + /// 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>, + pub winner_team: usize, + pub players: Vec, + pub summary: Value, +} + +impl MatchResult { + pub fn new( + teams: Vec>, + winner_team: usize, + players: Vec, + 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) -> 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, + ) -> 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; + + /// Serialize the opaque game state to plain JSON. + fn state_to_json(&self, state: &(dyn Any + Send + Sync)) -> Result; + + /// Rebuild the opaque game state from its JSON form. + fn state_from_json(&self, data: &Value) -> Result>; + + /// 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>; + + /// 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 { + 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::>(), + "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 { + 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::>>()) + .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, + }) +} diff --git a/server/crates/tavolo-platform/src/errors.rs b/server/crates/tavolo-platform/src/errors.rs new file mode 100644 index 0000000..5a9a7c9 --- /dev/null +++ b/server/crates/tavolo-platform/src/errors.rs @@ -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) -> Self { + Self { + code, + message: message.into(), + } + } + + /// The requested action violates the rules of the game. + pub fn illegal_move(message: impl Into) -> Self { + Self::new("illegal_move", message) + } + + /// A player attempted to act out of turn. + pub fn not_your_turn(message: impl Into) -> Self { + Self::new("illegal_move", message) + } + + /// An action was attempted before the game left the lobby. + pub fn game_not_started(message: impl Into) -> Self { + Self::new("illegal_move", message) + } + + /// An action was attempted after the match ended. + pub fn game_finished(message: impl Into) -> Self { + Self::new("illegal_move", message) + } + + /// A game already has its full complement of players. + pub fn lobby_full(message: impl Into) -> 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) -> 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) -> Self { + Self::new("illegal_move", message) + } + + /// A request named a `game_type` no registered engine provides. + pub fn unknown_game_type(message: impl Into) -> 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 = std::result::Result; diff --git a/server/crates/tavolo-platform/src/http.rs b/server/crates/tavolo-platform/src/http.rs new file mode 100644 index 0000000..d6dc3f8 --- /dev/null +++ b/server/crates/tavolo-platform/src/http.rs @@ -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 { + 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 { + 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> { + let mut params: HashMap> = 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) -> 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, 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) -> 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 = items.iter().map(py_repr).collect(); + format!("[{}]", rendered.join(", ")) + } + Value::Object(map) => { + let rendered: Vec = 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 +} diff --git a/server/crates/tavolo-platform/src/lib.rs b/server/crates/tavolo-platform/src/lib.rs new file mode 100644 index 0000000..5025903 --- /dev/null +++ b/server/crates/tavolo-platform/src/lib.rs @@ -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; diff --git a/server/crates/tavolo-platform/src/models.rs b/server/crates/tavolo-platform/src/models.rs new file mode 100644 index 0000000..22e7896 --- /dev/null +++ b/server/crates/tavolo-platform/src/models.rs @@ -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, + pub finished_at: DateTime, + 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, + pub won: bool, + pub score: f64, + pub elo_delta: Option, + 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, +} diff --git a/server/crates/tavolo-platform/src/oidc.rs b/server/crates/tavolo-platform/src/oidc.rs new file mode 100644 index 0000000..1694f51 --- /dev/null +++ b/server/crates/tavolo-platform/src/oidc.rs @@ -0,0 +1,551 @@ +//! OpenID Connect login flows (discovery, PKCE, ID-token validation). +//! +//! Mirrors `kaya.oidc`'s `OIDCProviders` / `OIDCMixin`: each configured +//! provider gets `/auth//login`, `/auth//callback` and +//! `/auth//logout` routes, namespaces its session state as +//! `oidc::...`, 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, + 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>, + jwks: Mutex>, +} + +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 { + 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 { + { + 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, 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, 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, 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::>(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, 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, 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 { + 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>, + ) -> Result { + 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 { + 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 = 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 = 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>, +} + +impl OidcProviders { + pub fn new(configs: Vec) -> Result { + 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> { + self.providers.iter() + } + + pub fn first(&self) -> &Arc { + &self.providers[0] + } + + pub fn get(&self, name: &str) -> Option<&Arc> { + self.providers + .iter() + .find(|provider| provider.name() == name) + } + + pub fn login_url(&self, name: &str) -> String { + format!("{BASE_PATH}/{name}/login") + } +} diff --git a/server/crates/tavolo-platform/src/pagination.rs b/server/crates/tavolo-platform/src/pagination.rs new file mode 100644 index 0000000..df0aaa5 --- /dev/null +++ b/server/crates/tavolo-platform/src/pagination.rs @@ -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>, +} + +/// Encode sort-key values as an opaque cursor. +pub fn encode_cursor(values: &Map) -> 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>, 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>, +) -> Result { + let limit = match params.get(LIMIT_PARAM).and_then(|values| values.first()) { + Some(value) => value + .parse::() + .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(¶ms).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(¶ms).unwrap().limit, 100); + + let mut params = HashMap::new(); + params.insert("limit".into(), vec!["zero".into()]); + assert!(matches!( + parse_cursor_params(¶ms), + Err(CursorDecodeError::InvalidLimit) + )); + } +} diff --git a/server/crates/tavolo-platform/src/platform.rs b/server/crates/tavolo-platform/src/platform.rs new file mode 100644 index 0000000..b860525 --- /dev/null +++ b/server/crates/tavolo-platform/src/platform.rs @@ -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, + pub game_store: SharedGameStore, + pub scheduler: Arc, + pub oidc: Arc, + pub stats: SharedStatsRepo, +} + +pub type SharedPlatform = Arc; diff --git a/server/crates/tavolo-platform/src/registry.rs b/server/crates/tavolo-platform/src/registry.rs new file mode 100644 index 0000000..f9293aa --- /dev/null +++ b/server/crates/tavolo-platform/src/registry.rs @@ -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>, + by_id: HashMap, +} + +impl GameRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Add `engine`; the first registered becomes the default. + pub fn register(&mut self, engine: Arc) -> 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> { + 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> { + 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> { + self.engines + .first() + .ok_or_else(|| crate::errors::Error::Internal("no game engines registered".into())) + } + + pub fn all(&self) -> &[Arc] { + &self.engines + } +} diff --git a/server/crates/tavolo-platform/src/routes/auth.rs b/server/crates/tavolo-platform/src/routes/auth.rs new file mode 100644 index 0000000..22f0db7 --- /dev/null +++ b/server/crates/tavolo-platform/src/routes/auth.rs @@ -0,0 +1,175 @@ +//! Authentication routes for the single-page app. +//! +//! Each configured provider gets `/auth//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 { + 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, session: Extension| { + 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, + query: RawQuery, + session: Extension| { + 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, session: Extension| { + 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) -> Response { + let results: Vec = 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) -> 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, + Extension(session): Extension, +) -> 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, + Extension(session): Extension, + 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, + RawQuery(query): RawQuery, + Extension(session): Extension, + 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, ¶ms).await { + Ok(location) => redirect(location), + Err(error) => flow_error_response(error), + } +} + +async fn provider_logout( + State(platform): State, + Extension(session): Extension, + 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), + } +} diff --git a/server/crates/tavolo-platform/src/routes/games.rs b/server/crates/tavolo-platform/src/routes/games.rs new file mode 100644 index 0000000..50b0228 --- /dev/null +++ b/server/crates/tavolo-platform/src/routes/games.rs @@ -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 { + 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 { + 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 { + let engine = platform + .registry + .require(&game.game_type) + .map_err(|_| send_error(StatusCode::INTERNAL_SERVER_ERROR, "unknown game type"))?; + let players: Vec = 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 { + 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 { + 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) -> Response { + let results: Vec = 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, + Extension(session): Extension, + 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, + Extension(session): Extension, + 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, + 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, + Extension(session): Extension, + Path(game_id): Path, +) -> 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, + } +} diff --git a/server/crates/tavolo-platform/src/routes/health.rs b/server/crates/tavolo-platform/src/routes/health.rs new file mode 100644 index 0000000..0017cfe --- /dev/null +++ b/server/crates/tavolo-platform/src/routes/health.rs @@ -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 { + Router::new().route("/api/health", get(health)) +} + +/// Health check: the service is up. +async fn health(State(_platform): State) -> Response { + send_json(StatusCode::OK, &json!({ "status": "ok" })) +} diff --git a/server/crates/tavolo-platform/src/routes/me.rs b/server/crates/tavolo-platform/src/routes/me.rs new file mode 100644 index 0000000..2d82d4c --- /dev/null +++ b/server/crates/tavolo-platform/src/routes/me.rs @@ -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 { + Router::new().route("/api/me", get(me)) +} + +/// Current user; 401 when not logged in. +async fn me( + State(_platform): State, + Extension(session): Extension, +) -> Response { + let Some(user) = current_user(&session).await else { + return unauthorized(); + }; + send_json( + StatusCode::OK, + &json!({ "sub": user.sub, "name": display_name(&user) }), + ) +} diff --git a/server/crates/tavolo-platform/src/routes/mod.rs b/server/crates/tavolo-platform/src/routes/mod.rs new file mode 100644 index 0000000..ae74cb8 --- /dev/null +++ b/server/crates/tavolo-platform/src/routes/mod.rs @@ -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 { + 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)) +} diff --git a/server/crates/tavolo-platform/src/routes/openapi.rs b/server/crates/tavolo-platform/src/routes/openapi.rs new file mode 100644 index 0000000..5b29534 --- /dev/null +++ b/server/crates/tavolo-platform/src/routes/openapi.rs @@ -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#" + + + + + {TITLE} - API documentation + + + +
+ + + + +"# + ) +} + +pub fn routes() -> Router { + Router::new() + .route(SPEC_PATH, get(spec_handler)) + .route(DOCS_PATH, get(docs_handler)) +} + +async fn spec_handler(State(_platform): State) -> Response { + send_json(StatusCode::OK, &spec()) +} + +async fn docs_handler(State(_platform): State) -> Response { + send_text(StatusCode::OK, docs_page(), "text/html; charset=utf-8") +} diff --git a/server/crates/tavolo-platform/src/routes/stats.rs b/server/crates/tavolo-platform/src/routes/stats.rs new file mode 100644 index 0000000..a98cd4b --- /dev/null +++ b/server/crates/tavolo-platform/src/routes/stats.rs @@ -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 { + 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>, +) -> Result, 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, + Extension(session): Extension, + RawQuery(query): RawQuery, +) -> Response { + let params = extract_query_params(query.as_deref()); + let cursor = match parse_cursor_params(¶ms) { + Ok(cursor) => cursor, + Err(err) => return cursor_error(err), + }; + let game_type = match parse_game_type(&platform, ¶ms) { + 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 = 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, + RawQuery(query): RawQuery, +) -> Response { + let params = extract_query_params(query.as_deref()); + let game_type = match parse_game_type(&platform, ¶ms) { + 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 = 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, + Extension(session): Extension, +) -> Response { + let Some(user) = current_user(&session).await else { + return unauthorized(); + }; + match platform.stats.my_ratings(&user.sub).await { + Ok(rows) => { + let results: Vec = 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") + } + } +} diff --git a/server/crates/tavolo-platform/src/session.rs b/server/crates/tavolo-platform/src/session.rs new file mode 100644 index 0000000..2db1280 --- /dev/null +++ b/server/crates/tavolo-platform/src/session.rs @@ -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, + old_id: Option, + data: Map, + modified: bool, + regenerate: bool, + invalidated: bool, +} + +impl SessionData { + pub fn new() -> Self { + Self::default() + } + + pub fn with_id(id: impl Into, data: Map) -> 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, value: Value) { + self.data.insert(key.into(), value); + self.modified = true; + } + + pub fn remove(&mut self, key: &str) -> Option { + let removed = self.data.remove(key); + if removed.is_some() { + self.modified = true; + } + removed + } + + pub fn keys(&self) -> impl Iterator { + self.data.keys() + } + + pub fn data(&self) -> &Map { + &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>); + +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) -> Result>; + + /// Persist the session data under `session_id`. + async fn save( + &self, + session_id: &str, + data: &Map, + max_age: Option, + ) -> 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; +} + +// --------------------------------------------------------------------------- +// In-memory store +// --------------------------------------------------------------------------- + +struct MemorySession { + data: Map, + expires: Option, +} + +/// Process-local session store used by tests and when Redis is not configured. +#[derive(Default)] +pub struct InMemorySessionStore { + sessions: Mutex>, +} + +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) -> Result> { + 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, + max_age: Option, + ) -> 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 { + 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:`, 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 { + 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) -> 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) -> Result> { + let mut conn = self.manager.clone(); + let key = self.key(session_id); + let payload: Option = 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::(&mut conn) + .await?; + } + let data: Map = serde_json::from_str(&payload)?; + Ok(Some(SessionData::with_id(session_id.to_string(), data))) + } + + async fn save( + &self, + session_id: &str, + data: &Map, + max_age: Option, + ) -> 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::(&mut conn) + .await?; + Ok(()) + } + + async fn count(&self) -> Result { + let mut conn = self.manager.clone(); + let mut cursor: u64 = 0; + let mut total = 0usize; + loop { + let (next, keys): (u64, Vec) = 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; + +// --------------------------------------------------------------------------- +// Cookie helpers +// --------------------------------------------------------------------------- + +/// Return the value of `name` from a `Cookie` header, if present. +pub fn parse_cookie_value(header_value: &str, name: &str) -> Option { + 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, + 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 { + 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, + 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 = 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) -> Error { + Error::Internal(message.into()) +} diff --git a/server/crates/tavolo-platform/src/stats.rs b/server/crates/tavolo-platform/src/stats.rs new file mode 100644 index 0000000..693cc04 --- /dev/null +++ b/server/crates/tavolo-platform/src/stats.rs @@ -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, + pub won: bool, + pub score: f64, + pub elo_delta: Option, + 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, + #[serde(serialize_with = "serialize_iso")] + pub finished_at: DateTime, + pub you_won: bool, + pub your_elo_delta: Option, + pub players: Vec, +} + +#[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>, + ) -> Result<(Vec, Option)>; + + /// The caller's rating per game type, ordered by game type. + async fn my_ratings(&self, user_sub: &str) -> Result>; + + /// 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>; + + /// Recompute every rating from the match history; returns the number of + /// matches replayed. + async fn backfill_elo(&self) -> Result; +} + +pub type SharedStatsRepo = std::sync::Arc; + +// --------------------------------------------------------------------------- +// 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], + winner_team: usize, +) -> Result> { + if teams.len() != 2 { + return Err(Error::Internal( + "Elo rating requires exactly two teams".into(), + )); + } + let mut ratings: HashMap = 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 = teams[0].iter().map(|sub| ratings[sub] as f64).collect(); + let team_b: Vec = 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>, + ) -> Result<(Vec, Option)> { + 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 = 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 = 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> = HashMap::new(); + if !page.is_empty() { + let ids: Vec = page.iter().map(|row| row.id).collect(); + let players: Vec = 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> { + 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> { + 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 = ratings.into_iter().collect(); + + let mut order: Vec = Vec::new(); + let mut aggregate: HashMap = 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 = 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 { + let mut tx = self.pool.begin().await?; + sqlx::query("DELETE FROM player_rating") + .execute(&mut *tx) + .await?; + let matches: Vec = 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 = 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> = 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> = 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, + finished_at: DateTime, + result: Value, +} + +#[derive(Clone)] +struct StoredPlayer { + match_id: Uuid, + user_sub: String, + display_name: String, + seat: i16, + team: Option, + won: bool, + score: f64, + elo_delta: Option, + details: Value, +} + +#[derive(Default)] +struct MemoryStats { + matches: Vec, + players: Vec, + ratings: HashMap<(String, String), (i32, i32)>, +} + +#[derive(Default)] +pub struct InMemoryStatsRepo { + inner: tokio::sync::Mutex, +} + +impl InMemoryStatsRepo { + pub fn new() -> Self { + Self::default() + } +} + +fn memory_apply_elo( + inner: &mut MemoryStats, + game_type: &str, + teams: &[Vec], + winner_team: usize, +) -> Result> { + if teams.len() != 2 { + return Err(Error::Internal( + "Elo rating requires exactly two teams".into(), + )); + } + let mut ratings: HashMap = 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 = teams[0].iter().map(|sub| ratings[sub] as f64).collect(); + let team_b: Vec = 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>, + ) -> Result<(Vec, Option)> { + 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 = 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 = 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> { + let inner = self.inner.lock().await; + let mut rows: Vec = 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> { + 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 = Vec::new(); + let mut aggregate: HashMap = 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 = 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 { + let mut inner = self.inner.lock().await; + inner.ratings.clear(); + let matches = inner.matches.clone(); + for row in &matches { + let players: Vec = inner + .players + .iter() + .filter(|p| p.match_id == row.id) + .cloned() + .collect(); + let mut by_team: std::collections::BTreeMap> = 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> = 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()) + } +} diff --git a/server/crates/tavolo-platform/src/store.rs b/server/crates/tavolo-platform/src/store.rs new file mode 100644 index 0000000..4e47886 --- /dev/null +++ b/server/crates/tavolo-platform/src/store.rs @@ -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:` 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 = + 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>; + + /// 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>; + + /// Number of live games that have not finished yet. + async fn count_ongoing(&self) -> Result; + + /// Acquire the per-game mutation lock, waiting up to ten seconds. + async fn acquire_lock(&self, game_id: &str) -> Result; + + /// 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>; + + /// 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>; + + /// Return the earliest pending due time (epoch seconds), if any. + async fn next_deadline(&self) -> Result>; + + /// 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>; +} + +pub type SharedGameStore = Arc; + +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, + ttl: i64, +} + +impl RedisGameStore { + pub async fn new(url: &str, registry: Arc, ttl_seconds: i64) -> Result { + 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> { + 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> { + let mut conn = self.manager.clone(); + let raw: Option = 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 = 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> { + let mut conn = self.manager.clone(); + let game_id: Option = 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 { + 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 = 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 { + 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 = 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::(&mut conn) + .await?; + tracing::debug!("redis publish {game_id}"); + Ok(()) + } + + async fn subscribe(&self, game_id: &str) -> Result> { + 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::(&mut conn) + .await?; + Ok(()) + } + + async fn due_deadlines(&self, now: f64, limit: usize) -> Result> { + let mut conn = self.manager.clone(); + let members: Vec = 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> { + 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::(&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::(&mut conn) + .await?; + Ok(()) + } + + async fn subscribe_deadlines(&self) -> Result> { + 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, + games: Mutex>, + codes: Mutex>, + ongoing: Mutex>, + locks: Mutex>>>, + subscribers: Mutex>>, + deadlines: Mutex>, + deadline_subscribers: broadcast::Sender<()>, +} + +impl InMemoryGameStore { + pub fn new(registry: Arc) -> 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> { + 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> { + 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 { + Ok(self.ongoing.lock().await.len()) + } + + async fn acquire_lock(&self, game_id: &str) -> Result { + 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> { + 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> { + 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> { + 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> { + Ok(broadcast_stream(self.deadline_subscribers.subscribe())) + } +} diff --git a/server/crates/tavolo-platform/src/ws.rs b/server/crates/tavolo-platform/src/ws.rs new file mode 100644 index 0000000..6ffcffa --- /dev/null +++ b/server/crates/tavolo-platform/src/ws.rs @@ -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, + Extension(session): Extension, + Path(game_id): Path, + 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>>, +} + +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, code: &str) -> Value { + json!({ "type": "error", "code": code, "message": message.into() }) +} + +fn state_message(platform: &Platform, game: &GameSession, sub: &str) -> Option { + 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, SplitStream) = + 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, +) { + 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 = 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; + } +} diff --git a/server/crates/tavolo-platform/tests/helpers/mod.rs b/server/crates/tavolo-platform/tests/helpers/mod.rs new file mode 100644 index 0000000..735b73d --- /dev/null +++ b/server/crates/tavolo-platform/tests/helpers/mod.rs @@ -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, + pub started: bool, + pub finished: bool, + pub winner: Option, + pub deadline_in_seconds: Option, +} + +pub struct DummyEngine; + +fn state_of(session: &GameSession) -> Result<&DummyState> { + session + .state + .as_ref() + .and_then(|state| state.downcast_ref::()) + .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::()) + .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) -> 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, + ) -> 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 { + 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 = 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 { + let state = state + .downcast_ref::() + .ok_or_else(|| Error::Internal("dummy state missing".into()))?; + Ok(serde_json::to_value(state)?) + } + + fn state_from_json(&self, data: &Value) -> Result> { + Ok(Box::new(serde_json::from_value::( + data.clone(), + )?)) + } + + fn next_deadline(&self, session: &GameSession) -> Result> { + 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, +} + +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) -> Response { + router.clone().oneshot(request).await.expect("request") +} + +pub async fn body_json(response: Response) -> 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 { + 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 { + 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, + >, +) -> 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, + >, +) -> 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) -> StatusCode { + response.status() +} diff --git a/server/crates/tavolo-platform/tests/http_ws.rs b/server/crates/tavolo-platform/tests/http_ws.rs new file mode 100644 index 0000000..0db97ec --- /dev/null +++ b/server/crates/tavolo-platform/tests/http_ws.rs @@ -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"); +} diff --git a/server/crates/tavolo-platform/tests/postgres_stats.rs b/server/crates/tavolo-platform/tests/postgres_stats.rs new file mode 100644 index 0000000..c0f56de --- /dev/null +++ b/server/crates/tavolo-platform/tests/postgres_stats.rs @@ -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 { + 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::()) + .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 { + tavolo_platform::pagination::decode_cursor(Some(cursor)) + .expect("cursor") + .expect("some") +} diff --git a/server/crates/tavolo-platform/tests/redis_store.rs b/server/crates/tavolo-platform/tests/redis_store.rs new file mode 100644 index 0000000..9553d61 --- /dev/null +++ b/server/crates/tavolo-platform/tests/redis_store.rs @@ -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 { + std::env::var("TEST_REDIS_URL") + .ok() + .filter(|url| !url.is_empty()) +} + +async fn store(ttl: i64) -> Option { + 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 { + store(60).await +} diff --git a/server/crates/tavolo-platform/tests/store_deadlines_stats.rs b/server/crates/tavolo-platform/tests/store_deadlines_stats.rs new file mode 100644 index 0000000..ea4885b --- /dev/null +++ b/server/crates/tavolo-platform/tests/store_deadlines_stats.rs @@ -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() + ); +} diff --git a/server/crates/tavolo-scopone/Cargo.toml b/server/crates/tavolo-scopone/Cargo.toml new file mode 100644 index 0000000..ece31b8 --- /dev/null +++ b/server/crates/tavolo-scopone/Cargo.toml @@ -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 } diff --git a/server/crates/tavolo-scopone/src/engine.rs b/server/crates/tavolo-scopone/src/engine.rs new file mode 100644 index 0000000..7c5d67f --- /dev/null +++ b/server/crates/tavolo-scopone/src/engine.rs @@ -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 { + 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(rng: &mut R) -> Vec { + 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> { + let mut result = Vec::new(); + if size == 0 { + return vec![Vec::new()]; + } + if size > items.len() { + return result; + } + let mut indices: Vec = (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> { + let equal: Vec = 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 = table + .iter() + .filter(|c| c.rank <= card.rank) + .cloned() + .collect(); + let mut captures: Vec> = 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::() == 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 { + 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( + 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(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(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 { + 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], requested: &[Card]) -> Option> { + let mut wanted: Vec = requested.iter().map(Card::code).collect(); + wanted.sort(); + for option in options { + let mut have: Vec = 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 = 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 = 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(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> = if options.is_empty() { + None + } else { + options.choose(rng).cloned() + }; + let sub = state.players[index].sub.clone(); + let capture_codes: Option> = 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 = 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( + 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; 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 = 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, Map) { + let mut piles: [Vec; 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 = 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 = 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 = 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) -> 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 = Vec::new(); + for player in &state.players { + let mut view: Map = 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 = 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 = 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 = 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) +} diff --git a/server/crates/tavolo-scopone/src/errors.rs b/server/crates/tavolo-scopone/src/errors.rs new file mode 100644 index 0000000..1ab8d84 --- /dev/null +++ b/server/crates/tavolo-scopone/src/errors.rs @@ -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")) +} diff --git a/server/crates/tavolo-scopone/src/lib.rs b/server/crates/tavolo-scopone/src/lib.rs new file mode 100644 index 0000000..5e61479 --- /dev/null +++ b/server/crates/tavolo-scopone/src/lib.rs @@ -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, +}; diff --git a/server/crates/tavolo-scopone/src/plugin.rs b/server/crates/tavolo-scopone/src/plugin.rs new file mode 100644 index 0000000..6295cd5 --- /dev/null +++ b/server/crates/tavolo-scopone/src/plugin.rs @@ -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::()) + .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::()) + .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 { + 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) -> 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, + ) -> 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> = 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 { + state + .downcast_ref::() + .map(ScoponeState::to_json) + .ok_or_else(|| GameError::illegal_move("state is not a ScoponeState").into()) + } + + fn state_from_json(&self, data: &Value) -> Result> { + Ok(Box::new(ScoponeState::from_json(data)?)) + } + + // -- deadlines ---------------------------------------------------------- + + fn next_deadline(&self, session: &GameSession) -> Result> { + 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 ¤t { + 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 = 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 { + 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::>(), + state + .players + .iter() + .filter(|player| player.team() == 1) + .map(|player| player.sub.clone()) + .collect::>(), + ]; + + 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::DateTime::::from_timestamp_millis(millis) + .unwrap_or_else(tavolo_platform::datetime::now) +} diff --git a/server/crates/tavolo-scopone/src/state.rs b/server/crates/tavolo-scopone/src/state.rs new file mode 100644 index 0000000..286556a --- /dev/null +++ b/server/crates/tavolo-scopone/src/state.rs @@ -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 { + 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 { + let upper = code.to_ascii_uppercase(); + let chars: Vec = 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 { + 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::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, + 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, + pub captured: Vec, + 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::>(), + "captured": self.captured.iter().map(Card::to_json).collect::>(), + "scope": self.scope, + }) + } + + pub fn from_json(data: &Value) -> Result { + let parse_cards = |value: Option<&Value>| -> Result> { + 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, + pub table: Vec, + pub dealer: i64, + pub turn: i64, + pub hand_number: i64, + pub scores: Vec, + pub winner: Option, + pub last_taker: Option, + /// Per-hand points awarded, for a compact audit trail in the API. + pub hand_scores: Vec, + /// The most recent play in the current hand, for move announcements. + pub last_move: Option, + /// While `phase == "hand_end"`: seats that acknowledged the summary, and + /// when the auto-continue timeout fires. + pub acked: Vec, + pub hand_end_deadline: Option, + /// 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, + 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::>(), + "table": self.table.iter().map(Card::to_json).collect::>(), + "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 { + 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.and_then(Value::as_array).cloned().unwrap_or_default() + }; + + let players = parse_array(obj.get("players")) + .iter() + .map(PlayerState::from_json) + .collect::>>()?; + let table = parse_array(obj.get("table")) + .iter() + .map(Card::from_json) + .collect::>>()?; + + let scores = obj + .get("scores") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .map(|x| x.as_i64().unwrap_or(0)) + .collect::>() + }) + .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::>() + }) + .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() + } +} diff --git a/server/crates/tavolo-scopone/tests/engine.rs b/server/crates/tavolo-scopone/tests/engine.rs new file mode 100644 index 0000000..c2820e6 --- /dev/null +++ b/server/crates/tavolo-scopone/tests/engine.rs @@ -0,0 +1,1310 @@ +//! Rule-engine tests: captures, scope, scoring and full-match simulation. +//! +//! Ported from `tavolo-scopone/tests/test_engine.py`. + +use std::collections::HashSet; + +use rand::rngs::StdRng; +use rand::SeedableRng; +use serde_json::{json, Value}; +use tavolo_scopone::engine::{self, HAND_SIZE, PLAYERS}; +use tavolo_scopone::state::{ + Card, Move, PlayerState, ScoponeState, PHASE_FINISHED, PHASE_HAND_END, PHASE_PLAYING, +}; + +fn card(code: &str) -> Card { + Card::parse(code).unwrap() +} + +fn codes(items: &[&str]) -> Vec { + items.iter().map(|s| s.to_string()).collect() +} + +fn view_codes(options: &[Vec]) -> Vec> { + options + .iter() + .map(|option| option.iter().map(Card::code).collect()) + .collect() +} + +fn make_state( + hands: &[&[&str]], + table: &[&str], + turn: i64, + captured: Option<&[&[&str]]>, + scope: Option<&[i64]>, + target: i64, + last_taker: Option, +) -> ScoponeState { + let mut state = ScoponeState { + target_score: target, + phase: PHASE_PLAYING.to_string(), + turn, + last_taker, + ..Default::default() + }; + for (seat, hand) in hands.iter().enumerate() { + state.players.push(PlayerState { + sub: format!("p{seat}"), + name: format!("p{seat}"), + seat: seat as i64, + hand: hand.iter().map(|c| card(c)).collect(), + ..Default::default() + }); + } + if let Some(captured) = captured { + for (player, pile) in state.players.iter_mut().zip(captured.iter()) { + player.captured = pile.iter().map(|c| card(c)).collect(); + } + } + if let Some(scope) = scope { + for (player, value) in state.players.iter_mut().zip(scope.iter()) { + player.scope = *value; + } + } + state.table = table.iter().map(|c| card(c)).collect(); + state +} + +// -- deck ------------------------------------------------------------------ + +#[test] +fn full_deck_has_40_unique_cards() { + let deck = engine::full_deck(); + assert_eq!(40, deck.len()); + let unique: HashSet = deck.iter().map(Card::code).collect(); + assert_eq!(40, unique.len()); + let suits: HashSet = deck.iter().map(|c| c.suit).collect(); + assert_eq!(4, suits.len()); + assert_eq!(4, deck.iter().filter(|c| c.rank == 7).count()); +} + +#[test] +fn shuffled_deck_is_permutation() { + let mut rng = StdRng::seed_from_u64(7); + let deck = engine::shuffled_deck(&mut rng); + let mut expected: Vec = engine::full_deck().iter().map(Card::code).collect(); + let mut actual: Vec = deck.iter().map(Card::code).collect(); + expected.sort(); + actual.sort(); + assert_eq!(expected, actual); +} + +// -- captures -------------------------------------------------------------- + +#[test] +fn equal_card_is_mandatory() { + let table = [card("05C"), card("02D"), card("03S")]; + let options = engine::legal_captures(&table, card("05D"), false); + assert_eq!(vec![vec!["05C".to_string()]], view_codes(&options)); +} + +#[test] +fn sum_combination() { + let table = [card("01C"), card("03C"), card("02S")]; + let options = engine::legal_captures(&table, card("04D"), false); + assert_eq!( + vec![vec!["01C".to_string(), "03C".to_string()]], + view_codes(&options) + ); +} + +#[test] +fn multiple_equal_cards_each_a_separate_option() { + let table = [card("05C"), card("05S")]; + let mut options = view_codes(&engine::legal_captures(&table, card("05D"), false)); + options.sort(); + assert_eq!( + vec![vec!["05C".to_string()], vec!["05S".to_string()]], + options + ); +} + +#[test] +fn no_capture() { + let table = [card("09C"), card("08S")]; + assert!(engine::legal_captures(&table, card("02D"), false).is_empty()); +} + +#[test] +fn play_without_capture_places_card_on_table() { + let mut state = make_state( + &[&["02D", "03D"], &["01C"], &["01S"], &["01B"]], + &["09C"], + 0, + None, + None, + 11, + None, + ); + engine::play(&mut state, "p0", "02D", None).unwrap(); + let table: Vec = state.table.iter().map(Card::code).collect(); + assert!(table.contains(&"02D".to_string())); + let hand: Vec = state.players[0].hand.iter().map(Card::code).collect(); + assert!(!hand.contains(&"02D".to_string())); + assert_eq!(1, state.turn); +} + +#[test] +fn play_capture_and_scopa() { + let mut state = make_state( + &[&["02D", "09C"], &["01C"], &["01S"], &["01B"]], + &["02C"], + 0, + None, + None, + 11, + None, + ); + engine::play(&mut state, "p0", "02D", Some(&codes(&["02C"]))).unwrap(); + assert_eq!(1, state.players[0].scope); + assert!(state.table.is_empty()); + let captured: Vec = state.players[0].captured.iter().map(Card::code).collect(); + assert_eq!(vec!["02C".to_string(), "02D".to_string()], captured); + let last_move = state.last_move.as_ref().expect("move recorded"); + assert_eq!(0, last_move.seat); + assert_eq!("p0", last_move.name); + assert_eq!("02D", last_move.card); + assert_eq!(vec!["02C".to_string()], last_move.captured); + assert!(last_move.scopa); +} + +#[test] +fn play_without_capture_records_move() { + let mut state = make_state( + &[&["02D", "03D"], &["01C"], &["01S"], &["01B"]], + &["09C"], + 0, + None, + None, + 11, + None, + ); + engine::play(&mut state, "p0", "02D", None).unwrap(); + let last_move = state.last_move.as_ref().expect("move recorded"); + assert_eq!("02D", last_move.card); + assert!(last_move.captured.is_empty()); + assert!(!last_move.scopa); +} + +#[test] +fn illegal_combination_when_equal_card_present() { + let mut state = make_state( + &[&["05D"], &["01C"], &["01S"], &["01B"]], + &["05C", "02D", "03S"], + 0, + None, + None, + 11, + None, + ); + assert!(engine::play(&mut state, "p0", "05D", Some(&codes(&["02D", "03S"]))).is_err()); +} + +#[test] +fn illegal_capture_rejected() { + let mut state = make_state( + &[&["04D"], &["01C"], &["01S"], &["01B"]], + &["02C", "03S"], + 0, + None, + None, + 11, + None, + ); + assert!(engine::play(&mut state, "p0", "04D", Some(&codes(&["02C"]))).is_err()); +} + +#[test] +fn no_capture_requested_when_capture_possible() { + let mut state = make_state( + &[&["05D"], &["01C"], &["01S"], &["01B"]], + &["05C"], + 0, + None, + None, + 11, + None, + ); + assert!(engine::play(&mut state, "p0", "05D", None).is_err()); +} + +#[test] +fn not_your_turn() { + let mut state = make_state( + &[&["02D"], &["03D"], &["04D"], &["05D"]], + &[], + 1, + None, + None, + 11, + None, + ); + assert!(engine::play(&mut state, "p0", "02D", None).is_err()); +} + +#[test] +fn card_not_in_hand() { + let mut state = make_state( + &[&["02D"], &["03D"], &["04D"], &["05D"]], + &[], + 0, + None, + None, + 11, + None, + ); + let error = engine::play(&mut state, "p0", "07D", None).unwrap_err(); + assert_eq!( + "card 07D is not in your hand", + error.as_game().unwrap().message + ); +} + +#[test] +fn finished_game_rejects_moves() { + let mut state = make_state( + &[&["02D"], &["03D"], &["04D"], &["05D"]], + &[], + 0, + None, + None, + 11, + None, + ); + state.phase = PHASE_FINISHED.to_string(); + assert!(engine::play(&mut state, "p0", "02D", None).is_err()); +} + +// -- last play / sweeping -------------------------------------------------- + +#[test] +fn no_scopa_on_last_play_of_hand() { + let mut state = make_state(&[&["02D"], &[], &[], &[]], &["02C"], 0, None, None, 2, None); + engine::play(&mut state, "p0", "02D", Some(&codes(&["02C"]))).unwrap(); + assert_eq!(PHASE_FINISHED, state.phase); + assert_eq!(Some(0), state.winner); + let summary = state.hand_scores.last().unwrap(); + assert_eq!(0, summary["scope"]["A"]); +} + +#[test] +fn table_swept_to_last_taker() { + let mut state = make_state( + &[&["02D"], &[], &[], &[]], + &["05C", "04D"], + 0, + None, + None, + 2, + Some(1), + ); + engine::play(&mut state, "p0", "02D", None).unwrap(); + let captured: HashSet = state.players[1].captured.iter().map(Card::code).collect(); + let expected: HashSet = ["05C", "04D", "02D"] + .iter() + .map(|s| s.to_string()) + .collect(); + assert_eq!(expected, captured); + assert_eq!(PHASE_FINISHED, state.phase); + assert_eq!(Some(1), state.winner); +} + +// -- scoring --------------------------------------------------------------- + +#[test] +fn primiera_values_and_all_suits_requirement() { + assert_eq!( + 70, + engine::primiera_score(&["07D", "06C", "01S", "05B"].map(card)) + ); + assert_eq!(0, engine::primiera_score(&["07D", "06C", "01S"].map(card))); + assert_eq!( + 40, + engine::primiera_score(&["08D", "09C", "10S", "10B"].map(card)) + ); +} + +#[test] +fn hand_points_carte_denara_settebello_primiera_scope() { + let state = make_state( + &[&[], &[], &[], &[]], + &[], + 0, + Some(&[ + &["07D", "06C", "01S", "05B"], + &["03D", "04C", "07S", "02B"], + &["02D"], + &["10D", "10C", "10S", "10B"], + ]), + Some(&[1, 0, 0, 2]), + 11, + None, + ); + let (points, details) = engine::hand_points(&state); + assert_eq!(vec![3, 3], points); + assert_eq!(json!({"A": 5, "B": 8}), details["cards"]); + assert_eq!(json!({"A": 2, "B": 2}), details["denara"]); + assert_eq!(json!({"A": true, "B": false}), details["settebello"]); + assert_eq!(json!({"A": 70, "B": 60}), details["primiera"]); + assert_eq!(json!({"A": 1, "B": 2}), details["scope"]); +} + +#[test] +fn ties_award_nothing() { + let state = make_state( + &[&[], &[], &[], &[]], + &[], + 0, + Some(&[ + &["06C", "01S", "05B", "02D"], + &["06S", "01B", "05D", "02C"], + &[], + &[], + ]), + Some(&[0, 0, 0, 0]), + 11, + None, + ); + let (points, _) = engine::hand_points(&state); + assert_eq!(vec![0, 0], points); +} + +// -- napola ---------------------------------------------------------------- + +#[test] +fn napola_score_runs() { + assert_eq!(0, engine::napola_score(&["02D", "03D", "04D"].map(card))); + assert_eq!(0, engine::napola_score(&["01D", "02D"].map(card))); + assert_eq!(3, engine::napola_score(&["03D", "01D", "02D"].map(card))); + assert_eq!( + 4, + engine::napola_score(&["01D", "02D", "03D", "04D", "07C"].map(card)) + ); + assert_eq!( + 3, + engine::napola_score(&["01D", "02D", "03D", "05D"].map(card)) + ); + let full: Vec = (1..=10).map(|rank| card(&format!("{rank:02}D"))).collect(); + assert_eq!(10, engine::napola_score(&full)); +} + +#[test] +fn hand_points_napola() { + let state = make_state( + &[&[], &[], &[], &[]], + &[], + 0, + Some(&[ + &["01D", "02D", "03D", "04C"], + &["05D", "06D", "07D", "08D"], + &["09D", "10D", "01C", "02C"], + &["03C", "05C", "06C", "07C"], + ]), + None, + 11, + None, + ); + let (points, details) = engine::hand_points(&state); + assert_eq!(json!({"A": 3, "B": 0}), details["napola"]); + assert_eq!(json!("A"), details["award"]["napola"]); + assert_eq!(vec![4, 1], points); +} + +#[test] +fn napola_disabled() { + let mut state = make_state( + &[&[], &[], &[], &[]], + &[], + 0, + Some(&[ + &["01D", "02D", "03D", "04C"], + &["05D", "06D", "07D", "08D"], + &["09D", "10D", "01C", "02C"], + &["03C", "05C", "06C", "07C"], + ]), + None, + 11, + None, + ); + state.napola = false; + let (points, details) = engine::hand_points(&state); + assert!(details.get("napola").is_none()); + assert_eq!(vec![1, 1], points); +} + +#[test] +fn full_denari_sweep_wins_match_instantly() { + let mut state = make_state( + &[&["02C"], &[], &[], &[]], + &[], + 0, + Some(&[ + &[ + "01D", "02D", "03D", "04D", "05D", "06D", "07D", "08D", "09D", "10D", + ], + &[], + &[], + &[], + ]), + None, + 100, + None, + ); + state.scores = vec![0, 50]; + engine::play(&mut state, "p0", "02C", None).unwrap(); + assert_eq!(PHASE_FINISHED, state.phase); + assert_eq!(Some(0), state.winner); + assert!(state.scores[0] < 100); + assert_eq!(10, state.hand_scores.last().unwrap()["napola"]["A"]); +} + +#[test] +fn napola_serialization_roundtrip() { + let mut state = make_state(&[&["02D"], &[], &[], &[]], &[], 0, None, None, 11, None); + assert!(state.napola); + state.napola = false; + assert!(!ScoponeState::from_json(&state.to_json()).unwrap().napola); + // States serialized before the option existed default to enabled. + let mut data = state.to_json(); + data.as_object_mut().unwrap().remove("napola"); + assert!(ScoponeState::from_json(&data).unwrap().napola); +} + +#[test] +fn create_game_napola_default_and_override() { + assert!( + engine::create_game("p0", "p0", 11, 30, 30, true, false) + .unwrap() + .napola + ); + assert!( + !engine::create_game("p0", "p0", 11, 30, 30, false, false) + .unwrap() + .napola + ); +} + +// -- asso pigliatutto ------------------------------------------------------ + +#[test] +fn ace_sweeps_the_whole_table_without_scopa() { + let mut state = make_state( + &[&["01D", "09C"], &["04D"], &["05D"], &["06D"]], + &["05C", "02D", "03S"], + 0, + None, + None, + 11, + None, + ); + state.asso_pigliatutto = true; + engine::play( + &mut state, + "p0", + "01D", + Some(&codes(&["05C", "02D", "03S"])), + ) + .unwrap(); + assert!(state.table.is_empty()); + let captured: HashSet = state.players[0].captured.iter().map(Card::code).collect(); + let expected: HashSet = ["05C", "02D", "03S", "01D"] + .iter() + .map(|s| s.to_string()) + .collect(); + assert_eq!(expected, captured); + assert_eq!(0, state.players[0].scope); + let last_move = state.last_move.as_ref().unwrap(); + assert!(last_move.pigliatutto); + assert!(!last_move.scopa); +} + +#[test] +fn legal_captures_offer_only_the_whole_table() { + let table = [card("05C"), card("02D"), card("03S")]; + assert_eq!( + vec![vec![ + "05C".to_string(), + "02D".to_string(), + "03S".to_string() + ]], + view_codes(&engine::legal_captures(&table, card("01D"), true)) + ); + assert_eq!( + vec![Vec::::new()], + view_codes(&engine::legal_captures(&[], card("01D"), true)) + ); + assert!(engine::legal_captures(&[], card("01D"), false).is_empty()); + assert!(engine::legal_captures(&table, card("04B"), true).is_empty()); +} + +#[test] +fn equal_rank_ace_blocks_the_sweep() { + let table = ["01C", "02D"]; + let mut state = make_state( + &[&["01D"], &["04D"], &["05D"], &["06D"]], + &table, + 0, + None, + None, + 11, + None, + ); + state.asso_pigliatutto = true; + assert_eq!( + vec![vec!["01C".to_string()]], + view_codes(&engine::legal_captures(&state.table, card("01D"), true)) + ); + assert!(engine::play(&mut state, "p0", "01D", Some(&codes(&["01C", "02D"]))).is_err()); + // A rejected play consumes the card, so retry on a fresh state. + let mut state = make_state( + &[&["01D"], &["04D"], &["05D"], &["06D"]], + &table, + 0, + None, + None, + 11, + None, + ); + state.asso_pigliatutto = true; + engine::play(&mut state, "p0", "01D", Some(&codes(&["01C"]))).unwrap(); + let table: Vec = state.table.iter().map(Card::code).collect(); + assert_eq!(vec!["02D".to_string()], table); + assert!(!state.last_move.as_ref().unwrap().pigliatutto); +} + +#[test] +fn disabled_by_default() { + let mut state = make_state( + &[&["01D", "09C"], &["04D"], &["05D"], &["06D"]], + &["05C", "02D"], + 0, + None, + None, + 11, + None, + ); + engine::play(&mut state, "p0", "01D", None).unwrap(); + let table: HashSet = state.table.iter().map(Card::code).collect(); + let expected: HashSet = ["05C", "02D", "01D"] + .iter() + .map(|s| s.to_string()) + .collect(); + assert_eq!(expected, table); + assert!(!state.last_move.as_ref().unwrap().pigliatutto); + // ... and on a bare table it stays there too. + let mut state = make_state( + &[&["01D", "09C"], &["04D"], &["05D"], &["06D"]], + &[], + 0, + None, + None, + 11, + None, + ); + engine::play(&mut state, "p0", "01D", None).unwrap(); + let table: Vec = state.table.iter().map(Card::code).collect(); + assert_eq!(vec!["01D".to_string()], table); + assert!(state.players[0].captured.is_empty()); + assert!(engine::legal_captures(&[card("05C")], card("01D"), false).is_empty()); +} + +#[test] +fn ace_on_empty_table_captures_itself() { + let mut state = make_state( + &[&["01D", "09C"], &["04D"], &["05D"], &["06D"]], + &[], + 0, + None, + None, + 11, + Some(1), + ); + state.asso_pigliatutto = true; + engine::play(&mut state, "p0", "01D", None).unwrap(); + assert!(state.table.is_empty()); + let captured: Vec = state.players[0].captured.iter().map(Card::code).collect(); + assert_eq!(vec!["01D".to_string()], captured); + assert_eq!(0, state.players[0].scope); + assert_eq!(Some(0), state.last_taker); + let last_move = state.last_move.as_ref().unwrap(); + assert!(last_move.pigliatutto); + assert!(last_move.captured.is_empty()); + assert!(!last_move.scopa); +} + +#[test] +fn non_ace_on_empty_table_stays() { + let mut state = make_state( + &[&["02D", "09C"], &["04D"], &["05D"], &["06D"]], + &[], + 0, + None, + None, + 11, + None, + ); + state.asso_pigliatutto = true; + engine::play(&mut state, "p0", "02D", None).unwrap(); + let table: Vec = state.table.iter().map(Card::code).collect(); + assert_eq!(vec!["02D".to_string()], table); + assert!(state.players[0].captured.is_empty()); + assert!(!state.last_move.as_ref().unwrap().pigliatutto); +} + +#[test] +fn move_pigliatutto_serialization() { + let move_ = Move { + seat: 0, + name: "p0".to_string(), + card: "01D".to_string(), + captured: vec!["05C".to_string()], + pigliatutto: true, + ..Default::default() + }; + let restored = Move::from_json(&move_.to_json()); + assert!(restored.pigliatutto); + let mut legacy = move_.to_json(); + legacy.as_object_mut().unwrap().remove("pigliatutto"); + assert!(!Move::from_json(&legacy).pigliatutto); +} + +#[test] +fn state_serialization_roundtrip() { + let mut state = make_state(&[&["02D"], &[], &[], &[]], &[], 0, None, None, 11, None); + assert!(!state.asso_pigliatutto); + state.asso_pigliatutto = true; + assert!( + ScoponeState::from_json(&state.to_json()) + .unwrap() + .asso_pigliatutto + ); + let mut data = state.to_json(); + data.as_object_mut().unwrap().remove("asso_pigliatutto"); + assert!(!ScoponeState::from_json(&data).unwrap().asso_pigliatutto); +} + +#[test] +fn create_game_default_and_override() { + assert!( + !engine::create_game("p0", "p0", 11, 30, 30, true, false) + .unwrap() + .asso_pigliatutto + ); + assert!( + engine::create_game("p0", "p0", 11, 30, 30, true, true) + .unwrap() + .asso_pigliatutto + ); +} + +#[test] +fn state_for_player_exposes_rule_and_legal_moves() { + let mut state = make_state( + &[&["01D"], &["04D"], &["05D"], &["06D"]], + &["05C", "02D", "03S"], + 0, + None, + None, + 11, + None, + ); + state.asso_pigliatutto = true; + let view = engine::state_for_player(&state, "p0"); + assert_eq!(json!(true), view["asso_pigliatutto"]); + assert_eq!(json!({"01D": [["05C", "02D", "03S"]]}), view["legal_moves"]); + // On a bare table the ace advertises the self-capture. + let mut state = make_state( + &[&["01D"], &["04D"], &["05D"], &["06D"]], + &[], + 0, + None, + None, + 11, + None, + ); + state.asso_pigliatutto = true; + let view = engine::state_for_player(&state, "p0"); + assert_eq!(json!({"01D": [[]]}), view["legal_moves"]); +} + +#[test] +fn auto_play_sweeps_with_the_rule() { + let mut state = make_state( + &[&["01D"], &["04D"], &["05D"], &["06D"]], + &["05C", "02D"], + 0, + None, + None, + 11, + None, + ); + state.asso_pigliatutto = true; + let mut rng = StdRng::seed_from_u64(1); + engine::auto_play(&mut state, &mut rng).unwrap(); + let last_move = state.last_move.as_ref().unwrap(); + assert!(last_move.pigliatutto); + assert_eq!( + vec!["05C".to_string(), "02D".to_string()], + last_move.captured + ); +} + +#[test] +fn auto_play_self_captures_on_bare_table() { + let mut state = make_state( + &[&["01D"], &["04D"], &["05D"], &["06D"]], + &[], + 0, + None, + None, + 11, + None, + ); + state.asso_pigliatutto = true; + let mut rng = StdRng::seed_from_u64(2); + engine::auto_play(&mut state, &mut rng).unwrap(); + let last_move = state.last_move.as_ref().unwrap(); + assert!(last_move.pigliatutto); + let captured: Vec = state.players[0].captured.iter().map(Card::code).collect(); + assert_eq!(vec!["01D".to_string()], captured); + assert!(state.table.is_empty()); +} + +#[test] +fn full_random_match_with_rule_keeps_all_cards() { + let mut rng = StdRng::seed_from_u64(2024); + let mut state = engine::create_game("p0", "p0", 11, 30, 30, true, true).unwrap(); + for i in 1..4 { + let name = format!("p{i}"); + engine::join_game(&mut state, &name, &name, &mut rng).unwrap(); + } + + let mut moves = 0; + while state.phase != PHASE_FINISHED && moves < 200_000 { + if state.phase == PHASE_HAND_END { + let subs: Vec = state.players.iter().map(|p| p.sub.clone()).collect(); + for sub in subs { + engine::acknowledge_hand(&mut state, &sub, &mut rng).unwrap(); + } + continue; + } + let index = state + .players + .iter() + .position(|p| p.seat == state.turn) + .unwrap(); + let played = state.players[index].hand[0].clone(); + let options = engine::legal_captures(&state.table, played.clone(), state.asso_pigliatutto); + let capture: Option> = if options.is_empty() { + None + } else { + Some(options[0].iter().map(Card::code).collect()) + }; + let sub = state.players[index].sub.clone(); + engine::play(&mut state, &sub, &played.code(), capture.as_deref()).unwrap(); + moves += 1; + } + + assert_eq!(PHASE_FINISHED, state.phase); + assert!(state.winner == Some(0) || state.winner == Some(1)); + // Self-captures included, all 40 cards end up in capture piles. + assert!(state.table.is_empty()); + assert!(state.players.iter().all(|p| p.hand.is_empty())); + assert_eq!( + 40, + state + .players + .iter() + .map(|p| p.captured.len()) + .sum::() + ); +} + +// -- match flow ------------------------------------------------------------ + +#[test] +fn join_starts_when_full() { + let mut rng = StdRng::seed_from_u64(3); + let mut state = engine::create_game("p0", "p0", 11, 30, 30, true, false).unwrap(); + assert_eq!(1, state.players.len()); + engine::join_game(&mut state, "p1", "p1", &mut rng).unwrap(); + engine::join_game(&mut state, "p2", "p2", &mut rng).unwrap(); + assert_eq!("lobby", state.phase); + engine::join_game(&mut state, "p3", "p3", &mut rng).unwrap(); + assert_eq!(PHASE_PLAYING, state.phase); + assert_eq!(PLAYERS, state.players.len()); + for player in &state.players { + assert_eq!(HAND_SIZE, player.hand.len()); + } + assert!(state.table.is_empty()); + assert_eq!(1, state.turn); // dealer is seat 0 +} + +#[test] +fn match_ends_when_target_reached() { + let mut state = make_state(&[&["02D"], &[], &[], &[]], &["02C"], 0, None, None, 1, None); + engine::play(&mut state, "p0", "02D", Some(&codes(&["02C"]))).unwrap(); + assert_eq!(PHASE_FINISHED, state.phase); + assert_eq!(Some(0), state.winner); + assert!(state.scores[0] >= 1); +} + +#[test] +fn state_for_player_hides_other_hands() { + let state = make_state( + &[&["02D", "03C"], &["04D"], &["05D"], &["06D"]], + &["07C"], + 0, + None, + None, + 11, + None, + ); + let view = engine::state_for_player(&state, "p0"); + let players = view["players"].as_array().unwrap(); + let by_seat = |seat: i64| players.iter().find(|p| p["seat"] == seat).unwrap(); + assert_eq!(json!(["02D", "03C"]), by_seat(0)["hand"]); + assert!(by_seat(1).get("hand").is_none()); + assert_eq!(json!(1), by_seat(1)["cards_left"]); + assert_eq!(json!(["07C"]), view["table"]); + assert_eq!(json!(true), view["your_turn"]); +} + +#[test] +fn legal_moves_only_for_player_on_turn() { + let state = make_state( + &[&["02D", "03C"], &["04D"], &["05D"], &["06D"]], + &["07C"], + 0, + None, + None, + 11, + None, + ); + let view = engine::state_for_player(&state, "p0"); + assert_eq!(json!({}), view["legal_moves"]); + + let state = make_state( + &[&["09D"], &["04D"], &["05D"], &["06D"]], + &["07C", "02S"], + 0, + None, + None, + 11, + None, + ); + let view = engine::state_for_player(&state, "p0"); + assert_eq!(json!({"09D": [["07C", "02S"]]}), view["legal_moves"]); + + // A player who is not on turn gets no legal_moves key. + let other = engine::state_for_player(&state, "p1"); + assert!(other.get("legal_moves").is_none()); + assert!(other.get("your_turn").is_none()); +} + +#[test] +fn full_random_match_reaches_completion() { + let mut rng = StdRng::seed_from_u64(99); + let mut state = engine::create_game("p0", "p0", 11, 30, 30, true, false).unwrap(); + for i in 1..4 { + let name = format!("p{i}"); + engine::join_game(&mut state, &name, &name, &mut rng).unwrap(); + } + + let mut moves = 0; + while state.phase != PHASE_FINISHED && moves < 200_000 { + if state.phase == PHASE_HAND_END { + let subs: Vec = state.players.iter().map(|p| p.sub.clone()).collect(); + for sub in subs { + engine::acknowledge_hand(&mut state, &sub, &mut rng).unwrap(); + } + continue; + } + let index = state + .players + .iter() + .position(|p| p.seat == state.turn) + .unwrap(); + let played = state.players[index].hand[0].clone(); + let options = engine::legal_captures(&state.table, played.clone(), false); + let capture: Option> = if options.is_empty() { + None + } else { + Some(options[0].iter().map(Card::code).collect()) + }; + let sub = state.players[index].sub.clone(); + engine::play(&mut state, &sub, &played.code(), capture.as_deref()).unwrap(); + moves += 1; + } + + assert_eq!(PHASE_FINISHED, state.phase); + assert!(state.winner == Some(0) || state.winner == Some(1)); + assert!(state.table.is_empty()); + assert!(state.players.iter().all(|p| p.hand.is_empty())); + assert_eq!( + 40, + state + .players + .iter() + .map(|p| p.captured.len()) + .sum::() + ); +} + +// -- hand-end acknowledgement --------------------------------------------- + +fn hand_end_state() -> ScoponeState { + let mut state = make_state( + &[&["02D"], &[], &[], &[]], + &["02C"], + 0, + None, + None, + 11, + None, + ); + engine::play(&mut state, "p0", "02D", Some(&codes(&["02C"]))).unwrap(); + state +} + +#[test] +fn end_of_hand_pauses_for_acknowledgement() { + let state = hand_end_state(); + assert_eq!("hand_end", state.phase); + assert!(state.acked.is_empty()); + assert_eq!(1, state.hand_number); + assert!(state.hand_end_deadline.is_some()); + let captured: Vec = state.players[0].captured.iter().map(Card::code).collect(); + assert_eq!(vec!["02C".to_string(), "02D".to_string()], captured); + let summary = state.hand_scores.last().unwrap(); + assert_eq!(1, summary["hand"]); + assert!(summary.get("award").is_some()); +} + +#[test] +fn play_during_hand_end_is_rejected() { + let mut state = hand_end_state(); + assert!(engine::play(&mut state, "p0", "02D", None).is_err()); +} + +#[test] +fn ack_all_four_deals_next_hand() { + let mut rng = StdRng::seed_from_u64(5); + let mut state = hand_end_state(); + let dealer_before = state.dealer; + for (i, sub) in ["p0", "p1", "p2"].iter().enumerate() { + engine::acknowledge_hand(&mut state, sub, &mut rng).unwrap(); + assert_eq!((0..=i as i64).collect::>(), state.acked); + assert_eq!("hand_end", state.phase); + } + engine::acknowledge_hand(&mut state, "p3", &mut rng).unwrap(); + assert_eq!("playing", state.phase); + assert_eq!(2, state.hand_number); + assert_eq!((dealer_before + 1) % 4, state.dealer); + assert!(state.acked.is_empty()); + assert!(state.hand_end_deadline.is_none()); + assert!(state.last_move.is_none()); + for player in &state.players { + assert_eq!(HAND_SIZE, player.hand.len()); + assert!(player.captured.is_empty()); + } + assert_eq!((dealer_before + 2) % 4, state.turn); +} + +#[test] +fn double_ack_is_idempotent() { + let mut rng = StdRng::seed_from_u64(6); + let mut state = hand_end_state(); + engine::acknowledge_hand(&mut state, "p0", &mut rng).unwrap(); + engine::acknowledge_hand(&mut state, "p0", &mut rng).unwrap(); + assert_eq!(vec![0], state.acked); +} + +#[test] +fn ack_outside_hand_end_is_rejected() { + let mut rng = StdRng::seed_from_u64(7); + let mut state = make_state( + &[&["02D"], &["03D"], &["04D"], &["05D"]], + &[], + 0, + None, + None, + 11, + None, + ); + let error = engine::acknowledge_hand(&mut state, "p0", &mut rng).unwrap_err(); + assert_eq!( + "no hand summary is waiting for acknowledgement", + error.as_game().unwrap().message + ); +} + +#[test] +fn ack_by_non_player_is_rejected() { + let mut rng = StdRng::seed_from_u64(8); + let mut state = hand_end_state(); + let error = engine::acknowledge_hand(&mut state, "mallory", &mut rng).unwrap_err(); + assert_eq!( + "you are not seated in this game", + error.as_game().unwrap().message + ); +} + +#[test] +fn state_exposes_ack_progress() { + let mut rng = StdRng::seed_from_u64(9); + let mut state = hand_end_state(); + engine::acknowledge_hand(&mut state, "p1", &mut rng).unwrap(); + let view = engine::state_for_player(&state, "p0"); + assert_eq!(json!([1]), view["acknowledged"]); + assert!(view["hand_end_deadline"].is_string()); + assert!(view["last_hand"].is_object()); + assert!(view["last_hand"].get("award").is_some()); +} + +// -- auto play ------------------------------------------------------------- + +#[test] +fn auto_play_plays_a_card_and_advances_turn() { + let mut rng = StdRng::seed_from_u64(11); + let mut state = make_state( + &[&["02D", "03C"], &["04D"], &["05D"], &["06D"]], + &["09B"], + 0, + None, + None, + 11, + None, + ); + state.turn_deadline = Some("2000-01-01T00:00:00+00:00".to_string()); + engine::auto_play(&mut state, &mut rng).unwrap(); + assert_eq!(1, state.turn); + assert_eq!(1, state.players[0].hand.len()); + // The played card could not capture the nine, so the table grew. + assert_eq!(2, state.table.len()); + let last_move = state.last_move.as_ref().unwrap(); + assert_eq!(0, last_move.seat); + assert_ne!( + "2000-01-01T00:00:00+00:00", + state.turn_deadline.as_ref().unwrap() + ); +} + +#[test] +fn auto_play_takes_a_mandatory_capture() { + let mut rng = StdRng::seed_from_u64(12); + let mut state = make_state( + &[&["05D"], &["04D"], &["05D"], &["06D"]], + &["05C", "09B"], + 0, + None, + None, + 11, + None, + ); + engine::auto_play(&mut state, &mut rng).unwrap(); + let last_move = state.last_move.as_ref().unwrap(); + assert_eq!("05D", last_move.card); + assert_eq!(vec!["05C".to_string()], last_move.captured); + let table: Vec = state.table.iter().map(Card::code).collect(); + assert_eq!(vec!["09B".to_string()], table); + let captured: Vec = state.players[0].captured.iter().map(Card::code).collect(); + assert_eq!(vec!["05C".to_string(), "05D".to_string()], captured); +} + +#[test] +fn auto_play_can_end_the_hand_and_clears_deadline() { + let mut rng = StdRng::seed_from_u64(13); + let mut state = make_state( + &[&["02D"], &[], &[], &[]], + &["02C"], + 0, + None, + None, + 11, + None, + ); + state.turn_deadline = Some("2000-01-01T00:00:00+00:00".to_string()); + engine::auto_play(&mut state, &mut rng).unwrap(); + assert_eq!("hand_end", state.phase); + assert!(state.turn_deadline.is_none()); + assert!(state.hand_end_deadline.is_some()); +} + +#[test] +fn auto_play_requires_playing_phase() { + let mut rng = StdRng::seed_from_u64(14); + let mut state = make_state( + &[&["02D"], &["04D"], &["05D"], &["06D"]], + &[], + 0, + None, + None, + 11, + None, + ); + state.phase = "hand_end".to_string(); + assert!(engine::auto_play(&mut state, &mut rng).is_err()); +} + +#[test] +fn create_game_copies_turn_timeout_and_arms_deadline() { + let mut rng = StdRng::seed_from_u64(15); + let mut state = engine::create_game("p0", "p0", 11, 30, 7, true, false).unwrap(); + assert_eq!(7, state.turn_timeout); + for i in 1..4 { + let name = format!("p{i}"); + engine::join_game(&mut state, &name, &name, &mut rng).unwrap(); + } + assert_eq!(PHASE_PLAYING, state.phase); + assert!(state.turn_deadline.is_some()); + let view = engine::state_for_player(&state, "p0"); + assert!(view["turn_deadline"].is_string()); +} + +// -- JSON shape ------------------------------------------------------------ + +#[test] +fn state_json_matches_python_keys() { + let mut state = make_state( + &[&["02D"], &["03D"], &["04D"], &["05D"]], + &["07C"], + 0, + Some(&[&["07D"], &[], &[], &[]]), + Some(&[1, 0, 0, 0]), + 11, + Some(1), + ); + state.last_move = Some(Move { + seat: 0, + name: "p0".to_string(), + card: "02D".to_string(), + captured: vec!["02C".to_string()], + scopa: false, + pigliatutto: false, + }); + state.last_taker = Some(0); + state.hand_number = 2; + state.winner = Some(1); + state.acked = vec![0, 2]; + state.hand_end_deadline = Some("2026-01-01T00:00:30+00:00".to_string()); + state.turn_deadline = Some("2026-01-01T00:00:00+00:00".to_string()); + + let value = state.to_json(); + let obj = value.as_object().unwrap(); + let keys: Vec<&str> = obj.keys().map(String::as_str).collect(); + assert_eq!( + vec![ + "target_score", + "napola", + "asso_pigliatutto", + "phase", + "players", + "table", + "dealer", + "turn", + "hand_number", + "scores", + "winner", + "last_taker", + "hand_scores", + "last_move", + "acked", + "hand_end_deadline", + "hand_ack_timeout", + "turn_deadline", + "turn_timeout", + ], + keys + ); + + let player = obj["players"][0].as_object().unwrap(); + let player_keys: Vec<&str> = player.keys().map(String::as_str).collect(); + assert_eq!( + vec!["sub", "name", "seat", "hand", "captured", "scope"], + player_keys + ); + assert_eq!(json!("07D"), player["captured"][0]); + + let move_keys: Vec<&str> = obj["last_move"] + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + assert_eq!( + vec!["seat", "name", "card", "captured", "scopa", "pigliatutto"], + move_keys + ); + assert_eq!(json!([]), obj["hand_scores"]); +} + +#[test] +fn state_json_roundtrip_preserves() { + let mut state = make_state( + &[&["02D"], &["03D"], &["04D"], &["05D"]], + &["07C"], + 0, + Some(&[&["07D"], &[], &[], &[]]), + Some(&[1, 0, 0, 0]), + 11, + Some(1), + ); + state.last_move = Some(Move { + seat: 0, + name: "p0".to_string(), + card: "02D".to_string(), + captured: vec!["02C".to_string()], + scopa: true, + pigliatutto: false, + }); + state.winner = Some(1); + state.acked = vec![0, 2]; + state + .hand_scores + .push(json!({"hand": 1, "team_a_points": 2})); + + let restored = ScoponeState::from_json(&state.to_json()).unwrap(); + assert_eq!(state.to_json(), restored.to_json()); +} + +#[test] +fn invalid_card_code_error_message() { + let mut state = make_state( + &[&["02D"], &["03D"], &["04D"], &["05D"]], + &[], + 0, + None, + None, + 11, + None, + ); + let error = engine::play(&mut state, "p0", "nope", None).unwrap_err(); + assert_eq!("invalid card code", error.as_game().unwrap().message); +} + +#[test] +fn target_score_range_error_message() { + let error = engine::create_game("p0", "p0", 0, 30, 30, true, false).unwrap_err(); + assert_eq!( + "target_score must be between 1 and 100", + error.as_game().unwrap().message + ); +} + +#[test] +fn legal_moves_value_is_json() { + // Sanity: the legal-move map serializes as expected. + let state = make_state( + &[&["02D", "03C"], &["04D"], &["05D"], &["06D"]], + &["07C"], + 0, + None, + None, + 11, + None, + ); + let view: Value = engine::state_for_player(&state, "p0"); + assert!(view["legal_moves"].is_object()); +} diff --git a/server/crates/tavolo-scopone/tests/plugin.rs b/server/crates/tavolo-scopone/tests/plugin.rs new file mode 100644 index 0000000..28b01bc --- /dev/null +++ b/server/crates/tavolo-scopone/tests/plugin.rs @@ -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 { + value.as_object().unwrap().clone() +} + +fn empty() -> Map { + Map::new() +} + +fn capitalize(name: &str) -> String { + let mut chars = name.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + 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) -> Result { + let mut session = base_session(engine); + engine.create(&mut session, &options)?; + Ok(session) +} + +fn started(engine: &ScoponeEngine, options: Map) -> 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::() + .unwrap() +} + +fn state_mut(session: &mut GameSession) -> &mut ScoponeState { + session + .state + .as_mut() + .unwrap() + .downcast_mut::() + .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) -> 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) = { + 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::>()), + ); + } + ("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::().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::>(), + restored + .players + .iter() + .map(|p| p.sub.clone()) + .collect::>() + ); +} + +// -- 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::>()), + ); + } + (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"] + ); +} diff --git a/server/migrations/models/0_20260915224544_init.py b/server/migrations/models/0_20260915224544_init.py deleted file mode 100644 index 3c686bf..0000000 --- a/server/migrations/models/0_20260915224544_init.py +++ /dev/null @@ -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=" -) diff --git a/server/migrations/models/1_20260916235833_update.py b/server/migrations/models/1_20260916235833_update.py deleted file mode 100644 index b58943f..0000000 --- a/server/migrations/models/1_20260916235833_update.py +++ /dev/null @@ -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/" -) diff --git a/server/migrations/models/2_20260918120620_elo.py b/server/migrations/models/2_20260918120620_elo.py deleted file mode 100644 index 6d71634..0000000 --- a/server/migrations/models/2_20260918120620_elo.py +++ /dev/null @@ -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=" -) diff --git a/server/migrations/models/3_20260919065917_generic_results.py b/server/migrations/models/3_20260919065917_generic_results.py deleted file mode 100644 index 68cb287..0000000 --- a/server/migrations/models/3_20260919065917_generic_results.py +++ /dev/null @@ -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==" -) diff --git a/server/packages/tavolo-app/README.md b/server/packages/tavolo-app/README.md deleted file mode 100644 index 6e7cbf8..0000000 --- a/server/packages/tavolo-app/README.md +++ /dev/null @@ -1,38 +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`. -- `telemetry.py` — the optional OpenTelemetry wiring beyond kaya-otel: - meter/tracer/logger providers, the - `tavolo.active_users`/`tavolo.ongoing_games` gauges (polled from the - stores), process/CPython metrics via - `opentelemetry-instrumentation-system-metrics`, Postgres/Redis query - spans via the asyncpg/redis instrumentations, and OTLP log export. -- `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/telemetry.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/`. diff --git a/server/packages/tavolo-app/pyproject.toml b/server/packages/tavolo-app/pyproject.toml deleted file mode 100644 index bfc8905..0000000 --- a/server/packages/tavolo-app/pyproject.toml +++ /dev/null @@ -1,50 +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.7", - "opentelemetry-instrumentation-asyncpg>=0.66b0", - "opentelemetry-instrumentation-redis>=0.66b0", - "opentelemetry-instrumentation-system-metrics>=0.66b0", -] - -[tool.setuptools.packages.find] -where = ["src"] -namespaces = true - -[tool.mypy] -python_version = "3.12" -ignore_missing_imports = true -plugins = [] diff --git a/server/packages/tavolo-app/src/tavolo/aerich_config.py b/server/packages/tavolo-app/src/tavolo/aerich_config.py deleted file mode 100644 index d4416f1..0000000 --- a/server/packages/tavolo-app/src/tavolo/aerich_config.py +++ /dev/null @@ -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", - } - }, -} diff --git a/server/packages/tavolo-app/src/tavolo/app.py b/server/packages/tavolo-app/src/tavolo/app.py deleted file mode 100644 index da2f90d..0000000 --- a/server/packages/tavolo-app/src/tavolo/app.py +++ /dev/null @@ -1,247 +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.OIDCProviders` (the configured OIDC providers) -- :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 Any, Dict, Optional - -from kaya.core import KayaApp, KayaMixin -from kaya.cors import CorsMixin -from kaya.oidc import OIDCConfig, OIDCProviders -from kaya.openapi import OpenAPIMixin -from kaya.session import SessionMixin -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 -from .telemetry import ( - CountableSessionStore, - CountingInMemorySessionStore, - CountingRedisSessionStore, - otel_headers, - session_tag_hook, - telemetry_from_settings, -) - -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, - meter_provider: Optional[Any] = None, - tracer_provider: Optional[Any] = None, -) -> 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. - - ``meter_provider``/``tracer_provider`` are the providers built by - :func:`tavolo.telemetry.telemetry_from_settings`; injecting them lets - the custom instruments and the database instrumentations share the same - OTLP pipelines (kaya-otel then leaves their shutdown to the telemetry - mixin). - """ - 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 - kwargs: Dict[str, Any] = { - "service_name": settings.otel_service_name, - "endpoint": settings.otel_exporter_endpoint, - "headers": otel_headers(settings), - "excluded_paths": settings.otel_excluded_paths, - # Runs after the session mixin's after hook, so the session id has - # been persisted/minted and the OIDC claims are on the session. - "server_response_hook": session_tag_hook, - "websocket_close_hook": session_tag_hook, - } - if meter_provider is not None: - kwargs["meter_provider"] = meter_provider - if tracer_provider is not None: - kwargs["tracer_provider"] = tracer_provider - return OTelMixin(**kwargs) - - -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: CountableSessionStore -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 = CountingRedisSessionStore(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 = CountingInMemorySessionStore() - game_store = InMemoryGameStore(registry) - log.info("REDIS_URL unset: using in-memory stores (sessions + live games)") - -session_mixin = SessionMixin(session_store) -oidc = OIDCProviders( - [ - OIDCConfig( - provider=provider.provider, - issuer=provider.issuer, - client_id=provider.client_id, - client_secret=provider.client_secret, - redirect_uri=provider.redirect_uri, - post_login_redirect=provider.post_login_redirect, - post_logout_redirect=provider.post_logout_redirect, - fetch_userinfo=True, - ) - for provider in settings.oidc_providers - ], - session=session_mixin, -) -log.info( - "OIDC providers: %s", - ", ".join(provider.provider for provider in settings.oidc_providers), -) -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, -) - -mixins: list[KayaMixin] = [session_mixin, oidc, tortoise_mixin, openapi_mixin, - PlatformMixin(platform), DeadlineSchedulerMixin(scheduler)] -telemetry = telemetry_from_settings(settings, session_store, game_store) -if telemetry is not None: - otel_mixin = otel_mixin_from_settings( - settings, - meter_provider=telemetry.meter_provider, - tracer_provider=telemetry.tracer_provider, - ) - assert otel_mixin is not None # OTEL_ENABLED was checked above - # kaya-otel first: 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. The telemetry mixin carries no - # hooks but owns the metric/logger providers' polling and shutdown, so - # it must be in the list too. - mixins.insert(0, telemetry.mixin) - mixins.insert(0, otel_mixin) - log.info( - "OpenTelemetry enabled: service=%s endpoint=%s (traces, metrics, logs)", - 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 diff --git a/server/packages/tavolo-app/src/tavolo/config.py b/server/packages/tavolo-app/src/tavolo/config.py deleted file mode 100644 index ea1dd12..0000000 --- a/server/packages/tavolo-app/src/tavolo/config.py +++ /dev/null @@ -1,315 +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 -import re -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") - - -# Per-provider OIDC settings are indexed with a 0-based suffix: -# OIDC__PROVIDER, OIDC__ISSUER, ... Indices must be contiguous from 0. -_OIDC_VARIABLES = ( - "PROVIDER", - "ISSUER", - "CLIENT_ID", - "CLIENT_SECRET", - "REDIRECT_URI", - "POST_LOGIN_REDIRECT", - "POST_LOGOUT_REDIRECT", -) -_OIDC_ENV = re.compile(r"^OIDC_(\d+)_([A-Z0-9_]+)$") -_PROVIDER_NAME = re.compile(r"^[A-Za-z0-9_-]+$") - -# The pre-0.0.6 unindexed names, detected only to point the migration out. -_LEGACY_OIDC_VARIABLES = ( - "OIDC_ISSUER", - "OIDC_CLIENT_ID", - "OIDC_CLIENT_SECRET", - "OIDC_REDIRECT_URI", - "OIDC_POST_LOGIN_REDIRECT", - "OIDC_POST_LOGOUT_REDIRECT", -) - -_DEFAULT_OIDC_ISSUER = "http://localhost:8180/tavolo" -_DEFAULT_OIDC_CLIENT_ID = "tavolo" - - -@dataclass(frozen=True) -class OidcProviderSettings: - """One OIDC provider, parsed from the ``OIDC__*`` variables. - - ``provider`` names the provider in URLs (``/auth//login``) - and namespaces its session state (``oidc::...``); it must - match kaya-oidc's ``[A-Za-z0-9_-]+`` pattern. - """ - - provider: str - issuer: str - client_id: str - client_secret: Optional[str] - redirect_uri: str - post_login_redirect: str - post_logout_redirect: str - - -def _oidc_providers_from_env() -> Tuple[OidcProviderSettings, ...]: - """Parse the indexed ``OIDC__*`` provider configuration. - - With no indexed variables at all, fall back to a single provider - named ``default`` pointing at the dev mock IdP, mirroring the - pre-0.0.6 defaults. The old unindexed variables are rejected with a - migration hint: they would otherwise be silently ignored. - """ - legacy = [name for name in _LEGACY_OIDC_VARIABLES if os.environ.get(name)] - if legacy: - renamed = ", ".join( - f"{name} -> OIDC_0_{name[len('OIDC_'):]}" for name in legacy - ) - raise RuntimeError( - "the OIDC variables are now indexed per provider " - f"({renamed}; add OIDC_0_PROVIDER)" - ) - - indices: set = set() - for name in os.environ: - match = _OIDC_ENV.match(name) - if match is None: - continue - index, variable = match.groups() - if variable not in _OIDC_VARIABLES: - raise RuntimeError( - f"unknown OIDC environment variable {name!r}; expected one of " - + ", ".join(f"OIDC__{v}" for v in _OIDC_VARIABLES) - ) - indices.add(int(index)) - - if not indices: - return ( - OidcProviderSettings( - provider="default", - issuer=_DEFAULT_OIDC_ISSUER, - client_id=_DEFAULT_OIDC_CLIENT_ID, - client_secret=None, - redirect_uri="http://localhost:8080/auth/default/callback", - post_login_redirect="/", - post_logout_redirect="/", - ), - ) - - missing = sorted(set(range(max(indices) + 1)) - indices) - if missing: - raise RuntimeError( - "OIDC provider indices must be contiguous from 0; missing " - + ", ".join(f"OIDC_{index}_*" for index in missing) - ) - - providers = [] - seen = set() - for index in sorted(indices): - prefix = f"OIDC_{index}_" - provider = _env(prefix + "PROVIDER") - if _PROVIDER_NAME.match(provider) is None: - raise RuntimeError( - f"{prefix}PROVIDER must be a non-empty string of letters, " - f"digits, underscores or hyphens (got {provider!r})" - ) - if provider in seen: - raise RuntimeError(f"duplicate OIDC provider name {provider!r}") - seen.add(provider) - providers.append( - OidcProviderSettings( - provider=provider, - issuer=_env(prefix + "ISSUER"), - client_id=_env(prefix + "CLIENT_ID"), - client_secret=os.environ.get(prefix + "CLIENT_SECRET") or None, - # kaya-oidc's OIDCProviders derives the callback route from - # the provider name; keep the default in sync with it. - redirect_uri=_env( - prefix + "REDIRECT_URI", - f"http://localhost:8080/auth/{provider}/callback", - ), - post_login_redirect=_env(prefix + "POST_LOGIN_REDIRECT", "/"), - post_logout_redirect=_env(prefix + "POST_LOGOUT_REDIRECT", "/"), - ) - ) - return tuple(providers) - - -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 - # The configured OIDC providers, in index order. Each provider - # authenticates users independently and namespaces its session state; - # see OidcProviderSettings above. - oidc_providers: Tuple[OidcProviderSettings, ...] - 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 - # when nothing else wakes it. Enqueued deadlines are announced over a - # pub/sub channel, so this is only the backstop recovering lost - # notifications and deadlines orphaned by a dead worker. - 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, ...] - # How often the OTLP metric reader exports (milliseconds) and how often - # the telemetry gauges refresh the session/game counts (seconds). - otel_metric_export_interval_millis: int - otel_metrics_poll_interval_seconds: int - - @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_providers=_oidc_providers_from_env(), - 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", "30000")), - 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, - # metrics and logs 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",), - otel_metric_export_interval_millis=int( - _env("OTEL_METRIC_EXPORT_INTERVAL_MILLIS", "60000") - ), - otel_metrics_poll_interval_seconds=int( - _env("OTEL_METRICS_POLL_INTERVAL_SECONDS", "15") - ), - ) - - -settings: Settings = Settings.from_env() diff --git a/server/packages/tavolo-app/src/tavolo/logging_config.py b/server/packages/tavolo-app/src/tavolo/logging_config.py deleted file mode 100644 index bcf008c..0000000 --- a/server/packages/tavolo-app/src/tavolo/logging_config.py +++ /dev/null @@ -1,83 +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}``; -``urllib3.connectionpool`` is capped at INFO so its per-request DEBUG -chatter does not drown the log. -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"], - }, - "loggers": { - # Every pooled HTTP request is a DEBUG record; keep the logger's - # INFO+ output, drop the per-connection noise. - "urllib3.connectionpool": { - "level": "INFO", - }, - }, -} - - -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) diff --git a/server/packages/tavolo-app/src/tavolo/static.py b/server/packages/tavolo-app/src/tavolo/static.py deleted file mode 100644 index 067525a..0000000 --- a/server/packages/tavolo-app/src/tavolo/static.py +++ /dev/null @@ -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/`` 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) diff --git a/server/packages/tavolo-app/src/tavolo/telemetry.py b/server/packages/tavolo-app/src/tavolo/telemetry.py deleted file mode 100644 index a68a8df..0000000 --- a/server/packages/tavolo-app/src/tavolo/telemetry.py +++ /dev/null @@ -1,375 +0,0 @@ -"""OpenTelemetry metrics and log export beyond kaya-otel. - -kaya-otel instruments HTTP requests and websockets with traces and HTTP -metrics; this module adds what it does not cover, all behind the optional -``otel`` extra (OpenTelemetry imports are lazy, so default installs and -the test suite never need them): - -* application gauges: ``tavolo.active_users`` (sessions held in the - session store) and ``tavolo.ongoing_games`` (live matches that have not - finished yet); -* process metrics through ``opentelemetry-instrumentation-system-metrics``: - CPU time/utilization, memory usage and CPython garbage-collection - counters; -* OTLP log export through a stdlib :class:`logging.Handler` attached to - the root logger, mirroring what the console sees; -* session/user tagging of request and websocket spans through - :func:`session_tag_hook` (kaya-otel's response/close hooks), so traces - can be correlated with a session (hashed id) and an OIDC user. - -The store counts are asynchronous while OpenTelemetry instrument -callbacks are synchronous, so :class:`TelemetryMixin` polls the stores on -a background task and its callbacks report the cached values. -""" -from __future__ import annotations - -import asyncio -import hashlib -import logging -from dataclasses import dataclass -from logging import getLogger -from typing import TYPE_CHECKING, Any, Dict, Optional, Union - -from kaya.core import KayaApp, KayaMixin -from kaya.session import InMemorySessionStore, SessionStore -from kaya.session.redis import RedisSessionStore -from redis.asyncio import Redis -from tavolo.platform.auth import session_user -from tavolo.platform.store import GameStore - -from .config import Settings - -if TYPE_CHECKING: - from opentelemetry.sdk._logs import LoggerProvider - from opentelemetry.sdk.metrics import MeterProvider - from opentelemetry.sdk.trace import TracerProvider - -log = getLogger(__name__) - -# Session keys live under kaya's default prefix. Keeping it explicit here -# makes the Redis count scan exact and preserves existing sessions. -SESSION_KEY_PREFIX = "kaya:session:" - -# The interpreter metrics to export. The instrumentation package can also -# emit host-level ``system.*`` metrics; those are deliberately left off: -# the application cares about its own process, and per-CPU host metrics -# add cardinality without context. -# -# ``process.cpu.utilization`` ignores its configuration value upstream -# (the callback always emits one aggregate observation), so its ``None`` -# is only for readability. -PROCESS_METRICS_CONFIG: Dict[str, Optional[list]] = { - "process.cpu.time": ["user", "system"], - "process.cpu.utilization": None, - "process.memory.usage": None, - "process.memory.virtual": None, - "cpython.gc.collections": None, - "cpython.gc.collected_objects": None, - "cpython.gc.uncollectable_objects": None, -} - - -def otel_headers(settings: Settings) -> Optional[Dict[str, str]]: - """Parse ``OTEL_EXPORTER_OTLP_HEADERS`` into a mapping (or ``None``).""" - headers = dict( - pair.split("=", 1) - for pair in (settings.otel_exporter_headers or ()) - if "=" in pair - ) - return headers or None - - -def _signal_endpoint(base: Optional[str], signal: str) -> Optional[str]: - """``{base}/v1/``, or ``None`` for the exporter's own default.""" - return f"{base}/{signal}" if base else None - - -def session_tag_hook(span: Any, carrier: Any) -> None: - """Tag an HTTP request/websocket span with the request's session/user. - - Wired into kaya-otel as its ``server_response_hook`` and - ``websocket_close_hook``: those run after the session mixin's after - hook has persisted (and, at login, minted) the session, so the final - session id is visible here. - - The session id is the session cookie, i.e. a credential, so only a - truncated SHA-256 of it is exported; session ids are - ``secrets.token_urlsafe(32)`` (256 bits), which makes the digest - irreversible in practice. The OIDC ``sub`` is a pseudonymous - identifier already persisted in Postgres and served by the API, so it - is exported verbatim as ``user.id`` (OpenTelemetry's current end-user - attribute; ``enduser.id`` is PII-flagged and being superseded). - """ - session = getattr(carrier, "session", None) - if session is None: - return - session_id = getattr(session, "id", None) - if session_id: - span.set_attribute( - "session.id", - hashlib.sha256(session_id.encode()).hexdigest()[:16], - ) - user = session_user(session) - if user is not None: - sub = user.get("sub") - if sub: - span.set_attribute("user.id", str(sub)) - - -class _NoOtelLogs(logging.Filter): - """Drop OpenTelemetry's own log records. - - Export failures are logged by the SDK; forwarding those through the - same exporter would feed an endless retry loop. - """ - - def filter(self, record: logging.LogRecord) -> bool: - return not record.name.startswith("opentelemetry") - - -class CountingInMemorySessionStore(InMemorySessionStore): - """In-memory session store that can report its live session count.""" - - async def count(self) -> int: - now = self._clock() - return sum( - 1 - for session_id in self._data - if session_id not in self._expires - or self._expires[session_id] > now - ) - - -class CountingRedisSessionStore(RedisSessionStore): - """Redis session store that can report its live session count.""" - - def __init__(self, client: Redis, prefix: str = SESSION_KEY_PREFIX) -> None: - super().__init__(client, prefix=prefix) - - async def count(self) -> int: - total = 0 - async for _ in self._client.scan_iter(match=f"{self._prefix}*", count=500): - total += 1 - return total - - -# The session stores the application builds: both expose ``count()`` on top -# of kaya's ``SessionStore`` interface. Game stores need no alias — the -# platform's ``GameStore`` declares ``count_ongoing()`` itself. -CountableSessionStore = Union[CountingInMemorySessionStore, CountingRedisSessionStore] - - -class TelemetryMixin(KayaMixin): - """Application gauges, DB instrumentation and log export. - - The meter, tracer and logger providers are built by - :func:`telemetry_from_settings` and injected: kaya-otel only shuts - down providers it created itself, so this mixin owns their shutdown - (and uninstruments the database clients it instrumented). - """ - - def __init__( - self, - *, - meter_provider: "MeterProvider", - tracer_provider: "TracerProvider", - logger_provider: Optional["LoggerProvider"], - log_handler: Optional[logging.Handler], - session_store: CountableSessionStore, - game_store: GameStore, - poll_interval_seconds: float, - ) -> None: - self._meter_provider = meter_provider - self._tracer_provider = tracer_provider - self._logger_provider = logger_provider - self._log_handler = log_handler - self._session_store = session_store - self._game_store = game_store - self._poll_interval = poll_interval_seconds - self._active_users: Optional[int] = None - self._ongoing_games: Optional[int] = None - self._poll_task: Optional[asyncio.Task] = None - self._instrumentor: Optional[Any] = None - self._db_instrumentors: list = [] - - def apply(self, app: KayaApp) -> None: - from opentelemetry.instrumentation.asyncpg import AsyncPGInstrumentor - from opentelemetry.instrumentation.redis import RedisInstrumentor - from opentelemetry.instrumentation.system_metrics import ( - SystemMetricsInstrumentor, - ) - from opentelemetry.metrics import CallbackOptions, Observation - - instrumentor = SystemMetricsInstrumentor(config=PROCESS_METRICS_CONFIG) - self._instrumentor = instrumentor - instrumentor.instrument(meter_provider=self._meter_provider) - - # Query spans for the Postgres (asyncpg, used by Tortoise) and Redis - # clients. Redis command values are redacted by the instrumentation - # (``SET ? ?``); asyncpg spans carry the parameterized SQL only. - self._db_instrumentors = [ - AsyncPGInstrumentor(capture_parameters=False), - RedisInstrumentor(), - ] - for db_instrumentor in self._db_instrumentors: - db_instrumentor.instrument(tracer_provider=self._tracer_provider) - - meter = self._meter_provider.get_meter(__name__) - - def active_users(options: CallbackOptions): - if self._active_users is not None: - yield Observation(self._active_users) - - def ongoing_games(options: CallbackOptions): - if self._ongoing_games is not None: - yield Observation(self._ongoing_games) - - meter.create_observable_gauge( - "tavolo.active_users", - callbacks=[active_users], - unit="{user}", - description="Sessions currently held in the session store.", - ) - meter.create_observable_gauge( - "tavolo.ongoing_games", - callbacks=[ongoing_games], - unit="{game}", - description="Live games that have not finished yet.", - ) - - # -- lifecycle ---------------------------------------------------------- - - def setup(self, loop: asyncio.AbstractEventLoop) -> None: - self._poll_task = loop.create_task(self._poll()) - - def shutdown(self, loop: asyncio.AbstractEventLoop) -> None: - if self._poll_task is not None: - self._poll_task.cancel() - self._poll_task = None - for db_instrumentor in self._db_instrumentors: - db_instrumentor.uninstrument() - self._db_instrumentors = [] - if self._log_handler is not None: - logging.getLogger().removeHandler(self._log_handler) - self._log_handler.close() - self._log_handler = None - if self._logger_provider is not None: - self._logger_provider.shutdown() - self._tracer_provider.shutdown() - self._meter_provider.shutdown() - - async def _poll(self) -> None: - while True: - await self.refresh_counts() - await asyncio.sleep(self._poll_interval) - - async def refresh_counts(self) -> None: - """Refresh the cached store counts (the poll loop calls this).""" - try: - self._active_users = await self._session_store.count() - except Exception: - log.warning("active_users refresh failed", exc_info=True) - try: - self._ongoing_games = await self._game_store.count_ongoing() - except Exception: - log.warning("ongoing_games refresh failed", exc_info=True) - - -@dataclass -class Telemetry: - """The providers and mixin built from the ``OTEL_*`` settings.""" - - meter_provider: "MeterProvider" - tracer_provider: "TracerProvider" - logger_provider: Optional["LoggerProvider"] - log_handler: Optional[logging.Handler] - mixin: TelemetryMixin - - -def telemetry_from_settings( - settings: Settings, - session_store: CountableSessionStore, - game_store: GameStore, -) -> Optional[Telemetry]: - """Build the metric/trace/log providers, or ``None`` when telemetry is off. - - Only called when ``OTEL_ENABLED`` is set; the OpenTelemetry imports - are lazy so the default install does not need the ``otel`` extra. - """ - if not settings.otel_enabled: - return None - try: - from opentelemetry.exporter.otlp.proto.http._log_exporter import ( - OTLPLogExporter, - ) - from opentelemetry.exporter.otlp.proto.http.metric_exporter import ( - OTLPMetricExporter, - ) - from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( - OTLPSpanExporter, - ) - from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler - from opentelemetry.sdk._logs.export import BatchLogRecordProcessor - from opentelemetry.sdk.metrics import MeterProvider - from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader - from opentelemetry.sdk.resources import Resource - from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.sdk.trace.export import BatchSpanProcessor - except ImportError as exc: - raise RuntimeError( - "OTEL_ENABLED is set but the OpenTelemetry dependencies are not " - "installed; install tavolo-app with the 'otel' extra" - ) from exc - - endpoint = settings.otel_exporter_endpoint - headers = otel_headers(settings) - resource = Resource.create({"service.name": settings.otel_service_name}) - tracer_provider = TracerProvider(resource=resource) - tracer_provider.add_span_processor( - BatchSpanProcessor( - OTLPSpanExporter( - endpoint=_signal_endpoint(endpoint, "v1/traces"), - headers=headers, - ) - ) - ) - meter_provider = MeterProvider( - resource=resource, - metric_readers=[ - PeriodicExportingMetricReader( - OTLPMetricExporter( - endpoint=_signal_endpoint(endpoint, "v1/metrics"), - headers=headers, - ), - export_interval_millis=settings.otel_metric_export_interval_millis, - ) - ], - ) - logger_provider = LoggerProvider(resource=resource) - logger_provider.add_log_record_processor( - BatchLogRecordProcessor( - OTLPLogExporter( - endpoint=_signal_endpoint(endpoint, "v1/logs"), - headers=headers, - ) - ) - ) - handler = LoggingHandler(level=logging.NOTSET, logger_provider=logger_provider) - handler.addFilter(_NoOtelLogs()) - logging.getLogger().addHandler(handler) - mixin = TelemetryMixin( - meter_provider=meter_provider, - tracer_provider=tracer_provider, - logger_provider=logger_provider, - log_handler=handler, - session_store=session_store, - game_store=game_store, - poll_interval_seconds=settings.otel_metrics_poll_interval_seconds, - ) - return Telemetry( - meter_provider=meter_provider, - tracer_provider=tracer_provider, - logger_provider=logger_provider, - log_handler=handler, - mixin=mixin, - ) diff --git a/server/packages/tavolo-platform/README.md b/server/packages/tavolo-platform/README.md deleted file mode 100644 index e85c2b9..0000000 --- a/server/packages/tavolo-platform/README.md +++ /dev/null @@ -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. diff --git a/server/packages/tavolo-platform/pyproject.toml b/server/packages/tavolo-platform/pyproject.toml deleted file mode 100644 index 27089b5..0000000 --- a/server/packages/tavolo-platform/pyproject.toml +++ /dev/null @@ -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>=0.0.7", - "kaya-session", - "kaya-oidc>=0.0.7", - "kaya-openapi", - "tortoise-orm", - "redis", -] - -[tool.setuptools.packages.find] -where = ["src"] -namespaces = true - -[tool.mypy] -python_version = "3.12" -ignore_missing_imports = true -plugins = [] - -# TortoiseORM auto-generates `_id` attributes on ForeignKeyField at -# runtime; without the (unavailable here) tortoise mypy plugin the stubs -# only declare the relation field. These are real attributes, not bugs. -[[tool.mypy.overrides]] -module = "tavolo.platform.models" -disable_error_code = ["attr-defined"] - -[[tool.mypy.overrides]] -module = "tavolo.platform.routes.*" -disable_error_code = ["attr-defined"] diff --git a/server/packages/tavolo-platform/src/tavolo/platform/__init__.py b/server/packages/tavolo-platform/src/tavolo/platform/__init__.py deleted file mode 100644 index 2cfc065..0000000 --- a/server/packages/tavolo-platform/src/tavolo/platform/__init__.py +++ /dev/null @@ -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", -] diff --git a/server/packages/tavolo-platform/src/tavolo/platform/auth.py b/server/packages/tavolo-platform/src/tavolo/platform/auth.py deleted file mode 100644 index 7c85b20..0000000 --- a/server/packages/tavolo-platform/src/tavolo/platform/auth.py +++ /dev/null @@ -1,88 +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 OIDCProviders, OIDCUser -from kaya.session import Session - -# kaya-oidc namespaces its session state per provider as -# ``oidc:{provider}:user`` (since 0.0.6), so the lookup cannot assume a -# fixed key or provider name. -_OIDC_PREFIX = "oidc:" -_USER_SUFFIX = ":user" - - -def session_user(session: Session) -> Optional[OIDCUser]: - """Return the authenticated user stored in a session, if any. - - The kaya-oidc mixin writes the userinfo at login under a per-provider - key, so any provider's user is accepted (the configured provider is - ``default`` unless the app says otherwise). - """ - for key in session: - if key.startswith(_OIDC_PREFIX) and key.endswith(_USER_SUFFIX): - claims = session.get(key) - if isinstance(claims, Mapping): - return OIDCUser(claims) - return None - - -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 - return session_user(session) - - -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 current_user(oidc: OIDCProviders, ctx: HttpContext) -> Optional[OIDCUser]: - """Return the authenticated user of an HTTP request, if any. - - Sessions are single-identity by default, so at most one provider has - a user; if several do, the first configured provider wins. - """ - return next(iter(oidc.get_users(ctx).values()), None) - - -def require_auth(oidc: OIDCProviders) -> 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 current_user(oidc, 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 diff --git a/server/packages/tavolo-platform/src/tavolo/platform/backfill_elo.py b/server/packages/tavolo-platform/src/tavolo/platform/backfill_elo.py deleted file mode 100644 index 9b1aac5..0000000 --- a/server/packages/tavolo-platform/src/tavolo/platform/backfill_elo.py +++ /dev/null @@ -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()) diff --git a/server/packages/tavolo-platform/src/tavolo/platform/deadlines.py b/server/packages/tavolo-platform/src/tavolo/platform/deadlines.py deleted file mode 100644 index 1490a99..0000000 --- a/server/packages/tavolo-platform/src/tavolo/platform/deadlines.py +++ /dev/null @@ -1,310 +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; -* enqueueing a deadline announces it on a shared pub/sub channel, so - every worker's consumer wakes immediately instead of waiting for its - next poll; the consumer also wakes on a slow heartbeat, which is the - backstop recovering notifications lost to a reconnect or a worker - crash. Due entries are handed 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, its listener task and its wake-up - event are 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, - retry_ms: int = 1000, - context_binder: Optional[Callable[[], Awaitable[None]]] = None, - ) -> None: - self._store = store - self._registry = registry - self._heartbeat = heartbeat_ms / 1000 - # Backoff for entries that failed to process and for listener - # reconnects; deliberately independent of the (slow) heartbeat. - self._retry_delay = retry_ms / 1000 - self._context_binder = context_binder - self._consumers: Dict[asyncio.AbstractEventLoop, asyncio.Task] = {} - self._wake_events: Dict[asyncio.AbstractEventLoop, asyncio.Event] = {} - self._listeners: Dict[asyncio.AbstractEventLoop, asyncio.Task] = {} - - 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) - # Announce the enqueue so consumers on other workers wake up too. - # The entry is already queued, so a lost notification only delays - # their discovery to the next poll backstop. - try: - await self._store.notify_deadline() - except Exception: - log.warning( - "deadline enqueue notification failed; relying on the poll backstop", - exc_info=True, - ) - 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) - self._listeners.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)) - listener = self._listeners.get(loop) - if listener is None or listener.done(): - self._listeners[loop] = loop.create_task(self._listen(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) - listener = self._listeners.pop(loop, None) - if listener is not None: - listener.cancel() - 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 briefly instead of - # polling it again immediately. Kept independent of the - # heartbeat, which is only a slow backstop. - delay = self._retry_delay - try: - await asyncio.wait_for(wake.wait(), timeout=delay) - except asyncio.TimeoutError: - pass - - async def _listen(self, loop: asyncio.AbstractEventLoop) -> None: - """Wake the consumer on deadline-enqueue notifications. - - The subscription is best effort: a dropped connection or a lost - message is recovered by the consumer's heartbeat backstop, so - this task only has to reconnect and keep forwarding signals. - """ - while True: - try: - async with self._store.subscribe_deadlines() as events: - async for _ in events: - wake = self._wake_events.get(loop) - if wake is None: - # Shut down: the consumer is gone. - return - wake.set() - except asyncio.CancelledError: - raise - except Exception: - log.exception("deadline listener failed; reconnecting") - try: - await asyncio.sleep(self._retry_delay) - except asyncio.CancelledError: - raise - - -class DeadlineSchedulerMixin(KayaMixin): - """Run the deadline consumer for the whole app lifetime. - - Every worker (and every pod) runs the same consumer and listener; - coordination happens exclusively through the shared deadline queue, - its notification channel and the per-game locks, so any worker may - fire any game's deadline. - """ - - def __init__(self, scheduler: DeadlineScheduler) -> None: - self._scheduler = scheduler - - def apply(self, app: KayaApp) -> None: - pass - - def setup(self, loop: asyncio.AbstractEventLoop) -> None: - self._scheduler.ensure_consumer(loop) - - def shutdown(self, loop: asyncio.AbstractEventLoop) -> None: - self._scheduler.stop_consumer(loop) diff --git a/server/packages/tavolo-platform/src/tavolo/platform/elo.py b/server/packages/tavolo-platform/src/tavolo/platform/elo.py deleted file mode 100644 index 9fbc9a2..0000000 --- a/server/packages/tavolo-platform/src/tavolo/platform/elo.py +++ /dev/null @@ -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)) diff --git a/server/packages/tavolo-platform/src/tavolo/platform/engine.py b/server/packages/tavolo-platform/src/tavolo/platform/engine.py deleted file mode 100644 index 3fee068..0000000 --- a/server/packages/tavolo-platform/src/tavolo/platform/engine.py +++ /dev/null @@ -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 {} diff --git a/server/packages/tavolo-platform/src/tavolo/platform/errors.py b/server/packages/tavolo-platform/src/tavolo/platform/errors.py deleted file mode 100644 index 1817c20..0000000 --- a/server/packages/tavolo-platform/src/tavolo/platform/errors.py +++ /dev/null @@ -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.""" diff --git a/server/packages/tavolo-platform/src/tavolo/platform/http.py b/server/packages/tavolo-platform/src/tavolo/platform/http.py deleted file mode 100644 index bcd6ece..0000000 --- a/server/packages/tavolo-platform/src/tavolo/platform/http.py +++ /dev/null @@ -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}) diff --git a/server/packages/tavolo-platform/src/tavolo/platform/mixin.py b/server/packages/tavolo-platform/src/tavolo/platform/mixin.py deleted file mode 100644 index 2a83cf0..0000000 --- a/server/packages/tavolo-platform/src/tavolo/platform/mixin.py +++ /dev/null @@ -1,51 +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 providers 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 OIDCProviders - -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: OIDCProviders - - -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 auth, games, health, me, stats - - health.register(app, self.platform) - auth.register(app, self.platform) - me.register(app, self.platform) - games.register(app, self.platform) - stats.register(app, self.platform) - ws.register(app, self.platform) diff --git a/server/packages/tavolo-platform/src/tavolo/platform/models.py b/server/packages/tavolo-platform/src/tavolo/platform/models.py deleted file mode 100644 index f8d9969..0000000 --- a/server/packages/tavolo-platform/src/tavolo/platform/models.py +++ /dev/null @@ -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"),) diff --git a/server/packages/tavolo-platform/src/tavolo/platform/openapi.py b/server/packages/tavolo-platform/src/tavolo/platform/openapi.py deleted file mode 100644 index b5d0220..0000000 --- a/server/packages/tavolo-platform/src/tavolo/platform/openapi.py +++ /dev/null @@ -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.", - }, -] diff --git a/server/packages/tavolo-platform/src/tavolo/platform/pagination.py b/server/packages/tavolo-platform/src/tavolo/platform/pagination.py deleted file mode 100644 index 1bd992b..0000000 --- a/server/packages/tavolo-platform/src/tavolo/platform/pagination.py +++ /dev/null @@ -1,154 +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 TYPE_CHECKING, Any, Dict, List, Optional, Tuple - -from tortoise.queryset import QuerySet - -from .http import extract_query_params - -if TYPE_CHECKING: - from tortoise.models import Model - -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 _coerce_sort_values( - model: "type[Model]", sort: Sort, after: Dict[str, Any] -) -> Dict[str, Any]: - """Restore the Python type of every decoded cursor value. - - The cursor is JSON, so field values travel as strings (datetimes via - ``default=str``). asyncpg rejects strings where a ``timestamp`` - parameter is expected, and sqlite only tolerates them by accident, so - each value is run through its model field's ``to_python_value`` before - it reaches the keyset filter. - """ - fields = model._meta.fields_map - coerced: Dict[str, Any] = {} - for field, _ in sort: - value = after.get(field) - field_obj = fields.get(field) - if value is not None and field_obj is not None: - value = field_obj.to_python_value(value) - coerced[field] = value - return coerced - - -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: - after = _coerce_sort_values(queryset.model, sort, cursor.after) - qs = qs.filter(_keyset_where(sort, 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) diff --git a/server/packages/tavolo-platform/src/tavolo/platform/py.typed b/server/packages/tavolo-platform/src/tavolo/platform/py.typed deleted file mode 100644 index e69de29..0000000 diff --git a/server/packages/tavolo-platform/src/tavolo/platform/registry.py b/server/packages/tavolo-platform/src/tavolo/platform/registry.py deleted file mode 100644 index 1096a22..0000000 --- a/server/packages/tavolo-platform/src/tavolo/platform/registry.py +++ /dev/null @@ -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()) diff --git a/server/packages/tavolo-platform/src/tavolo/platform/routes/__init__.py b/server/packages/tavolo-platform/src/tavolo/platform/routes/__init__.py deleted file mode 100644 index d117088..0000000 --- a/server/packages/tavolo-platform/src/tavolo/platform/routes/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""HTTP endpoints of the platform: health, identity, lobby, statistics.""" diff --git a/server/packages/tavolo-platform/src/tavolo/platform/routes/auth.py b/server/packages/tavolo-platform/src/tavolo/platform/routes/auth.py deleted file mode 100644 index f0d9cfd..0000000 --- a/server/packages/tavolo-platform/src/tavolo/platform/routes/auth.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Authentication plumbing for the single-page app. - -kaya-oidc registers one login/callback/logout route per provider -(``/auth//...``); 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), so the UI can keep a - single logout link. -""" -from __future__ import annotations - -from typing import TYPE_CHECKING - -from kaya.core import HttpContext, KayaApp -from kaya.openapi import operation - -from ..http import send_json - -if TYPE_CHECKING: - from ..mixin import Platform - - -def _login_url(platform: "Platform", provider: str) -> str: - return f"{platform.oidc.base_path}/{provider}/login" - - -def register(app: KayaApp, platform: "Platform") -> None: - providers = list(platform.oidc.providers) - - @app.GET("/api/auth/providers") - @operation(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"}}) - async def auth_providers(ctx: HttpContext) -> None: - await send_json( - ctx, - 200, - { - "providers": [ - {"name": name, "login_url": _login_url(platform, name)} - for name in providers - ] - }, - ) - - @app.GET("/auth/login") - async def login(ctx: HttpContext) -> None: - await ctx.send_empty(302, {"Location": _login_url(platform, providers[0])}) - - @app.GET("/auth/logout") - async def logout(ctx: HttpContext) -> None: - logged_in = platform.oidc.logged_in_providers(ctx) - location = ( - f"{platform.oidc.base_path}/{logged_in[0]}/logout" if logged_in else "/" - ) - await ctx.send_empty(302, {"Location": location}) diff --git a/server/packages/tavolo-platform/src/tavolo/platform/routes/games.py b/server/packages/tavolo-platform/src/tavolo/platform/routes/games.py deleted file mode 100644 index 792f30f..0000000 --- a/server/packages/tavolo-platform/src/tavolo/platform/routes/games.py +++ /dev/null @@ -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 current_user, 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 = current_user(platform.oidc, 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 = current_user(platform.oidc, 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 = current_user(platform.oidc, ctx) - assert user is not None - if not session.seated(user.sub): - await send_error(ctx, 403, "forbidden") - return - await send_json(ctx, 200, _view_payload(platform, session, user.sub)) diff --git a/server/packages/tavolo-platform/src/tavolo/platform/routes/health.py b/server/packages/tavolo-platform/src/tavolo/platform/routes/health.py deleted file mode 100644 index 15261ad..0000000 --- a/server/packages/tavolo-platform/src/tavolo/platform/routes/health.py +++ /dev/null @@ -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",)}, - ) diff --git a/server/packages/tavolo-platform/src/tavolo/platform/routes/me.py b/server/packages/tavolo-platform/src/tavolo/platform/routes/me.py deleted file mode 100644 index d53e04a..0000000 --- a/server/packages/tavolo-platform/src/tavolo/platform/routes/me.py +++ /dev/null @@ -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 current_user, 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 = current_user(platform.oidc, ctx) - assert user is not None # enforced by @require_auth - await send_json(ctx, 200, {"sub": user.sub, "name": display_name(user)}) diff --git a/server/packages/tavolo-platform/src/tavolo/platform/routes/stats.py b/server/packages/tavolo-platform/src/tavolo/platform/routes/stats.py deleted file mode 100644 index d416e54..0000000 --- a/server/packages/tavolo-platform/src/tavolo/platform/routes/stats.py +++ /dev/null @@ -1,208 +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 collections import defaultdict -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple -from uuid import UUID - -from kaya.core import HttpContext, KayaApp -from kaya.openapi import operation - -from ..auth import current_user, 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 - - -def _serialize_match( - match: Match, viewer: str, participants: List[MatchPlayer] -) -> Dict[str, Any]: - 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 = current_user(platform.oidc, 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 - ) - # One participants query for the whole page instead of one per - # match; the grouping keeps each match's players seat-ordered. - by_match: Dict[UUID, List[MatchPlayer]] = defaultdict(list) - if matches: - participants = ( - await MatchPlayer.filter(match_id__in=[m.id for m in matches]) - .order_by("match_id", "seat") - ) - for player in participants: - # Tortoise sets the FK's raw id on instances but does not - # declare it on the model class, hence the getattr. - by_match[getattr(player, "match_id")].append(player) - results = [ - _serialize_match(m, user.sub, by_match.get(m.id, [])) 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 = current_user(platform.oidc, ctx) - assert user is not None - rows = await PlayerRating.filter(user_sub=user.sub).order_by("game_type") - await send_json(ctx, 200, { - "results": [ - { - "game_type": row.game_type, - "rating": row.rating, - "matches_played": row.matches_played, - } - for row in rows - ] - }) diff --git a/server/packages/tavolo-platform/src/tavolo/platform/stats.py b/server/packages/tavolo-platform/src/tavolo/platform/stats.py deleted file mode 100644 index ddd23ff..0000000 --- a/server/packages/tavolo-platform/src/tavolo/platform/stats.py +++ /dev/null @@ -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), - ) diff --git a/server/packages/tavolo-platform/src/tavolo/platform/store.py b/server/packages/tavolo-platform/src/tavolo/platform/store.py deleted file mode 100644 index b6456a3..0000000 --- a/server/packages/tavolo-platform/src/tavolo/platform/store.py +++ /dev/null @@ -1,403 +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:`` 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 announce it -on a shared "deadline enqueued" channel, and a consumer on every worker -wakes on that signal to process 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 -import time -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" -# Channel announcing newly enqueued deadline entries, so every worker's -# consumer can wake immediately instead of polling on its heartbeat. -DEADLINES_CHANNEL = "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. -ONGOING_GAMES_KEY = "tavolo:games:ongoing" - -# 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 - async def count_ongoing(self) -> int: - """Return the number of live games that have not finished yet. - - Lobbies and matches in progress count; matches that are over but - still retained for the final-board view do not. - """ - - @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.""" - - @abstractmethod - async def notify_deadline(self) -> None: - """Wake every worker's deadline consumer (best effort). - - Notification is advisory: consumers stay correct when a signal - is lost, because their poll backstop recovers it. - """ - - @abstractmethod - def subscribe_deadlines(self) -> AsyncContextManager[AsyncIterator[None]]: - """Async context manager yielding deadline-enqueue signals.""" - - -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)) - engine = self._registry.require(session.game_type) - 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) - if engine.is_finished(session): - pipe.zrem(ONGOING_GAMES_KEY, session.id) - else: - pipe.zadd(ONGOING_GAMES_KEY, {session.id: time.time() + self._ttl}) - pipe.expire(ONGOING_GAMES_KEY, 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)) - - async def count_ongoing(self) -> int: - # Prune entries whose game has expired since the last poll, then - # count what is left. Both commands run in one transaction so the - # count cannot see a half-pruned set. - now = time.time() - async with self._redis.pipeline(transaction=True) as pipe: - pipe.zremrangebyscore(ONGOING_GAMES_KEY, "-inf", now) - pipe.zcard(ONGOING_GAMES_KEY) - _, count = await pipe.execute() - return int(count) - - @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]: - # ``ZRANGE ... BYSCORE`` is the non-deprecated spelling of the - # old ``ZRANGEBYSCORE``. - members = cast( - list, - await self._redis.zrange( - DEADLINES_KEY, - "-inf", - now, - byscore=True, - offset=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 notify_deadline(self) -> None: - await self._redis.publish(DEADLINES_CHANNEL, "deadline") - - @contextlib.asynccontextmanager - async def subscribe_deadlines(self) -> AsyncIterator[AsyncIterator[None]]: - pubsub = self._redis.pubsub() - await pubsub.subscribe(DEADLINES_CHANNEL) - try: - yield _redis_events(pubsub) - finally: - with contextlib.suppress(Exception): - await pubsub.unsubscribe(DEADLINES_CHANNEL) - await pubsub.aclose() - - -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._ongoing: Set[str] = set() - self._locks: Dict[str, asyncio.Lock] = {} - self._subscribers: Dict[str, Set[asyncio.Queue]] = {} - self._deadlines: Dict[str, float] = {} - self._deadline_subscribers: Set[asyncio.Queue] = set() - - 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 - engine = self._registry.require(session.game_type) - if engine.is_finished(session): - self._ongoing.discard(session.id) - else: - self._ongoing.add(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) - - async def count_ongoing(self) -> int: - return len(self._ongoing) - - @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 notify_deadline(self) -> None: - for queue in list(self._deadline_subscribers): - queue.put_nowait(_BUMP) - - @contextlib.asynccontextmanager - async def subscribe_deadlines(self) -> AsyncIterator[AsyncIterator[None]]: - queue: asyncio.Queue = asyncio.Queue() - self._deadline_subscribers.add(queue) - try: - yield _queue_events(queue) - finally: - self._deadline_subscribers.discard(queue) - - -async def _queue_events(queue: asyncio.Queue) -> AsyncIterator[None]: - while True: - await queue.get() - yield None diff --git a/server/packages/tavolo-platform/src/tavolo/platform/tortoise_mixin.py b/server/packages/tavolo-platform/src/tavolo/platform/tortoise_mixin.py deleted file mode 100644 index b0b8268..0000000 --- a/server/packages/tavolo-platform/src/tavolo/platform/tortoise_mixin.py +++ /dev/null @@ -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 diff --git a/server/packages/tavolo-platform/src/tavolo/platform/ws.py b/server/packages/tavolo-platform/src/tavolo/platform/ws.py deleted file mode 100644 index 6841ad0..0000000 --- a/server/packages/tavolo-platform/src/tavolo/platform/ws.py +++ /dev/null @@ -1,231 +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": "", ...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, WebSocketDisconnect - -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} - - -async def _reject(ws: WebSocket, status: int) -> None: - """Refuse a websocket upgrade with the HTTP ``status``. - - Rejections happen before :meth:`WebSocket.accept`, so ``close()`` - carries the HTTP status of the refused upgrade rather than a - WebSocket close code: ASGI servers answer ``403`` per the spec, and - RSGI (granian) uses the value verbatim as the response status. - Passing a 4xxx close code here would panic granian's RSGI layer on - the invalid status — and since granian is built with - ``panic = "abort"``, that kills the worker and takes the whole app - down. - """ - await ws.close(status) - - -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 _reject(ws, 401) - return - - session = await platform.game_store.load(game_id) - if session is None: - log.debug("websocket rejected: unknown game %s", game_id) - await _reject(ws, 404) - return - if not session.seated(user.sub): - log.debug("websocket %s rejected: %s is not seated", game_id, user.sub) - await _reject(ws, 403) - 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: - try: - await ws.send_text(json.dumps(payload)) - except WebSocketDisconnect: - # The peer went away without a close handshake: kaya - # raises this from the transport, and the receive loop - # ends momentarily. - pass - - await send(_state_message(platform, session, user.sub)) - # Backstop: make sure the current deadline is queued even if its - # entry was lost (e.g. the queue was flushed while the game lived - # on thanks to its sliding TTL). - await platform.scheduler.sync_deadline(session) - - async with platform.game_store.subscribe(game_id) as events: - forward = asyncio.create_task( - _forward(platform, events, game_id, user.sub, send) - ) - try: - async for message in ws: - if message.kind == "close": - break - if message.kind != "text" or not isinstance(message.data, str): - await send(_error("expected a text frame with a JSON object")) - continue - await _handle_message(platform, send, game_id, user.sub, message.data) - finally: - forward.cancel() - with suppress(asyncio.CancelledError): - await forward - log.debug("%s disconnected from game %s", user.sub, game_id) - - -async def _forward( - platform: "Platform", - events, - game_id: str, - sub: str, - send: Send, -) -> None: - async for _ in events: - session = await platform.game_store.load(game_id) - if session is None: - return - engine = platform.registry.require(session.game_type) - await send(_state_message(platform, session, sub)) - if engine.is_finished(session): - await send( - { - "type": "game_over", - **engine.game_over_view(session, sub), - } - ) - return - - -async def _handle_message( - platform: "Platform", send: Send, game_id: str, sub: str, raw: str -) -> None: - try: - data = json.loads(raw) - except (ValueError, TypeError): - log.debug("game %s: malformed message from %s (not JSON)", game_id, sub) - await send(_error("invalid JSON")) - return - if not isinstance(data, dict): - log.debug("game %s: malformed message from %s (not an object)", game_id, sub) - await send(_error("message must be a JSON object")) - return - - action = data.get("action") - if action in ("state", "sync"): - session = await platform.game_store.load(game_id) - if session is not None: - await send(_state_message(platform, session, sub)) - return - if not isinstance(action, str): - await send(_error(f"unknown action: {action!r}")) - return - await _handle_action(platform, send, game_id, sub, action, data) - - -async def _handle_action( - platform: "Platform", - send: Send, - game_id: str, - sub: str, - action: str, - data: Dict[str, Any], -) -> None: - async with platform.game_store.lock(game_id): - session = await platform.game_store.load(game_id) - if session is None: - await send(_error("game not found", code="not_found")) - return - engine = platform.registry.require(session.game_type) - payload = {key: value for key, value in data.items() if key != "action"} - try: - engine.handle_action(session, sub, action, payload) - except GameError as exc: - log.debug("game %s: rejected %r by %s: %s", game_id, action, sub, exc) - await send(_error(str(exc), code=exc.code)) - return - log.debug("game %s: %s performed %r", game_id, sub, action) - await platform.scheduler.finalize_mutation(session) diff --git a/server/packages/tavolo-platform/tests/helpers.py b/server/packages/tavolo-platform/tests/helpers.py deleted file mode 100644 index 06841cf..0000000 --- a/server/packages/tavolo-platform/tests/helpers.py +++ /dev/null @@ -1,361 +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, dummy OIDC providers (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, OIDCProviders, 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(),), - heartbeat_ms: int = 50, - oidc_providers: Sequence[str] = ("default",), -) -> 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 = OIDCProviders( - [ - OIDCConfig( - provider=name, - issuer=f"http://localhost:8180/{name}", - client_id="tavolo", - client_secret=None, - redirect_uri=f"http://localhost:8080/auth/{name}/callback", - post_login_redirect="/", - post_logout_redirect="/", - ) - for name in oidc_providers - ], - 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=heartbeat_ms, - context_binder=tortoise_mixin.ensure_context, - ) - platform = Platform( - registry=registry, - game_store=game_store, - scheduler=scheduler, - oidc=oidc, - ) - app = KayaApp( - mixins=[ - session_mixin, - oidc, - 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: OIDCProviders, sub: str, name: Optional[str] = None) -> Iterator[OIDCUser]: - """Context manager: make ``oidc.get_users`` return this user for the - first configured provider.""" - user = make_user(sub, name) - provider = next(iter(oidc.providers)) - patcher = _mock.patch.object(oidc, "get_users", return_value={provider: user}) - patcher.start() - try: - yield user - finally: - patcher.stop() - - -@contextlib.contextmanager -def ws_users(users: Sequence[OIDCUser]) -> Iterator[None]: - """Context manager: patch ``auth.get_ws_user`` to hand out ``users`` - one per websocket connection, in order. Once exhausted it keeps - returning the last user.""" - from tavolo.platform import auth - - remaining = list(users) - last = remaining[-1] if remaining else None - - def _next(_ws): - if remaining: - return remaining.pop(0) - return last - - patcher = _mock.patch.object(auth, "get_ws_user", side_effect=_next) - patcher.start() - try: - yield - finally: - patcher.stop() diff --git a/server/packages/tavolo-platform/tests/test_auth.py b/server/packages/tavolo-platform/tests/test_auth.py deleted file mode 100644 index daf3ffe..0000000 --- a/server/packages/tavolo-platform/tests/test_auth.py +++ /dev/null @@ -1,152 +0,0 @@ -"""Tests for the auth helpers' OIDC user lookup and gating. - -kaya-oidc (0.0.6) namespaces session state per provider as -``oidc:{provider}:user`` and tavolo wires an :class:`OIDCProviders`; these -tests pin both contracts so a kaya upgrade or a provider change cannot -silently break HTTP or websocket authentication. -""" -from __future__ import annotations - -import unittest -from types import SimpleNamespace -from typing import Any - -from kaya.oidc import OIDCConfig, OIDCProviders -from kaya.session import InMemorySessionStore, Session, SessionMixin - -from helpers import async_test -from tavolo.platform.auth import current_user, get_ws_user, require_auth, session_user - - -def _providers(*names: str) -> OIDCProviders: - session = SessionMixin(InMemorySessionStore()) - return OIDCProviders( - [ - OIDCConfig( - provider=name, - issuer=f"http://localhost:8180/{name}", - client_id="tavolo", - redirect_uri=f"http://localhost:8080/auth/{name}/callback", - ) - for name in names - ], - session=session, - ) - - -def _ctx(data: Any) -> Any: - return SimpleNamespace(session=Session("sid", data)) - - -def _ws(session: Any) -> Any: - return SimpleNamespace(session=session) - - -class SessionUserTest(unittest.TestCase): - def test_finds_default_provider_user(self) -> None: - user = session_user(Session("sid", {"oidc:default:user": {"sub": "alice"}})) - assert user is not None - self.assertEqual("alice", user.sub) - - def test_finds_user_of_any_provider(self) -> None: - user = session_user(Session("sid", {"oidc:google:user": {"sub": "alice"}})) - assert user is not None - self.assertEqual("alice", user.sub) - - def test_ignores_flow_state_and_tokens(self) -> None: - session = Session( - "sid", - { - "oidc:default:state": "state", - "oidc:default:id_token": "token", - "oidc:default:access_token": "token", - }, - ) - self.assertIsNone(session_user(session)) - - def test_ignores_legacy_unprefixed_user_key(self) -> None: - session = Session("sid", {"oidc_user": {"sub": "alice"}}) - self.assertIsNone(session_user(session)) - - def test_ignores_malformed_user_claims(self) -> None: - session = Session("sid", {"oidc:default:user": "alice"}) - self.assertIsNone(session_user(session)) - - -class GetWsUserTest(unittest.TestCase): - def test_returns_user_from_provider_namespaced_session(self) -> None: - session = Session( - "sid", - {"oidc:default:user": {"sub": "alice", "preferred_username": "Alice"}}, - ) - user = get_ws_user(_ws(session)) - assert user is not None - self.assertEqual("alice", user.sub) - - def test_missing_session_returns_none(self) -> None: - self.assertIsNone(get_ws_user(SimpleNamespace())) - - -class CurrentUserTest(unittest.TestCase): - def test_anonymous_session_has_no_user(self) -> None: - self.assertIsNone(current_user(_providers("default"), _ctx({}))) - - def test_returns_the_authenticated_provider_user(self) -> None: - user = current_user( - _providers("default", "google"), - _ctx({"oidc:google:user": {"sub": "alice"}}), - ) - assert user is not None - self.assertEqual("alice", user.sub) - - def test_first_configured_provider_wins(self) -> None: - # Single-identity sessions never hold two users; this only pins the - # tie-break should they ever coexist. - user = current_user( - _providers("default", "google"), - _ctx( - { - "oidc:default:user": {"sub": "alice"}, - "oidc:google:user": {"sub": "bob"}, - } - ), - ) - assert user is not None - self.assertEqual("alice", user.sub) - - -class RequireAuthTest(unittest.TestCase): - @async_test - async def test_rejects_anonymous_requests(self) -> None: - oidc = _providers("default") - ctx = _ctx({}) - sent = {} - - async def send_bytes(*args: Any) -> None: - sent["status"] = args[0] - - ctx.send_bytes = send_bytes - handled = [] - - @require_auth(oidc) - async def handler(_ctx: Any) -> None: - handled.append(True) - - await handler(ctx) - self.assertEqual(401, sent["status"]) - self.assertEqual([], handled) - - @async_test - async def test_allows_authenticated_requests(self) -> None: - oidc = _providers("default") - ctx = _ctx({"oidc:default:user": {"sub": "alice"}}) - handled = [] - - @require_auth(oidc) - async def handler(request_ctx: Any) -> None: - user = current_user(oidc, request_ctx) - assert user is not None - handled.append(user.sub) - - await handler(ctx) - self.assertEqual(["alice"], handled) diff --git a/server/packages/tavolo-platform/tests/test_deadlines.py b/server/packages/tavolo-platform/tests/test_deadlines.py deleted file mode 100644 index bcdf0b2..0000000 --- a/server/packages/tavolo-platform/tests/test_deadlines.py +++ /dev/null @@ -1,250 +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 NotificationTest(unittest.TestCase): - """Deadline-enqueue notifications wake consumers on other workers.""" - - @async_test - async def test_notification_wakes_a_long_heartbeat_consumer(self) -> None: - # The consumer's heartbeat is a minute away and nothing sets its - # local wake event: only the pub/sub notification can deliver the - # deadline in time. This is the cross-worker path. - _, platform, _ = make_platform(heartbeat_ms=60_000) - store = platform.game_store - scheduler = platform.scheduler - session = _started_session(deadline_in_seconds=0.1) - await store.save(session) - scheduler.ensure_consumer() - # Let the listener task subscribe before announcing. - await asyncio.sleep(0.05) - - engine = platform.registry.require(session.game_type) - deadline = engine.next_deadline(session) - assert deadline is not None - member = encode({ - "game_id": session.id, - "kind": deadline.kind, - "token": deadline.token, - }) - await store.add_deadline(member, deadline.due_at.timestamp()) - await store.notify_deadline() - - result = await _wait_for(lambda: _plays_is(store, session.id, 1)) - self.assertIsNotNone(result, "notification did not wake the consumer") - - -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() diff --git a/server/packages/tavolo-platform/tests/test_elo.py b/server/packages/tavolo-platform/tests/test_elo.py deleted file mode 100644 index 413111e..0000000 --- a/server/packages/tavolo-platform/tests/test_elo.py +++ /dev/null @@ -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() diff --git a/server/packages/tavolo-platform/tests/test_pagination.py b/server/packages/tavolo-platform/tests/test_pagination.py deleted file mode 100644 index 603def9..0000000 --- a/server/packages/tavolo-platform/tests/test_pagination.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Keyset pagination tests: cursor values are restored to Python types. - -Cursors are JSON, so a cursor carrying a datetime reaches the next -request as a string. Postgres/asyncpg rejects strings for timestamp -parameters (sqlite silently coerces them), so ``paginate`` must run the -decoded values through their model fields before building the WHERE -clause. -""" -from __future__ import annotations - -import unittest -import uuid -from datetime import datetime, timezone -from typing import Any, List - -from tavolo.platform.models import Match -from tavolo.platform.pagination import Cursor, decode_cursor, encode_cursor, paginate - -from helpers import async_test - -SORT = [("finished_at", "DESC"), ("id", "ASC")] - - -class _CapturingQuerySet: - """Minimal stand-in for a Tortoise QuerySet, recording its filter.""" - - model = Match - - def __init__(self) -> None: - self.queryset_filter: Any = None - - def order_by(self, *orderings: str) -> "_CapturingQuerySet": - return self - - def filter(self, q: Any) -> "_CapturingQuerySet": - self.queryset_filter = q - return self - - async def limit(self, count: int) -> List[Any]: - return [] - - -def _flatten_q(q: Any) -> List[Any]: - nodes = [q] - for child in q.children: - nodes.extend(_flatten_q(child)) - return nodes - - -def _filter_values(q: Any, key: str) -> List[Any]: - return [node.filters[key] for node in _flatten_q(q) if key in node.filters] - - -class PaginateCursorTypesTest(unittest.TestCase): - @async_test - async def test_cursor_values_are_restored_before_filtering(self) -> None: - match_id = uuid.uuid4() - finished_at = datetime(2026, 9, 19, 5, 2, 50, 547904, tzinfo=timezone.utc) - # Round-trip through the wire format, exactly as a request does. - after = decode_cursor( - encode_cursor({"finished_at": finished_at, "id": match_id}) - ) - assert after is not None - - qs = _CapturingQuerySet() - await paginate(qs, SORT, Cursor(limit=1, after=after)) - - assert qs.queryset_filter is not None - for key in ("finished_at", "finished_at__lt"): - values = _filter_values(qs.queryset_filter, key) - self.assertEqual(1, len(values)) - self.assertIsInstance(values[0], datetime) - self.assertEqual(finished_at, values[0]) - id_values = _filter_values(qs.queryset_filter, "id__gt") - self.assertEqual(1, len(id_values)) - self.assertIsInstance(id_values[0], uuid.UUID) - self.assertEqual(match_id, id_values[0]) - - -if __name__ == "__main__": - unittest.main() diff --git a/server/packages/tavolo-platform/tests/test_routes.py b/server/packages/tavolo-platform/tests/test_routes.py deleted file mode 100644 index ad4855a..0000000 --- a/server/packages/tavolo-platform/tests/test_routes.py +++ /dev/null @@ -1,254 +0,0 @@ -"""Game lobby route tests via kaya's ASGI transport, on the DummyEngine.""" -from __future__ import annotations - -import unittest - -from httpx import ASGITransport, AsyncClient - -from helpers import async_test, make_platform, oidc_user - - -class GamesRouteTest(unittest.TestCase): - @async_test - async def test_create_requires_auth(self) -> None: - app, _, _ = make_platform() - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - response = await client.post("/api/games", json={}) - self.assertEqual(401, response.status_code) - self.assertEqual({"error": "unauthenticated"}, response.json()) - - @async_test - async def test_create_and_read_lobby(self) -> None: - app, platform, _ = make_platform() - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - with oidc_user(platform.oidc, "alice"): - created = await client.post( - "/api/games", json={"options": {"target": 5}} - ) - self.assertEqual(201, created.status_code) - body = created.json() - self.assertEqual("lobby", body["phase"]) - self.assertEqual(1, body["seats_open"]) - self.assertEqual(5, body["target"]) - self.assertEqual("dummy", body["game_type"]) - self.assertEqual("A", body["players"][0]["team"]) - self.assertEqual(6, len(body["join_code"])) - game_id = body["id"] - - with oidc_user(platform.oidc, "alice"): - snapshot = await client.get(f"/api/games/{game_id}") - self.assertEqual(200, snapshot.status_code) - snap = snapshot.json() - self.assertEqual(game_id, snap["id"]) - self.assertEqual("dummy", snap["game_type"]) - self.assertTrue(snap["viewer_seated"]) - - with oidc_user(platform.oidc, "mallory"): - forbidden = await client.get(f"/api/games/{game_id}") - self.assertEqual(403, forbidden.status_code) - - @async_test - async def test_join_starts_game(self) -> None: - app, platform, _ = make_platform() - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - with oidc_user(platform.oidc, "alice"): - created = await client.post("/api/games", json={}) - code = created.json()["join_code"] - - with oidc_user(platform.oidc, "bob"): - started = await client.post("/api/games/join", json={"code": code}) - self.assertEqual(200, started.status_code) - state = started.json() - # The second join started the match, so the response is the - # personalized view rather than the lobby payload. - self.assertTrue(state["started"]) - self.assertFalse(state["finished"]) - self.assertEqual(0, state["plays"]) - self.assertTrue(state["viewer_seated"]) - - @async_test - async def test_preview_requires_auth(self) -> None: - app, _, _ = make_platform() - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - response = await client.get("/api/games/preview?code=ABC123") - self.assertEqual(401, response.status_code) - - @async_test - async def test_preview_lobby_and_started_game(self) -> None: - app, platform, _ = make_platform() - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - with oidc_user(platform.oidc, "alice"): - created = await client.post( - "/api/games", json={"options": {"target": 5}} - ) - code = created.json()["join_code"] - - with oidc_user(platform.oidc, "mallory"): - missing = await client.get("/api/games/preview") - unknown = await client.get("/api/games/preview?code=ZZZZZZ") - self.assertEqual(400, missing.status_code) - self.assertEqual(404, unknown.status_code) - - with oidc_user(platform.oidc, "mallory"): - preview = await client.get(f"/api/games/preview?code={code}") - self.assertEqual(200, preview.status_code) - body = preview.json() - self.assertEqual("dummy", body["game_type"]) - self.assertEqual("Dummy game", body["game_name"]) - self.assertTrue(body["game_description"]) - self.assertEqual("lobby", body["phase"]) - self.assertEqual(5, body["target"]) - self.assertEqual(1, body["seats_open"]) - self.assertEqual("alice", body["players"][0]["sub"]) - - # Join codes are case-insensitive. - with oidc_user(platform.oidc, "mallory"): - lower = await client.get(f"/api/games/preview?code={code.lower()}") - self.assertEqual(200, lower.status_code) - - with oidc_user(platform.oidc, "bob"): - await client.post("/api/games/join", json={"code": code}) - with oidc_user(platform.oidc, "mallory"): - started = await client.get(f"/api/games/preview?code={code}") - self.assertEqual(200, started.status_code) - self.assertEqual("playing", started.json()["phase"]) - self.assertEqual(0, started.json()["seats_open"]) - - @async_test - async def test_create_rejects_bad_options(self) -> None: - app, platform, _ = make_platform() - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - with oidc_user(platform.oidc, "alice"): - zero = await client.post( - "/api/games", json={"options": {"target": 0}} - ) - text = await client.post( - "/api/games", json={"options": {"target": "three"}} - ) - non_object = await client.post( - "/api/games", json={"options": [1, 2]} - ) - self.assertEqual(400, zero.status_code) - self.assertEqual(400, text.status_code) - self.assertEqual(400, non_object.status_code) - - @async_test - async def test_join_errors(self) -> None: - app, platform, _ = make_platform() - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - with oidc_user(platform.oidc, "alice"): - created = await client.post("/api/games", json={}) - code = created.json()["join_code"] - - with oidc_user(platform.oidc, "bob"): - unknown = await client.post("/api/games/join", json={"code": "ZZZZZZ"}) - self.assertEqual(404, unknown.status_code) - - with oidc_user(platform.oidc, "alice"): - duplicate = await client.post("/api/games/join", json={"code": code}) - self.assertEqual(409, duplicate.status_code) - - with oidc_user(platform.oidc, "bob"): - missing = await client.post("/api/games/join", json={}) - self.assertEqual(400, missing.status_code) - - with oidc_user(platform.oidc, "bob"): - await client.post("/api/games/join", json={"code": code}) - with oidc_user(platform.oidc, "erin"): - late = await client.post("/api/games/join", json={"code": code}) - self.assertEqual(409, late.status_code) - - @async_test - async def test_get_unknown_game(self) -> None: - app, platform, _ = make_platform() - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - with oidc_user(platform.oidc, "alice"): - response = await client.get("/api/games/does-not-exist") - self.assertEqual(404, response.status_code) - - -class GameTypesRouteTest(unittest.TestCase): - @async_test - async def test_lists_available_game_types(self) -> None: - app, _, _ = make_platform() - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - response = await client.get("/api/game-types") - self.assertEqual(200, response.status_code) - results = response.json()["results"] - self.assertEqual(["dummy"], [g["id"] for g in results]) - self.assertEqual("Dummy game", results[0]["name"]) - self.assertTrue(results[0]["description"]) - self.assertEqual(2, results[0]["min_players"]) - self.assertEqual(2, results[0]["max_players"]) - self.assertIn("target", results[0]["options_schema"]["properties"]) - - @async_test - async def test_create_defaults_game_type(self) -> None: - app, platform, _ = make_platform() - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - with oidc_user(platform.oidc, "alice"): - created = await client.post("/api/games", json={}) - self.assertEqual(201, created.status_code) - self.assertEqual("dummy", created.json()["game_type"]) - - @async_test - async def test_create_with_explicit_game_type(self) -> None: - app, platform, _ = make_platform() - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - with oidc_user(platform.oidc, "alice"): - created = await client.post( - "/api/games", json={"game_type": "dummy"} - ) - self.assertEqual(201, created.status_code) - body = created.json() - self.assertEqual("dummy", body["game_type"]) - - with oidc_user(platform.oidc, "alice"): - snapshot = await client.get(f"/api/games/{body['id']}") - self.assertEqual("dummy", snapshot.json()["game_type"]) - - @async_test - async def test_create_rejects_unknown_game_type(self) -> None: - app, platform, _ = make_platform() - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - with oidc_user(platform.oidc, "alice"): - unknown = await client.post("/api/games", json={"game_type": "briscola"}) - non_string = await client.post("/api/games", json={"game_type": 42}) - self.assertEqual(400, unknown.status_code) - self.assertEqual(400, non_string.status_code) - - -class MeRouteTest(unittest.TestCase): - @async_test - async def test_me_authenticated(self) -> None: - app, platform, _ = make_platform() - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - with oidc_user(platform.oidc, "alice"): - response = await client.get("/api/me") - self.assertEqual(200, response.status_code) - self.assertEqual({"sub": "alice", "name": "alice"}, response.json()) - - @async_test - async def test_me_unauthenticated(self) -> None: - app, _, _ = make_platform() - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - response = await client.get("/api/me") - self.assertEqual(401, response.status_code) - - -if __name__ == "__main__": - unittest.main() diff --git a/server/packages/tavolo-platform/tests/test_routes_auth.py b/server/packages/tavolo-platform/tests/test_routes_auth.py deleted file mode 100644 index 9e0ba4e..0000000 --- a/server/packages/tavolo-platform/tests/test_routes_auth.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Tests for the provider-agnostic auth routes. - -``GET /api/auth/providers`` feeds the SPA's login buttons; ``/auth/login`` -and ``/auth/logout`` are the generic aliases that redirect to the right -per-provider kaya route. -""" -from __future__ import annotations - -import unittest -from unittest import mock - -from httpx import ASGITransport, AsyncClient - -from helpers import async_test, make_platform - - -class AuthProvidersRouteTest(unittest.TestCase): - @async_test - async def test_lists_providers_in_configuration_order(self) -> None: - app, _platform, _tortoise = make_platform(oidc_providers=("default", "google")) - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - response = await client.get("/api/auth/providers") - self.assertEqual(200, response.status_code) - self.assertEqual( - { - "providers": [ - {"name": "default", "login_url": "/auth/default/login"}, - {"name": "google", "login_url": "/auth/google/login"}, - ] - }, - response.json(), - ) - - -class GenericAuthRedirectTest(unittest.TestCase): - @async_test - async def test_login_redirects_to_the_first_provider(self) -> None: - app, _platform, _tortoise = make_platform(oidc_providers=("google", "github")) - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - response = await client.get("/auth/login", follow_redirects=False) - self.assertEqual(302, response.status_code) - self.assertEqual("/auth/google/login", response.headers["location"]) - - @async_test - async def test_logout_redirects_to_the_logged_in_provider(self) -> None: - app, platform, _tortoise = make_platform() - with mock.patch.object( - platform.oidc, "logged_in_providers", return_value=["default"] - ): - transport = ASGITransport(app=app) - async with AsyncClient( - transport=transport, base_url="http://127.0.0.1" - ) as client: - response = await client.get("/auth/logout", follow_redirects=False) - self.assertEqual(302, response.status_code) - self.assertEqual("/auth/default/logout", response.headers["location"]) - - @async_test - async def test_logout_without_a_session_redirects_home(self) -> None: - app, _platform, _tortoise = make_platform() - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - response = await client.get("/auth/logout", follow_redirects=False) - self.assertEqual(302, response.status_code) - self.assertEqual("/", response.headers["location"]) diff --git a/server/packages/tavolo-platform/tests/test_stats.py b/server/packages/tavolo-platform/tests/test_stats.py deleted file mode 100644 index dd5d286..0000000 --- a/server/packages/tavolo-platform/tests/test_stats.py +++ /dev/null @@ -1,415 +0,0 @@ -"""Match-statistics tests: Postgres persistence and the stats endpoints.""" -from __future__ import annotations - -import unittest -import uuid -from datetime import datetime, timezone -from unittest import mock - -from httpx import ASGITransport, AsyncClient - -from tavolo.platform.elo import INITIAL_RATING -from tavolo.platform.models import Match, MatchPlayer, PlayerRating -from tavolo.platform.stats import save_match_result -from helpers import DummyEngine, async_test, make_platform, oidc_user, use_db - - -def _finished_session(target: int = 2): - """A started dummy session one play short of completion.""" - from tavolo.platform import GameSession, Seat - - engine = DummyEngine() - session = GameSession( - id="stats-game", - game_type=engine.id, - join_code="STATS1", - creator_sub="alice", - players=[Seat(user_sub="alice", display_name="alice", team="A")], - ) - engine.create(session, {"target": target}) - engine.join(session, "bob", "bob") - engine.handle_action(session, "alice", "play", {}) - return engine, session - - -class SaveMatchResultTest(unittest.TestCase): - @async_test - async def test_finished_match_is_persisted_once(self) -> None: - _, _, tortoise_mixin = make_platform() - ctx = await use_db(tortoise_mixin) - engine, session = _finished_session() - engine.handle_action(session, "alice", "play", {}) - self.assertTrue(engine.is_finished(session)) - - with ctx: - await save_match_result(session, engine) - await save_match_result(session, engine) # idempotent - self.assertEqual(1, await Match.all().count()) - self.assertEqual(2, await MatchPlayer.all().count()) - - match = await Match.all().first() - assert match is not None - # The game type travels from the session onto the row; the - # engine's summary is stored verbatim as the result. - self.assertEqual("dummy", match.game_type) - self.assertEqual("alice", match.result["winner"]) - self.assertEqual(2, match.result["plays"]) - winners = await MatchPlayer.filter(won=True) - self.assertEqual({"alice"}, {p.user_sub for p in winners}) - scores = {p.user_sub: p.score for p in await MatchPlayer.all()} - self.assertEqual({"alice": 2.0, "bob": 0.0}, scores) - - @async_test - async def test_finished_match_updates_elo_ratings(self) -> None: - _, _, tortoise_mixin = make_platform() - ctx = await use_db(tortoise_mixin) - engine, session = _finished_session() - engine.handle_action(session, "alice", "play", {}) - - with ctx: - await save_match_result(session, engine) - - ratings = { - row.user_sub: row for row in await PlayerRating.all() - } - self.assertEqual(2, len(ratings)) - # Two players at 1500: winner gains K/2, loser loses it. - self.assertEqual(INITIAL_RATING + 16, ratings["alice"].rating) - self.assertEqual(1, ratings["alice"].matches_played) - self.assertEqual(INITIAL_RATING - 16, ratings["bob"].rating) - self.assertEqual(1, ratings["bob"].matches_played) - - # The per-match delta is recorded on each participation row. - deltas = { - p.user_sub: p.elo_delta for p in await MatchPlayer.all() - } - self.assertEqual({"alice": 16, "bob": -16}, deltas) - - @async_test - async def test_elo_ratings_accumulate_across_matches(self) -> None: - _, _, tortoise_mixin = make_platform() - ctx = await use_db(tortoise_mixin) - engine, session = _finished_session() - engine.handle_action(session, "alice", "play", {}) - - from tavolo.platform import GameSession, Seat - - engine2, session2 = DummyEngine(), GameSession( - id="stats-game-2", - game_type="dummy", - join_code="STATS2", - creator_sub="alice", - players=[Seat(user_sub="alice", display_name="alice", team="A")], - ) - engine2.create(session2, {"target": 2}) - engine2.join(session2, "bob", "bob") - # Bob wins the second match. - engine2.handle_action(session2, "bob", "play", {}) - engine2.handle_action(session2, "bob", "play", {}) - - with ctx: - await save_match_result(session, engine) - await save_match_result(session2, engine2) - - ratings = { - row.user_sub: row.rating for row in await PlayerRating.all() - } - # Match 1: even teams, alice wins (+16/-16). Match 2: alice - # is now the favourite (1516 vs 1484), so losing costs 17. - self.assertEqual(INITIAL_RATING - 1, ratings["alice"]) - self.assertEqual(INITIAL_RATING + 1, ratings["bob"]) - bob = await PlayerRating.get(user_sub="bob") - self.assertEqual(2, bob.matches_played) - - @async_test - async def test_unfinished_match_is_not_persisted(self) -> None: - _, _, tortoise_mixin = make_platform() - ctx = await use_db(tortoise_mixin) - engine, session = _finished_session() - with ctx: - await save_match_result(session, engine) - self.assertEqual(0, await Match.all().count()) - - -async def _seed_two_matches( - tortoise_mixin, game_types: tuple = ("dummy", "dummy") -) -> None: - ctx = await use_db(tortoise_mixin) - with ctx: - for index, (winner, finished) in enumerate( - [ - ("alice", datetime(2026, 1, 1, 10, tzinfo=timezone.utc)), - ("bob", datetime(2026, 1, 2, 10, tzinfo=timezone.utc)), - ] - ): - match = await Match.create( - id=uuid.uuid4(), - game_type=game_types[index], - started_at=datetime(2025, 12, 31, tzinfo=timezone.utc), - finished_at=finished, - result={"winner": winner, "plays": 3 + index}, - ) - for seat, sub in enumerate(("alice", "bob")): - await MatchPlayer.create( - id=uuid.uuid4(), - match=match, - user_sub=sub, - display_name=sub, - seat=seat, - team="A" if seat == 0 else "B", - won=(sub == winner), - score=2.0 + index if sub == winner else 1.0, - ) - - -class StatsRouteTest(unittest.TestCase): - @async_test - async def test_my_matches_newest_first(self) -> None: - app, platform, tortoise_mixin = make_platform() - await _seed_two_matches(tortoise_mixin) - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - with oidc_user(platform.oidc, "alice"): - response = await client.get("/api/me/matches") - self.assertEqual(200, response.status_code) - results = response.json()["results"] - self.assertEqual(2, len(results)) - self.assertEqual("bob", results[0]["result"]["winner"]) # newest first - self.assertFalse(results[0]["you_won"]) - self.assertTrue(results[1]["you_won"]) - self.assertEqual(2, len(results[0]["players"])) - self.assertIn("next_cursor", response.json()) - - @async_test - async def test_my_matches_pagination(self) -> None: - app, platform, tortoise_mixin = make_platform() - await _seed_two_matches(tortoise_mixin) - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - with oidc_user(platform.oidc, "alice"): - first = await client.get("/api/me/matches?limit=1") - cursor = first.json()["next_cursor"] - self.assertIsNotNone(cursor) - second = await client.get(f"/api/me/matches?limit=1&cursor={cursor}") - self.assertEqual(1, len(first.json()["results"])) - self.assertEqual(1, len(second.json()["results"])) - self.assertNotEqual( - first.json()["results"][0]["id"], - second.json()["results"][0]["id"], - ) - - @async_test - async def test_my_matches_fetches_participants_in_one_query(self) -> None: - app, platform, tortoise_mixin = make_platform() - await _seed_two_matches(tortoise_mixin) - transport = ASGITransport(app=app) - with mock.patch.object( - MatchPlayer, "filter", wraps=MatchPlayer.filter - ) as spy: - async with AsyncClient( - transport=transport, base_url="http://127.0.0.1" - ) as client: - with oidc_user(platform.oidc, "alice"): - response = await client.get("/api/me/matches") - self.assertEqual(200, response.status_code) - # One query for all matches, not one per match row. - self.assertEqual(1, spy.call_count) - results = response.json()["results"] - self.assertEqual(2, len(results)) - for result in results: - seats = [player["seat"] for player in result["players"]] - self.assertEqual([0, 1], seats) - - @async_test - async def test_my_matches_requires_auth(self) -> None: - app, _, _ = make_platform() - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - response = await client.get("/api/me/matches") - self.assertEqual(401, response.status_code) - - @async_test - async def test_leaderboard_aggregates(self) -> None: - app, _, tortoise_mixin = make_platform() - await _seed_two_matches(tortoise_mixin) - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - response = await client.get("/api/leaderboard") - self.assertEqual(200, response.status_code) - by_sub = {row["user_sub"]: row for row in response.json()["results"]} - self.assertEqual(2, by_sub["alice"]["matches"]) - self.assertEqual(1, by_sub["alice"]["wins"]) # alice won match 1 - self.assertEqual(3.0, by_sub["alice"]["points"]) # 2.0 + 1.0 - self.assertEqual(1, by_sub["bob"]["wins"]) # bob won match 2 - self.assertEqual(4.0, by_sub["bob"]["points"]) # 1.0 + 3.0 - # Bob leads on points after tying Alice on wins. - self.assertEqual("bob", response.json()["results"][0]["user_sub"]) - - @async_test - async def test_leaderboard_includes_elo_and_sorts_by_it(self) -> None: - app, _, tortoise_mixin = make_platform() - await _seed_two_matches(tortoise_mixin) - ctx = await use_db(tortoise_mixin) - with ctx: - # Alice outranks everyone despite Bob leading on points. - await PlayerRating.create( - id=uuid.uuid4(), - user_sub="alice", - game_type="dummy", - rating=1600, - matches_played=2, - ) - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - response = await client.get("/api/leaderboard") - self.assertEqual(200, response.status_code) - results = response.json()["results"] - by_sub = {row["user_sub"]: row for row in results} - self.assertEqual(1600, by_sub["alice"]["elo"]) - # Players without a rating row report the initial rating. - self.assertEqual(INITIAL_RATING, by_sub["bob"]["elo"]) - # Elo outranks wins/points. - self.assertEqual("alice", results[0]["user_sub"]) - - @async_test - async def test_leaderboard_elo_scoped_by_game_type(self) -> None: - app, platform, tortoise_mixin = make_platform( - engines=(DummyEngine(), SecondEngine()) - ) - await _seed_two_matches(tortoise_mixin) - ctx = await use_db(tortoise_mixin) - with ctx: - await PlayerRating.create( - id=uuid.uuid4(), - user_sub="bob", - game_type="dummy", - rating=1516, - matches_played=1, - ) - # Bob's rating in another game must not leak into the - # dummy leaderboard. - await PlayerRating.create( - id=uuid.uuid4(), - user_sub="bob", - game_type="second", - rating=1800, - matches_played=1, - ) - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - response = await client.get("/api/leaderboard?game_type=dummy") - self.assertEqual(200, response.status_code) - results = response.json()["results"] - by_sub = {row["user_sub"]: row for row in results} - self.assertEqual(1516, by_sub["bob"]["elo"]) - self.assertEqual(INITIAL_RATING, by_sub["alice"]["elo"]) - self.assertEqual("second", platform.registry.all()[1].id) - - @async_test - async def test_my_matches_include_elo_delta(self) -> None: - app, platform, tortoise_mixin = make_platform() - ctx = await use_db(tortoise_mixin) - engine, session = _finished_session() - engine.handle_action(session, "alice", "play", {}) - with ctx: - await save_match_result(session, engine) - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - with oidc_user(platform.oidc, "alice"): - response = await client.get("/api/me/matches") - self.assertEqual(200, response.status_code) - players = { - p["user_sub"]: p - for p in response.json()["results"][0]["players"] - } - self.assertEqual(16, players["alice"]["elo_delta"]) - self.assertEqual(-16, players["bob"]["elo_delta"]) - self.assertEqual(16, response.json()["results"][0]["your_elo_delta"]) - - @async_test - async def test_my_ratings_requires_auth(self) -> None: - app, _, _ = make_platform() - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - response = await client.get("/api/me/ratings") - self.assertEqual(401, response.status_code) - - @async_test - async def test_my_ratings_returns_only_own_rows(self) -> None: - app, platform, tortoise_mixin = make_platform() - ctx = await use_db(tortoise_mixin) - with ctx: - await PlayerRating.create( - id=uuid.uuid4(), - user_sub="alice", - game_type="dummy", - rating=1516, - matches_played=1, - ) - await PlayerRating.create( - id=uuid.uuid4(), - user_sub="bob", - game_type="dummy", - rating=1484, - matches_played=1, - ) - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - with oidc_user(platform.oidc, "alice"): - response = await client.get("/api/me/ratings") - self.assertEqual(200, response.status_code) - self.assertEqual( - [{"game_type": "dummy", "rating": 1516, "matches_played": 1}], - response.json()["results"], - ) - - -class SecondEngine(DummyEngine): - id = "second" - name = "Second game" - - -class GameTypeFilterTest(unittest.TestCase): - """Stats endpoints scope results by the match's game type.""" - - @async_test - async def test_my_matches_filter_by_game_type(self) -> None: - # The second seed names a game the registry does not know; rows are - # written directly, so this only exercises the SQL filter. - app, platform, tortoise_mixin = make_platform() - await _seed_two_matches(tortoise_mixin, game_types=("dummy", "other_game")) - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - with oidc_user(platform.oidc, "alice"): - all_matches = await client.get("/api/me/matches") - scoped = await client.get("/api/me/matches?game_type=dummy") - unknown = await client.get("/api/me/matches?game_type=briscola") - self.assertEqual(2, len(all_matches.json()["results"])) - self.assertEqual( - {"dummy", "other_game"}, - {m["game_type"] for m in all_matches.json()["results"]}, - ) - scoped_results = scoped.json()["results"] - self.assertEqual(1, len(scoped_results)) - self.assertEqual("dummy", scoped_results[0]["game_type"]) - self.assertEqual(400, unknown.status_code) - - @async_test - async def test_leaderboard_filter_by_game_type(self) -> None: - app, platform, tortoise_mixin = make_platform() - await _seed_two_matches(tortoise_mixin, game_types=("dummy", "other_game")) - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - scoped = await client.get("/api/leaderboard?game_type=dummy") - unknown = await client.get("/api/leaderboard?game_type=briscola") - self.assertEqual(200, scoped.status_code) - by_sub = {row["user_sub"]: row for row in scoped.json()["results"]} - # Only the first match counts: one match per player, alice won. - self.assertEqual(1, by_sub["alice"]["matches"]) - self.assertEqual(1, by_sub["alice"]["wins"]) - self.assertEqual(0, by_sub["bob"]["wins"]) - self.assertEqual(400, unknown.status_code) - - -if __name__ == "__main__": - unittest.main() diff --git a/server/packages/tavolo-platform/tests/test_store.py b/server/packages/tavolo-platform/tests/test_store.py deleted file mode 100644 index 71108a2..0000000 --- a/server/packages/tavolo-platform/tests/test_store.py +++ /dev/null @@ -1,334 +0,0 @@ -"""In-memory game store behaviour (the Redis store shares this interface).""" -from __future__ import annotations - -import asyncio -import time -import unittest -from typing import Any, Optional, cast - -from tavolo.platform import GameSession, Seat -from tavolo.platform.registry import GameRegistry -from tavolo.platform.store import ( - DEADLINES_KEY, - ONGOING_GAMES_KEY, - InMemoryGameStore, - RedisGameStore, -) -from helpers import DummyEngine, async_test - - -def _registry() -> GameRegistry: - return GameRegistry([DummyEngine()]) - - -def _session(game_id: str = "g1", code: str = "CODE01") -> GameSession: - engine = DummyEngine() - session = GameSession( - id=game_id, - game_type=engine.id, - join_code=code, - creator_sub="alice", - players=[Seat(user_sub="alice", display_name="alice", team="A")], - ) - engine.create(session, {"target": 5}) - return session - - -class _FakePipeline: - """Records commands and applies them to the fake Redis on execute.""" - - def __init__(self, redis: "_FakeRedis") -> None: - self._redis = redis - self._ops: list = [] - - async def __aenter__(self) -> "_FakePipeline": - return self - - async def __aexit__(self, *exc: object) -> bool: - return False - - def set(self, key: str, value: str, ex: object = None) -> None: - self._ops.append(("set", key)) - - def zadd(self, key: str, mapping: dict) -> None: - self._ops.append(("zadd", key, mapping)) - - def zrem(self, key: str, member: str) -> None: - self._ops.append(("zrem", key, member)) - - def expire(self, key: str, ttl: int) -> None: - self._ops.append(("expire", key)) - - def zremrangebyscore(self, key: str, minimum: object, maximum: object) -> None: - self._ops.append(("zremrangebyscore", key)) - - def zcard(self, key: str) -> None: - self._ops.append(("zcard", key)) - - async def execute(self) -> list: - results = [self._redis.apply(op) for op in self._ops] - self._ops = [] - return results - - -class _FakeRedis: - """The slice of the Redis client the game store uses.""" - - def __init__(self) -> None: - self.keys: dict = {} - self.zsets: dict = {} - self.zrange_calls: list = [] - - def pipeline(self, transaction: bool = True) -> _FakePipeline: - return _FakePipeline(self) - - async def zadd(self, key: str, mapping: dict) -> int: - return cast(int, self.apply(("zadd", key, mapping))) - - async def zrem(self, key: str, member: str) -> int: - return cast(int, self.apply(("zrem", key, member))) - - async def zrange( - self, - name: str, - start: Any, - end: Any, - byscore: bool = False, - offset: Optional[int] = None, - num: Optional[int] = None, - **kwargs: object, - ) -> list: - self.zrange_calls.append((name, start, end, byscore, offset, num)) - members = sorted( - self.zsets.get(name, {}).items(), key=lambda item: item[1] - ) - if byscore: - low = float("-inf") if start == "-inf" else float(start) - high = float("inf") if end == "+inf" else float(end) - members = [item for item in members if low <= item[1] <= high] - if offset is not None and num is not None: - members = members[offset : offset + num] - return [member for member, _ in members] - - def apply(self, op: tuple) -> object: - name, *args = op - if name == "set": - self.keys[args[0]] = "payload" - return True - if name == "zadd": - self.zsets.setdefault(args[0], {}).update(args[1]) - return 1 - if name == "zrem": - self.zsets.get(args[0], {}).pop(args[1], None) - return 1 - if name == "expire": - return True - if name == "zremrangebyscore": - zset = self.zsets.get(args[0], {}) - for member in [ - member - for member, score in zset.items() - if score <= time.time() - ]: - del zset[member] - return 0 - if name == "zcard": - return len(self.zsets.get(args[0], {})) - raise AssertionError(f"unexpected command: {name}") - - - -class InMemoryGameStoreTest(unittest.TestCase): - @async_test - async def test_save_load_roundtrip(self) -> None: - store = InMemoryGameStore(_registry()) - session = _session() - await store.save(session) - - loaded = await store.load("g1") - self.assertIsNotNone(loaded) - assert loaded is not None - self.assertEqual("CODE01", loaded.join_code) - self.assertEqual("dummy", loaded.game_type) - self.assertEqual(5, loaded.state["target"]) - self.assertEqual(["alice"], [p.user_sub for p in loaded.players]) - # The loaded state is a deserialized copy, not the same object. - self.assertIsNot(loaded.state, session.state) - - @async_test - async def test_unknown_game_type_rejected(self) -> None: - store = InMemoryGameStore(_registry()) - session = _session() - session.game_type = "nope" - with self.assertRaises(Exception): - await store.save(session) - - @async_test - async def test_load_missing_returns_none(self) -> None: - store = InMemoryGameStore(_registry()) - self.assertIsNone(await store.load("nope")) - self.assertIsNone(await store.find_by_code("NOPE01")) - - @async_test - async def test_find_by_code(self) -> None: - store = InMemoryGameStore(_registry()) - await store.save(_session()) - found = await store.find_by_code("code01") # case-insensitive - self.assertIsNotNone(found) - assert found is not None - self.assertEqual("g1", found.id) - - @async_test - async def test_load_returns_a_copy(self) -> None: - store = InMemoryGameStore(_registry()) - await store.save(_session()) - first = await store.load("g1") - assert first is not None - first.state["target"] = 999 - second = await store.load("g1") - assert second is not None - self.assertEqual(5, second.state["target"]) - - @async_test - async def test_publish_reaches_subscriber(self) -> None: - store = InMemoryGameStore(_registry()) - await store.save(_session()) - - received = [] - - async with store.subscribe("g1") as events: - await store.publish("g1") - async for _ in events: - received.append(True) - break - - self.assertEqual([True], received) - - @async_test - async def test_lock_serializes_concurrent_mutations(self) -> None: - store = InMemoryGameStore(_registry()) - order = [] - - async def holder() -> None: - async with store.lock("g5"): - order.append("holder-enter") - await asyncio.sleep(0.05) - order.append("holder-exit") - - async def contender() -> None: - await asyncio.sleep(0.01) - async with store.lock("g5"): - order.append("contender") - - await asyncio.gather(holder(), contender()) - self.assertEqual( - ["holder-enter", "holder-exit", "contender"], order - ) - - @async_test - async def test_count_ongoing_tracks_unfinished_games(self) -> None: - store = InMemoryGameStore(_registry()) - self.assertEqual(0, await store.count_ongoing()) - - session = _session() - await store.save(session) - self.assertEqual(1, await store.count_ongoing()) - - other = _session(game_id="g2", code="CODE02") - await store.save(other) - self.assertEqual(2, await store.count_ongoing()) - - # A finished match is retained for the final board but no longer - # counts as ongoing. - session.state["finished"] = True - await store.save(session) - self.assertEqual(1, await store.count_ongoing()) - other.state["finished"] = True - await store.save(other) - self.assertEqual(0, await store.count_ongoing()) - - @async_test - async def test_deadline_queue(self) -> None: - store = InMemoryGameStore(_registry()) - self.assertIsNone(await store.next_deadline()) - self.assertEqual([], await store.due_deadlines(now=100.0)) - - await store.add_deadline("b", due_at=50.0) - await store.add_deadline("a", due_at=10.0) - await store.add_deadline("c", due_at=200.0) - # Re-adding an existing member only updates its due time. - await store.add_deadline("b", due_at=60.0) - - self.assertEqual(10.0, await store.next_deadline()) - self.assertEqual(["a"], await store.due_deadlines(now=10.0)) - self.assertEqual(["a", "b"], await store.due_deadlines(now=100.0)) - # Due entries come out in due-time order and stay queued until removed. - self.assertEqual(["a", "b"], await store.due_deadlines(now=100.0)) - - await store.remove_deadline("a") - await store.remove_deadline("a") # removing twice is a no-op - self.assertEqual(60.0, await store.next_deadline()) - self.assertEqual(["b"], await store.due_deadlines(now=100.0)) - - @async_test - async def test_deadline_notifications_reach_subscribers(self) -> None: - store = InMemoryGameStore(_registry()) - async with store.subscribe_deadlines() as events: - await store.notify_deadline() - signal = await asyncio.wait_for(anext(events), timeout=1.0) - self.assertIsNone(signal) - # The subscription is gone after the context exits: notifying - # again is a no-op rather than an error. - await store.notify_deadline() - - -class RedisGameStoreTest(unittest.TestCase): - @async_test - async def test_save_marks_games_ongoing_and_finished_ones_stop_counting(self) -> None: - redis = _FakeRedis() - store = RedisGameStore(redis, _registry(), ttl_seconds=3600) # type: ignore[arg-type] - session = _session() - - await store.save(session) - self.assertEqual(1, await store.count_ongoing()) - score = redis.zsets[ONGOING_GAMES_KEY][session.id] - self.assertGreater(score, time.time()) - self.assertLessEqual(score, time.time() + 3600) - - session.state["finished"] = True - await store.save(session) - self.assertNotIn(session.id, redis.zsets[ONGOING_GAMES_KEY]) - self.assertEqual(0, await store.count_ongoing()) - - @async_test - async def test_count_ongoing_prunes_expired_entries(self) -> None: - redis = _FakeRedis() - redis.zsets[ONGOING_GAMES_KEY] = { - "stale": time.time() - 10, - "live": time.time() + 100, - } - store = RedisGameStore(redis, _registry()) # type: ignore[arg-type] - self.assertEqual(1, await store.count_ongoing()) - self.assertNotIn("stale", redis.zsets[ONGOING_GAMES_KEY]) - - @async_test - async def test_due_deadlines_uses_zrange_byscore(self) -> None: - redis = _FakeRedis() - store = RedisGameStore(redis, _registry()) # type: ignore[arg-type] - await store.add_deadline("b", due_at=50.0) - await store.add_deadline("a", due_at=10.0) - await store.add_deadline("c", due_at=200.0) - # Re-adding an existing member only updates its due time. - await store.add_deadline("b", due_at=60.0) - - self.assertEqual(["a"], await store.due_deadlines(now=20.0)) - self.assertEqual( - [(DEADLINES_KEY, "-inf", 20.0, True, 0, 32)], redis.zrange_calls - ) - # Matches come back in due-time order and honour the limit. - self.assertEqual(["a"], await store.due_deadlines(now=100.0, limit=1)) - self.assertEqual(["a", "b"], await store.due_deadlines(now=100.0)) - - -if __name__ == "__main__": - unittest.main() diff --git a/server/packages/tavolo-platform/tests/test_ws.py b/server/packages/tavolo-platform/tests/test_ws.py deleted file mode 100644 index c6f8c65..0000000 --- a/server/packages/tavolo-platform/tests/test_ws.py +++ /dev/null @@ -1,306 +0,0 @@ -"""WebSocket live-play tests via kaya's ASGI websocket transport.""" -from __future__ import annotations - -import json -import unittest -from typing import Any, Dict, Mapping, Sequence - -from httpx import ASGITransport, AsyncClient -from httpx_ws import WebSocketDisconnect as HttpxWebSocketDisconnect, aconnect_ws -from httpx_ws.transport import ASGIWebSocketTransport -from kaya.core import WebSocket, WebSocketDisconnect, WebSocketMessage - -from helpers import async_test, make_platform, make_user, oidc_user, ws_users -from tavolo.platform import GameSession, Seat - - -class AbruptCloseSocket(WebSocket): - """A socket whose peer vanished without a close handshake. - - kaya surfaces this from the transport: ``receive()`` reports a - ``close`` message so iteration ends normally, while sends raise - ``WebSocketDisconnect``. - """ - - def __init__(self, path: str, gone: bool = False) -> None: - self.path = path - self.query_string = "" - self.scheme = "ws" - self.headers: Mapping[str, Sequence[str]] = {} - self.client = ("127.0.0.1", 12345) - self.server = ("127.0.0.1", 80) - self.sent: list[Dict[str, Any]] = [] - self.close_codes: list[int] = [] - self.gone = gone - - async def accept(self, headers: Any = None) -> None: - pass - - async def receive(self) -> WebSocketMessage: - self.gone = True - return WebSocketMessage(kind="close") - - async def send_text(self, data: str) -> None: - if self.gone: - raise WebSocketDisconnect(code=1006) - self.sent.append(json.loads(data)) - - async def send_bytes(self, data: bytes) -> None: - if self.gone: - raise WebSocketDisconnect(code=1006) - - async def close(self, code: int = 1000) -> None: - self.close_codes.append(code) - - async def __anext__(self) -> WebSocketMessage: - await self.receive() - raise StopAsyncIteration - - -class RecordingSocket(WebSocket): - """A socket that only records pre-accept rejections.""" - - def __init__(self, path: str) -> None: - self.path = path - self.query_string = "" - self.scheme = "ws" - self.headers: Mapping[str, Sequence[str]] = {} - self.client = ("127.0.0.1", 12345) - self.server = ("127.0.0.1", 80) - self.close_codes: list[int] = [] - - async def accept(self, headers: Any = None) -> None: - raise AssertionError("a rejected socket must not be accepted") - - async def receive(self) -> WebSocketMessage: - raise AssertionError("a rejected socket must not receive") - - async def send_text(self, data: str) -> None: - raise AssertionError("a rejected socket must not send") - - async def send_bytes(self, data: bytes) -> None: - raise AssertionError("a rejected socket must not send") - - async def close(self, code: int = 1000) -> None: - self.close_codes.append(code) - - async def __anext__(self) -> WebSocketMessage: - raise AssertionError("a rejected socket must not receive") - - -class WebSocketTest(unittest.TestCase): - async def _started_game(self, client: AsyncClient, oidc, target: int = 3) -> dict: - """Create a game and seat both players; return the started state.""" - with oidc_user(oidc, "alice"): - created = await client.post( - "/api/games", json={"options": {"target": target}} - ) - code = created.json()["join_code"] - with oidc_user(oidc, "bob"): - response = await client.post("/api/games/join", json={"code": code}) - return response.json() - - @async_test - async def test_move_updates_all_connections(self) -> None: - app, platform, _ = make_platform() - api_transport = ASGITransport(app=app) - async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client: - state = await self._started_game(client, platform.oidc) - game_id = state["id"] - - ws_transport = ASGIWebSocketTransport(app=app) - async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: - with ws_users([make_user("bob"), make_user("alice")]): - async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws: - first = await bob_ws.receive_json() - self.assertEqual("state", first["type"]) - self.assertEqual("dummy", first["game"]["game_type"]) - self.assertEqual(0, first["game"]["plays"]) - self.assertEqual(game_id, first["game"]["id"]) - - async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws: - alice_first = await alice_ws.receive_json() - self.assertEqual("state", alice_first["type"]) - - await bob_ws.send_json({"action": "play"}) - bob_update = await bob_ws.receive_json() - alice_update = await alice_ws.receive_json() - - for update in (bob_update, alice_update): - self.assertEqual("state", update["type"]) - self.assertEqual(1, update["game"]["plays"]) - - @async_test - async def test_unknown_action_returns_error(self) -> None: - app, platform, _ = make_platform() - api_transport = ASGITransport(app=app) - async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client: - state = await self._started_game(client, platform.oidc) - game_id = state["id"] - - ws_transport = ASGIWebSocketTransport(app=app) - async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: - with ws_users([make_user("bob")]): - async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws: - await bob_ws.receive_json() - await bob_ws.send_json({"action": "dance"}) - error = await bob_ws.receive_json() - self.assertEqual("error", error["type"]) - self.assertEqual("illegal_move", error["code"]) - - @async_test - async def test_state_action_resyncs(self) -> None: - app, platform, _ = make_platform() - api_transport = ASGITransport(app=app) - async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client: - state = await self._started_game(client, platform.oidc) - game_id = state["id"] - - ws_transport = ASGIWebSocketTransport(app=app) - async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: - with ws_users([make_user("bob")]): - async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws: - await bob_ws.receive_json() - await bob_ws.send_json({"action": "state"}) - resent = await bob_ws.receive_json() - self.assertEqual("state", resent["type"]) - - @async_test - async def test_abrupt_disconnect_is_not_an_error(self) -> None: - app, platform, _ = make_platform() - api_transport = ASGITransport(app=app) - async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client: - state = await self._started_game(client, platform.oidc) - game_id = state["id"] - - ws = AbruptCloseSocket(f"/ws/games/{game_id}") - with ws_users([make_user("bob")]): - # kaya reports a peer that vanished without a close handshake as - # a close message; the handler must exit cleanly. - await app.handle_websocket(ws) - self.assertEqual("state", ws.sent[0]["type"]) - self.assertEqual([], ws.close_codes) - - @async_test - async def test_send_on_vanished_peer_is_ignored(self) -> None: - app, platform, _ = make_platform() - api_transport = ASGITransport(app=app) - async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client: - state = await self._started_game(client, platform.oidc) - game_id = state["id"] - - ws = AbruptCloseSocket(f"/ws/games/{game_id}", gone=True) - with ws_users([make_user("bob")]): - # Sending raises WebSocketDisconnect; the handler swallows it - # rather than letting it escape the forward task. - await app.handle_websocket(ws) - self.assertEqual([], ws.sent) - self.assertEqual([], ws.close_codes) - - @async_test - async def test_game_over_broadcast(self) -> None: - app, platform, _ = make_platform() - api_transport = ASGITransport(app=app) - async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client: - # target 1: the first play ends the match. - state = await self._started_game(client, platform.oidc, target=1) - game_id = state["id"] - - ws_transport = ASGIWebSocketTransport(app=app) - async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: - with ws_users([make_user("alice"), make_user("bob")]): - async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws: - await alice_ws.receive_json() - async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws: - await bob_ws.receive_json() - await bob_ws.send_json({"action": "play"}) - # Both connections see the final state... - alice_final = await alice_ws.receive_json() - bob_final = await bob_ws.receive_json() - self.assertTrue(alice_final["game"]["finished"]) - self.assertTrue(bob_final["game"]["finished"]) - # ...followed by the game_over announcement. - alice_over = await alice_ws.receive_json() - bob_over = await bob_ws.receive_json() - self.assertEqual("game_over", alice_over["type"]) - self.assertEqual("game_over", bob_over["type"]) - - @async_test - async def test_unknown_game_is_closed(self) -> None: - app, _, _ = make_platform() - ws_transport = ASGIWebSocketTransport(app=app) - async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: - with ws_users([make_user("alice")]): - with self.assertRaises(HttpxWebSocketDisconnect) as caught: - async with aconnect_ws("/ws/games/no-such-game", ws_client): - pass - self.assertEqual(404, caught.exception.code) - - @async_test - async def test_non_player_is_closed(self) -> None: - app, platform, _ = make_platform() - api_transport = ASGITransport(app=app) - async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client: - state = await self._started_game(client, platform.oidc) - game_id = state["id"] - - ws_transport = ASGIWebSocketTransport(app=app) - async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: - with ws_users([make_user("mallory")]): - with self.assertRaises(HttpxWebSocketDisconnect) as caught: - async with aconnect_ws(f"/ws/games/{game_id}", ws_client): - pass - self.assertEqual(403, caught.exception.code) - - @async_test - async def test_unauthenticated_is_closed(self) -> None: - app, _, _ = make_platform() - ws_transport = ASGIWebSocketTransport(app=app) - async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: - with ws_users([]): - with self.assertRaises(HttpxWebSocketDisconnect) as caught: - async with aconnect_ws("/ws/games/whatever", ws_client): - pass - self.assertEqual(401, caught.exception.code) - - -class RejectionStatusTest(unittest.TestCase): - """Pre-accept rejections must carry HTTP statuses, not close codes. - - Granian's RSGI layer uses the value passed to ``close()`` before - ``accept()`` as the HTTP status of the refused upgrade; a WebSocket - close code like 4401 is not a valid status and panics the worker - (``InvalidStatusCode``, and with ``panic = "abort"`` the whole app - dies). Drive the rejections through the app with a recording socket - so the invariant is checked without a real server. - """ - - @async_test - async def test_pre_accept_rejections_are_http_statuses(self) -> None: - app, platform, _ = make_platform() - session = GameSession( - id="seated-game", - game_type="dummy", - join_code="SEAT01", - creator_sub="alice", - players=[Seat(user_sub="alice", display_name="Alice")], - ) - engine = platform.registry.require("dummy") - engine.create(session, {}) - await platform.game_store.save(session) - cases = [ - ("unauthenticated", [], "/ws/games/whatever", 401), - ("unknown game", [make_user("alice")], "/ws/games/no-such-game", 404), - ("not seated", [make_user("mallory")], f"/ws/games/{session.id}", 403), - ] - for label, users, path, expected in cases: - ws = RecordingSocket(path) - with ws_users(users): - await app.handle_websocket(ws) - self.assertEqual([expected], ws.close_codes, label) - for code in ws.close_codes: - self.assertTrue(100 <= code <= 599, f"{label}: {code} is not an HTTP status") - - -if __name__ == "__main__": - unittest.main() diff --git a/server/packages/tavolo-scopone/README.md b/server/packages/tavolo-scopone/README.md deleted file mode 100644 index c491067..0000000 --- a/server/packages/tavolo-scopone/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# tavolo-scopone - -Scopone scientifico — the four-player, fixed-partnership Italian card -game — as a [`tavolo-platform`](../tavolo-platform/README.md) game -implementation. - -## Contents - -- `state.py` — `ScoponeState` (pure game data: phases, players, hands, - table, scores, deadlines), `PlayerState`, `Card`, `Move`, with JSON - (de)serialization. No session envelope, no transport, no I/O. -- `engine.py` — the pure rules engine: deck, legal captures, plays, - auto-play, hand scoring (carte, denara, settebello, primiera, scope, - napola), the optional asso pigliatutto capture, hand-end - acknowledgements. Deterministic and I/O-free apart from logging, so the - whole rule set is unit-testable. -- `errors.py` — scopone-specific errors (`CardNotInHand`); every other - failure mode is a shared `tavolo.platform.errors` subclass. -- `plugin.py` — `ScoponeEngine(GameEngine)`: the platform-facing adapter. - It translates create/join/websocket actions/deadlines into rules-engine - calls and back, validates the `target_score`/`napola`/`asso_pigliatutto` - creation options, and extracts the `MatchResult` (teams, winner, - per-player scores and the match summary persisted as the match's JSON - `result`). - -Timeouts are constructor arguments (`turn_timeout_seconds`, -`hand_ack_timeout_seconds`), wired from the environment by the -application composition root. - -## Development (from `server/`) - -```sh -.venv/bin/python -m unittest discover -s packages/tavolo-scopone/tests -.venv/bin/python -m mypy -p tavolo.scopone -``` - -`test_engine.py` covers the pure rules; `test_plugin.py` covers the -platform contract (actions, deadlines, serialization, results). diff --git a/server/packages/tavolo-scopone/pyproject.toml b/server/packages/tavolo-scopone/pyproject.toml deleted file mode 100644 index 3798a7a..0000000 --- a/server/packages/tavolo-scopone/pyproject.toml +++ /dev/null @@ -1,22 +0,0 @@ -[build-system] -requires = ["setuptools>=68"] -build-backend = "setuptools.build_meta" - -[project] -name = "tavolo-scopone" -version = "0.1.0" -description = "Scopone scientifico game implementation for the tavolo platform" -readme = "README.md" -requires-python = ">=3.10" -dependencies = [ - "tavolo-platform", -] - -[tool.setuptools.packages.find] -where = ["src"] -namespaces = true - -[tool.mypy] -python_version = "3.12" -ignore_missing_imports = true -plugins = [] diff --git a/server/packages/tavolo-scopone/src/tavolo/scopone/__init__.py b/server/packages/tavolo-scopone/src/tavolo/scopone/__init__.py deleted file mode 100644 index 6c49ee0..0000000 --- a/server/packages/tavolo-scopone/src/tavolo/scopone/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Scopone scientifico: tavolo's first game implementation. - -The pure rules (:mod:`tavolo.scopone.engine`, :mod:`tavolo.scopone.state`) -know nothing about HTTP, Redis or Postgres; :mod:`tavolo.scopone.plugin` -adapts them to the platform's -:class:`~tavolo.platform.engine.GameEngine` contract so the -game-independent platform can host them. -""" -from __future__ import annotations - -from .plugin import ScoponeEngine - -__all__ = ["ScoponeEngine"] diff --git a/server/packages/tavolo-scopone/src/tavolo/scopone/engine.py b/server/packages/tavolo-scopone/src/tavolo/scopone/engine.py deleted file mode 100644 index 8df3c2e..0000000 --- a/server/packages/tavolo-scopone/src/tavolo/scopone/engine.py +++ /dev/null @@ -1,587 +0,0 @@ -"""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) -:class:`~tavolo.scopone.state.ScoponeState` and raises -:class:`~tavolo.platform.errors.GameError` subclasses on rule violations. -This makes the whole rule set unit-testable without Redis, Postgres or -HTTP. The platform-facing adapter is -:class:`~tavolo.scopone.plugin.ScoponeEngine`. - -Rules implemented ------------------ -* 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. -""" -from __future__ import annotations - -import random -from datetime import datetime, timedelta, timezone -from itertools import combinations -from logging import getLogger -from typing import Dict, List, Optional, Sequence, Tuple - -from tavolo.platform.errors import ( - AlreadyJoined, - GameFinished, - GameNotStarted, - IllegalMove, - LobbyFull, - NotYourTurn, -) - -from .errors import CardNotInHand - -from .state import ( - DEFAULT_TARGET_SCORE, - PHASE_FINISHED, - PHASE_HAND_END, - PHASE_LOBBY, - PHASE_PLAYING, - SUITS, - TEAM_NAMES, - Card, - Move, - PlayerState, - ScoponeState, - parse_card, -) - -log = getLogger(__name__) - -# Number of cards dealt to each player at the start of a hand. -HAND_SIZE = 10 -PLAYERS = 4 - -# Default seconds the hand-end summary waits before dealing anyway. Games -# carry their own copy in ``ScoponeState.hand_ack_timeout``. -DEFAULT_HAND_ACK_TIMEOUT_SECONDS = 30 - -# Default seconds a player has to play before the server plays a random -# legal card for them. Games carry their own copy in -# ``ScoponeState.turn_timeout``. -DEFAULT_TURN_TIMEOUT_SECONDS = 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. -PRIMIERA_VALUES: Dict[int, int] = { - 7: 21, - 6: 18, - 1: 16, - 5: 15, - 4: 14, - 3: 13, - 2: 12, - 8: 10, - 9: 10, - 10: 10, -} - -_rng = random.SystemRandom() - - -def full_deck() -> List[Card]: - """Return the 40 cards of the Italian deck in canonical order.""" - return [Card(rank=rank, suit=suit) for suit in SUITS for rank in range(1, 11)] - - -def shuffled_deck(rng: Optional[random.Random] = None) -> List[Card]: - """Return a shuffled deck. Pass ``rng`` for deterministic tests.""" - deck = full_deck() - (rng or _rng).shuffle(deck) - return deck - - -def legal_captures( - table: Sequence[Card], card: Card, *, asso_pigliatutto: bool = False -) -> List[List[Card]]: - """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. - """ - equal = [c for c in table if c.rank == card.rank] - if equal: - return [[c] for c in equal] - if asso_pigliatutto and card.rank == 1: - return [list(table)] - - candidates = [c for c in table if c.rank <= card.rank] - captures: List[List[Card]] = [] - # A sum-equal capture needs at least two cards (single non-equal cards - # cannot sum to the played card). - for size in range(2, len(candidates) + 1): - for combo in combinations(candidates, size): - if sum(c.rank for c in combo) == card.rank: - captures.append(list(combo)) - return captures - - -def create_game( - creator_sub: str, - creator_name: str, - target_score: int = DEFAULT_TARGET_SCORE, - hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS, - turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS, - napola: bool = True, - asso_pigliatutto: bool = False, -) -> ScoponeState: - """Create a lobby game with the creator seated first.""" - if target_score < 1 or target_score > 100: - raise IllegalMove("target_score must be between 1 and 100") - return ScoponeState( - target_score=target_score, - napola=napola, - asso_pigliatutto=asso_pigliatutto, - phase=PHASE_LOBBY, - players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)], - hand_ack_timeout=hand_ack_timeout, - turn_timeout=turn_timeout, - ) - - -def join_game(state: ScoponeState, sub: str, name: str) -> None: - """Seat ``sub`` in the next free chair, starting the match when full.""" - if state.phase != PHASE_LOBBY: - raise GameNotStarted("game has already started") - if state.seated(sub): - raise AlreadyJoined("already joined this game") - if len(state.players) >= PLAYERS: - raise LobbyFull("game is full") - seat = len(state.players) - state.players.append(PlayerState(sub=sub, name=name, seat=seat)) - if len(state.players) == PLAYERS: - start_game(state) - - -def start_game(state: ScoponeState) -> None: - """Deal the first hand and switch the game to playing.""" - if len(state.players) != PLAYERS: - raise GameNotStarted("need exactly four players to start") - state.phase = PHASE_PLAYING - _deal_hand(state) - - -def _set_turn_deadline(state: ScoponeState) -> None: - """Arm the auto-play deadline for whoever is on turn.""" - deadline = datetime.now(timezone.utc) + timedelta(seconds=state.turn_timeout) - state.turn_deadline = deadline.isoformat() - - -def _deal_hand(state: ScoponeState) -> None: - deck = shuffled_deck() - for player in state.players: - player.hand = [] - player.captured = [] - player.scope = 0 - state.table = [] - 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 - _set_turn_deadline(state) - for offset in range(HAND_SIZE): - for seat in range(PLAYERS): - player = _player_at(state, (state.dealer + 1 + seat) % PLAYERS) - player.hand.append(deck.pop()) - log.debug("hand %d dealt (dealer seat %d)", state.hand_number, state.dealer) - - -def _player_at(state: ScoponeState, seat: int) -> PlayerState: - for player in state.players: - if player.seat == seat: - return player - raise IllegalMove(f"no player in seat {seat}") - - -def play( - state: ScoponeState, - sub: str, - card_code: str, - capture_codes: Optional[Sequence[str]] = None, -) -> 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 :func:`legal_captures`) when one exists and empty - otherwise. Raises a :class:`~tavolo.platform.errors.GameError` subclass - on any violation. - """ - if state.phase == PHASE_FINISHED: - raise GameFinished("the match is over") - if state.phase == PHASE_HAND_END: - raise IllegalMove("the hand is over; acknowledge the summary to continue") - if state.phase != PHASE_PLAYING: - raise GameNotStarted("the game has not started yet") - - player = state.player_for(sub) - if player is None or player.seat != state.turn: - raise NotYourTurn("it is not your turn") - - card = parse_card(card_code) - if card not in player.hand: - raise CardNotInHand(f"card {card.code} is not in your hand") - # Remove the card now so the scopa check below can tell whether this - # was the last play of the hand. - player.hand.remove(card) - - requested = [parse_card(c) for c in (capture_codes or [])] - table_before = list(state.table) - options = legal_captures( - table_before, card, asso_pigliatutto=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. - pigliatutto = ( - state.asso_pigliatutto - and card.rank == 1 - and all(c.rank != card.rank for c in table_before) - ) - - taken: List[Card] = [] - scopa = False - if not options: - if requested: - raise IllegalMove("no capture is possible with that card") - state.table.append(card) - else: - chosen = _match_option(options, requested) - if chosen is None: - raise IllegalMove("the requested capture is not legal") - taken = chosen - for captured in chosen: - state.table.remove(captured) - player.captured.append(captured) - player.captured.append(card) - state.last_taker = player.seat - # A scopa scores only if cards remain to be played this hand. - hands_empty = all(not p.hand for p in state.players) - if not state.table and not hands_empty and not pigliatutto: - player.scope += 1 - scopa = True - - state.last_move = Move( - seat=player.seat, - name=player.name, - card=card.code, - captured=[c.code for c in taken], - scopa=scopa, - pigliatutto=pigliatutto, - ) - - if all(not p.hand for p in state.players): - _end_hand(state) - else: - state.turn = (state.turn + 1) % PLAYERS - _set_turn_deadline(state) - - -def _match_option( - options: Sequence[Sequence[Card]], requested: Sequence[Card] -) -> Optional[List[Card]]: - """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. - """ - wanted = sorted(c.code for c in requested) - for option in options: - if sorted(c.code for c in option) == wanted: - return list(option) - return None - - -def auto_play(state: ScoponeState, rng: Optional[random.Random] = None) -> None: - """Play a random legal move for the player currently on turn. - - A card is drawn at random from that player's hand; if it can capture, - one of the legal captures is chosen at random (the rules require a - capture when one exists). Delegates to :func:`play`, so the move is - fully validated and can end the hand or the match. Pass ``rng`` for - deterministic tests. - """ - if state.phase != PHASE_PLAYING: - raise GameNotStarted("the game has not started yet") - player = _player_at(state, state.turn) - if not player.hand: - raise IllegalMove("the player on turn has no cards") - chooser = rng or _rng - card = chooser.choice(player.hand) - options = legal_captures( - state.table, card, asso_pigliatutto=state.asso_pigliatutto - ) - capture = chooser.choice(options) if options else None - play( - state, - player.sub, - card.code, - [c.code for c in capture] if capture is not None else None, - ) - - -def _end_hand(state: ScoponeState) -> None: - """Sweep the table and score the hand. - - If the match continues, the game pauses in the ``hand_end`` phase so - every player can read the scoring summary; the next hand is dealt by - :func:`acknowledge_hand` once all four players have acknowledged (or - by the hand-end timeout fired by the platform's deadline scheduler). - If the match is over the game goes to ``finished`` immediately. - """ - state.turn_deadline = None - if state.table and state.last_taker is not None: - taker = _player_at(state, state.last_taker) - taker.captured.extend(state.table) - state.table = [] - - points, details = hand_points(state) - for team in (0, 1): - state.scores[team] += points[team] - details["hand"] = state.hand_number - details["team_a_points"] = points[0] - details["team_b_points"] = points[1] - state.hand_scores.append(details) - - a, b = state.scores - log.debug( - "hand %d scored A+%d B+%d (totals %d-%d)", - state.hand_number, - points[0], - points[1], - a, - b, - ) - # A full napola (the whole denari suit) wins the match outright, - # regardless of the score. - napola = details.get("napola") - if isinstance(napola, dict): - for team, name in enumerate(TEAM_NAMES): - if napola.get(name) == 10: - state.phase = PHASE_FINISHED - state.winner = team - log.info("team %s swept the denari (napola) and wins", name) - return - reached = max(a, b) >= state.target_score - if reached and a != b: - state.phase = PHASE_FINISHED - state.winner = 0 if a > b else 1 - log.debug("match ended, team %s wins", "A" if state.winner == 0 else "B") - return - - # Pause for the scoring summary instead of dealing immediately. - state.phase = PHASE_HAND_END - state.acked = [] - deadline = datetime.now(timezone.utc) + timedelta(seconds=state.hand_ack_timeout) - state.hand_end_deadline = deadline.isoformat() - - -def acknowledge_hand(state: ScoponeState, sub: str) -> None: - """Record that ``sub`` has read the hand-end summary. - - When all four players have acknowledged, the next hand is dealt. - Acknowledging twice is a no-op; acknowledging outside the ``hand_end`` - phase raises an error. - """ - if state.phase != PHASE_HAND_END: - raise IllegalMove("no hand summary is waiting for acknowledgement") - player = state.player_for(sub) - if player is None: - raise NotYourTurn("you are not seated in this game") - if player.seat in state.acked: - return - state.acked.append(player.seat) - if len(state.acked) < PLAYERS: - return - - state.hand_number += 1 - state.dealer = (state.dealer + 1) % PLAYERS - state.acked = [] - state.hand_end_deadline = None - state.last_move = None - state.phase = PHASE_PLAYING - _deal_hand(state) - - -def primiera_score(captured: Sequence[Card]) -> int: - """Return the primiera value of a capture pile (0 if a suit is absent).""" - best: Dict[str, int] = {} - for card in captured: - value = PRIMIERA_VALUES[card.rank] - if card.suit not in best or value > best[card.suit]: - best[card.suit] = value - if len(best) < len(SUITS): - return 0 - return sum(best.values()) - - -def napola_score(captured: Sequence[Card]) -> int: - """Return the napola value of a capture pile. - - The longest run of consecutive denari starting from the ace scores one - point per card once it reaches three cards (A-2-3 = 3, A-2-3-4 = 4, - ...), so the whole suit (ace to king) is worth 10. Shorter runs score - nothing. Only one team can score a napola: the ace of denari belongs - to exactly one capture pile. - """ - ranks = {card.rank for card in captured if card.suit == "D"} - run = 0 - while run + 1 in ranks: - run += 1 - return run if run >= 3 else 0 - - -def hand_points(state: ScoponeState) -> Tuple[List[int], Dict[str, object]]: - """Compute the hand points for both teams (index 0 = team A).""" - piles: List[List[Card]] = [[], []] - scope: List[int] = [0, 0] - for player in state.players: - piles[player.team].extend(player.captured) - scope[player.team] += player.scope - - points = [0, 0] - award: Dict[str, Optional[str]] = {} - # Carte: most captured cards. Ties award nothing. - cards = [len(piles[0]), len(piles[1])] - if cards[0] != cards[1]: - winner = 0 if cards[0] > cards[1] else 1 - points[winner] += 1 - award["carte"] = TEAM_NAMES[winner] - else: - award["carte"] = None - # Denara: most diamond cards. Ties award nothing. - coins = [ - sum(1 for c in piles[t] if c.suit == "D") for t in (0, 1) - ] - if coins[0] != coins[1]: - winner = 0 if coins[0] > coins[1] else 1 - points[winner] += 1 - award["denara"] = TEAM_NAMES[winner] - else: - award["denara"] = None - # Settebello: the 7 of diamonds always belongs to someone. - settebello = [ - any(c.rank == 7 and c.suit == "D" for c in piles[t]) for t in (0, 1) - ] - if settebello[0] != settebello[1]: - winner = 0 if settebello[0] else 1 - points[winner] += 1 - award["settebello"] = TEAM_NAMES[winner] - # Primiera: highest value, only if the team holds all four suits. - primiera = [primiera_score(piles[t]) for t in (0, 1)] - if primiera[0] != primiera[1]: - winner = 0 if primiera[0] > primiera[1] else 1 - points[winner] += 1 - award["primiera"] = TEAM_NAMES[winner] - else: - award["primiera"] = None - # Scope: one point each. - points[0] += scope[0] - points[1] += scope[1] - - details: Dict[str, object] = { - "cards": {"A": cards[0], "B": cards[1]}, - "denara": {"A": coins[0], "B": coins[1]}, - "settebello": {"A": settebello[0], "B": settebello[1]}, - "primiera": {"A": primiera[0], "B": primiera[1]}, - "scope": {"A": scope[0], "B": scope[1]}, - "award": award, - } - # Napola (optional rule): consecutive denari from the ace. A run of 10 - # means the team swept the whole suit and wins the match instantly. - if state.napola: - napola = [napola_score(piles[t]) for t in (0, 1)] - for team in (0, 1): - points[team] += napola[team] - award["napola"] = next( - (TEAM_NAMES[t] for t in (0, 1) if napola[t] > 0), None - ) - details["napola"] = {"A": napola[0], "B": napola[1]} - return points, details - - -def state_for_player(state: ScoponeState, sub: str) -> Dict[str, object]: - """Serialize ``state`` hiding other players' hands. - - Hands are reduced to a count, except for the requesting player's own - hand. Non-seated viewers simply see no hand at all. The platform - merges the session envelope (``id``, ``join_code``, ``game_type``) - into the view itself. - """ - viewer = state.player_for(sub) - players: List[Dict[str, object]] = [] - for player in state.players: - view: Dict[str, object] = { - "sub": player.sub, - "name": player.name, - "seat": player.seat, - "team": TEAM_NAMES[player.team], - "cards_left": len(player.hand), - "captured_count": len(player.captured), - "scope": player.scope, - } - if viewer is not None and viewer.seat == player.seat: - view["hand"] = [c.code for c in player.hand] - players.append(view) - - payload: Dict[str, object] = { - "phase": state.phase, - "target_score": state.target_score, - "napola": state.napola, - "asso_pigliatutto": state.asso_pigliatutto, - "hand_number": state.hand_number, - "dealer": state.dealer, - "turn": state.turn, - "scores": {"A": state.scores[0], "B": state.scores[1]}, - "winner": None if state.winner is None else TEAM_NAMES[state.winner], - "table": [c.code for c in state.table], - "players": players, - "last_hand": state.hand_scores[-1] if state.hand_scores else None, - "last_move": state.last_move.to_json() if state.last_move else None, - "acknowledged": list(state.acked), - "hand_end_deadline": state.hand_end_deadline, - "turn_deadline": state.turn_deadline, - } - if viewer is not None and state.phase == PHASE_PLAYING and viewer.seat == state.turn: - payload["your_turn"] = True - # Only the player on turn receives their legal captures, so all the - # rule logic stays server-side. - legal_moves: Dict[str, List[List[str]]] = {} - for hand_card in viewer.hand: - options = legal_captures( - state.table, hand_card, asso_pigliatutto=state.asso_pigliatutto - ) - if options: - legal_moves[hand_card.code] = [ - [c.code for c in option] for option in options - ] - payload["legal_moves"] = legal_moves - return payload diff --git a/server/packages/tavolo-scopone/src/tavolo/scopone/errors.py b/server/packages/tavolo-scopone/src/tavolo/scopone/errors.py deleted file mode 100644 index e32f1be..0000000 --- a/server/packages/tavolo-scopone/src/tavolo/scopone/errors.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Scopone scientifico errors. - -Most failure modes are game-independent and live in -:mod:`tavolo.platform.errors`; only genuinely scopone-specific errors -are defined here. -""" -from __future__ import annotations - -from tavolo.platform.errors import ( - AlreadyJoined, - GameError, - GameFinished, - GameNotFound, - GameNotStarted, - IllegalMove, - LobbyFull, - NotYourTurn, -) - -__all__ = [ - "AlreadyJoined", - "CardNotInHand", - "GameError", - "GameFinished", - "GameNotFound", - "GameNotStarted", - "IllegalMove", - "LobbyFull", - "NotYourTurn", -] - - -class CardNotInHand(GameError): - """The played card is not held by the player.""" diff --git a/server/packages/tavolo-scopone/src/tavolo/scopone/plugin.py b/server/packages/tavolo-scopone/src/tavolo/scopone/plugin.py deleted file mode 100644 index cd55333..0000000 --- a/server/packages/tavolo-scopone/src/tavolo/scopone/plugin.py +++ /dev/null @@ -1,277 +0,0 @@ -"""The platform-facing adapter for scopone scientifico. - -:class:`ScoponeEngine` implements -:class:`~tavolo.platform.engine.GameEngine` on top of the pure rules in -:mod:`tavolo.scopone.engine`: it translates platform calls (create, join, -websocket actions, deadlines) into rules-engine calls and back, keeping -all scopone knowledge inside this package. Nothing outside -``tavolo.scopone`` needs to know about phases, turns or hand scoring. -""" -from __future__ import annotations - -from datetime import datetime, timezone -from logging import getLogger -from typing import Any, Dict, Mapping, Optional - -from tavolo.platform.engine import ( - Deadline, - GameEngine, - GameSession, - MatchResult, - PlayerResult, - Seat, -) -from tavolo.platform.errors import GameError, IllegalMove - -from . import engine -from .state import ( - DEFAULT_TARGET_SCORE, - PHASE_FINISHED, - PHASE_HAND_END, - PHASE_LOBBY, - PHASE_PLAYING, - TEAM_NAMES, - ScoponeState, -) - -log = getLogger(__name__) - - -def _deadline_ms(iso: Optional[str]) -> Optional[int]: - """Epoch milliseconds for an ISO-8601 deadline, ``None`` when absent - or unparseable.""" - if not iso: - return None - try: - return int(datetime.fromisoformat(iso).timestamp() * 1000) - except ValueError: - return None - - -class ScoponeEngine(GameEngine): - """Scopone scientifico as a platform game engine.""" - - id = "scopone_scientifico" - name = "Scopone scientifico" - description = ( - "Four players in fixed partnerships, ten cards each and an empty " - "table. First team to the target score wins." - ) - min_players = 4 - max_players = 4 - options_schema = { - "type": "object", - "properties": { - "target_score": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": DEFAULT_TARGET_SCORE, - "description": "Match points the winning team must reach.", - }, - "napola": { - "type": "boolean", - "default": True, - "description": "Score the napola rule; a full denari " - "sweep wins the match", - }, - "asso_pigliatutto": { - "type": "boolean", - "default": False, - "description": "Score the asso pigliatutto rule; an ace " - "sweeps the whole table (no scopa)", - }, - }, - } - - def __init__( - self, - turn_timeout_seconds: int = engine.DEFAULT_TURN_TIMEOUT_SECONDS, - hand_ack_timeout_seconds: int = engine.DEFAULT_HAND_ACK_TIMEOUT_SECONDS, - ) -> None: - self._turn_timeout = turn_timeout_seconds - self._hand_ack_timeout = hand_ack_timeout_seconds - - # -- lobby ------------------------------------------------------------ - - def create(self, session: GameSession, options: Mapping[str, Any]) -> None: - target_score = options.get("target_score", DEFAULT_TARGET_SCORE) - if isinstance(target_score, bool) or not isinstance(target_score, int): - raise IllegalMove("target_score must be an integer") - napola = options.get("napola", True) - if not isinstance(napola, bool): - raise IllegalMove("napola must be a boolean") - asso_pigliatutto = options.get("asso_pigliatutto", False) - if not isinstance(asso_pigliatutto, bool): - raise IllegalMove("asso_pigliatutto must be a boolean") - creator = session.players[0] - session.state = engine.create_game( - creator_sub=creator.user_sub, - creator_name=creator.display_name, - target_score=target_score, - hand_ack_timeout=self._hand_ack_timeout, - turn_timeout=self._turn_timeout, - napola=napola, - asso_pigliatutto=asso_pigliatutto, - ) - # The creator takes seat 0, i.e. team A. - session.players[0] = Seat( - user_sub=creator.user_sub, - display_name=creator.display_name, - team=TEAM_NAMES[0], - ) - - def join(self, session: GameSession, user_sub: str, display_name: str) -> None: - seat = len(session.players) - engine.join_game(session.state, user_sub, display_name) - # join_game raises before appending on any violation, so the seat - # list stays in sync with the engine's players. - session.players.append( - Seat( - user_sub=user_sub, - display_name=display_name, - team=TEAM_NAMES[seat % 2], - ) - ) - - def in_lobby(self, session: GameSession) -> bool: - return session.state.phase == PHASE_LOBBY - - def lobby_view(self, session: GameSession) -> Dict[str, Any]: - state: ScoponeState = session.state - return { - "phase": state.phase, - "target_score": state.target_score, - "napola": state.napola, - "asso_pigliatutto": state.asso_pigliatutto, - } - - # -- play ------------------------------------------------------------- - - def handle_action( - self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any] - ) -> None: - state: ScoponeState = session.state - if action == "play": - card = payload.get("card") - capture = payload.get("capture") - if not isinstance(card, str): - raise IllegalMove("'card' must be a card code string") - if capture is not None and ( - not isinstance(capture, list) - or any(not isinstance(item, str) for item in capture) - ): - raise IllegalMove("'capture' must be a list of card codes") - try: - engine.play(state, user_sub, card, capture) - except ValueError: - raise IllegalMove("invalid card code") - elif action == "ack": - engine.acknowledge_hand(state, user_sub) - else: - raise IllegalMove(f"unknown action: {action!r}") - - def view_for(self, session: GameSession, user_sub: str) -> Dict[str, Any]: - return engine.state_for_player(session.state, user_sub) - - def is_finished(self, session: GameSession) -> bool: - return session.state.phase == PHASE_FINISHED - - def game_over_view(self, session: GameSession, user_sub: str) -> Dict[str, Any]: - state: ScoponeState = session.state - return { - "scores": {"A": state.scores[0], "B": state.scores[1]}, - "winner": None if state.winner is None else TEAM_NAMES[state.winner], - } - - # -- (de)serialization ------------------------------------------------- - - def state_to_json(self, state: Any) -> Dict[str, Any]: - assert isinstance(state, ScoponeState) - return state.to_json() - - def state_from_json(self, data: Mapping[str, Any]) -> Any: - return ScoponeState.from_json(dict(data)) - - # -- deadlines ---------------------------------------------------------- - - def next_deadline(self, session: GameSession) -> Optional[Deadline]: - state: ScoponeState = session.state - if state.phase == PHASE_PLAYING and state.turn_deadline: - due_ms = _deadline_ms(state.turn_deadline) - if due_ms is None: - return None - return Deadline( - kind="turn", - due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc), - token=f"turn:{state.hand_number}:{state.turn}:{due_ms}", - ) - if state.phase == PHASE_HAND_END and state.hand_end_deadline: - due_ms = _deadline_ms(state.hand_end_deadline) - if due_ms is None: - return None - return Deadline( - kind="hand_end", - due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc), - token=f"hand_end:{state.hand_number}:{due_ms}", - ) - return None - - def fire_deadline(self, session: GameSession, kind: str, token: str) -> None: - current = self.next_deadline(session) - if current is None or current.kind != kind or current.token != token: - # Overtaken by events (a play landed in time, the hand was - # acknowledged, the deadline moved): nothing to do. - raise GameError("stale deadline") - state: ScoponeState = session.state - if kind == "turn": - engine.auto_play(state) - actor = state.last_move.seat if state.last_move is not None else None - log.info( - "auto-played for seat %s (turn timeout, hand %d)", - actor, - state.hand_number, - ) - elif kind == "hand_end": - for player in state.players: - engine.acknowledge_hand(state, player.sub) - log.info( - "hand %d auto-advanced after the acknowledgement timeout", - state.hand_number, - ) - else: # pragma: no cover - next_deadline never emits other kinds - raise GameError(f"unknown deadline kind: {kind!r}") - - # -- results ------------------------------------------------------------- - - def result(self, session: GameSession) -> MatchResult: - state: ScoponeState = session.state - if state.winner is None: - raise GameError("no result: the match is not finished") - teams = [ - [p.sub for p in state.players if p.team == 0], - [p.sub for p in state.players if p.team == 1], - ] - return MatchResult( - teams=teams, - winner_team=state.winner, - players=[ - PlayerResult( - user_sub=player.sub, - seat=player.seat, - won=player.team == state.winner, - team=TEAM_NAMES[player.team], - score=float(state.scores[player.team]), - details={"scope": player.scope}, - ) - for player in state.players - ], - summary={ - "team_a_score": state.scores[0], - "team_b_score": state.scores[1], - "winner_team": TEAM_NAMES[state.winner], - "target_score": state.target_score, - "hands_played": state.hand_number, - "hand_scores": state.hand_scores, - }, - ) diff --git a/server/packages/tavolo-scopone/src/tavolo/scopone/py.typed b/server/packages/tavolo-scopone/src/tavolo/scopone/py.typed deleted file mode 100644 index e69de29..0000000 diff --git a/server/packages/tavolo-scopone/src/tavolo/scopone/state.py b/server/packages/tavolo-scopone/src/tavolo/scopone/state.py deleted file mode 100644 index f1edb69..0000000 --- a/server/packages/tavolo-scopone/src/tavolo/scopone/state.py +++ /dev/null @@ -1,249 +0,0 @@ -"""In-memory representation of a scopone scientifico game. - -The whole mutable game lives in :class:`ScoponeState`, which is serialized -to and from plain JSON for storage inside the platform's session envelope -(see :mod:`tavolo.platform.store`). Keeping the representation JSON-native -means the store needs no custom codecs and the state is inspectable with -``redis-cli``. - -The state contains only game data: the platform owns the session envelope -(id, join code, seats, timestamps, stats persistence) — see -:class:`tavolo.platform.engine.GameSession`. - -Deck convention: a 40-card Italian deck. Suits are ``D`` (denari), -``C`` (coppe), ``S`` (spade) and ``B`` (bastoni); ranks are ``1``..``10``. -A card is rendered as ``RRSUIT`` (e.g. ``07D`` is the settebello). -""" -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional - -SUITS = ("D", "C", "S", "B") -RANKS = tuple(range(1, 11)) - -# 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. -TEAM_A = 0 -TEAM_B = 1 -TEAM_NAMES = ("A", "B") - -PHASE_LOBBY = "lobby" -PHASE_PLAYING = "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). -PHASE_HAND_END = "hand_end" -PHASE_FINISHED = "finished" - -DEFAULT_TARGET_SCORE = 11 - - -def team_of(seat: int) -> int: - return seat % 2 - - -@dataclass(frozen=True) -class Card: - rank: int - suit: str - - def __post_init__(self) -> None: - if self.suit not in SUITS: - raise ValueError(f"invalid suit: {self.suit!r}") - if self.rank not in RANKS: - raise ValueError(f"invalid rank: {self.rank!r}") - - @property - def code(self) -> str: - return f"{self.rank:02d}{self.suit}" - - @staticmethod - def parse(code: str) -> "Card": - code = str(code).upper() - if len(code) != 3 or not code[:2].isdigit(): - raise ValueError(f"invalid card code: {code!r}") - return Card(rank=int(code[:2]), suit=code[2]) - - def to_json(self) -> str: - return self.code - - @staticmethod - def from_json(value: Any) -> "Card": - return Card.parse(str(value)) - - -def parse_card(code: Any) -> Card: - """Parse a card code, raising :class:`ValueError` on malformed input.""" - try: - return Card.parse(str(code)) - except ValueError: - raise - - -@dataclass -class Move: - """Record of a single play, broadcast so every client can show who - played which card and what it captured.""" - - seat: int - name: str - card: str - captured: List[str] = field(default_factory=list) - scopa: bool = False - # The play was an asso pigliatutto capture (the whole table, or the - # ace itself when the table was empty). - pigliatutto: bool = False - - def to_json(self) -> Dict[str, Any]: - return { - "seat": self.seat, - "name": self.name, - "card": self.card, - "captured": list(self.captured), - "scopa": self.scopa, - "pigliatutto": self.pigliatutto, - } - - @staticmethod - def from_json(data: Dict[str, Any]) -> "Move": - return Move( - seat=int(data["seat"]), - name=str(data["name"]), - card=str(data["card"]), - captured=[str(c) for c in data.get("captured", [])], - scopa=bool(data.get("scopa", False)), - pigliatutto=bool(data.get("pigliatutto", False)), - ) - - -@dataclass -class PlayerState: - sub: str - name: str - seat: int - hand: List[Card] = field(default_factory=list) - captured: List[Card] = field(default_factory=list) - scope: int = 0 - - @property - def team(self) -> int: - return team_of(self.seat) - - def to_json(self) -> Dict[str, Any]: - return { - "sub": self.sub, - "name": self.name, - "seat": self.seat, - "hand": [c.to_json() for c in self.hand], - "captured": [c.to_json() for c in self.captured], - "scope": self.scope, - } - - @staticmethod - def from_json(data: Dict[str, Any]) -> "PlayerState": - return PlayerState( - sub=str(data["sub"]), - name=str(data["name"]), - seat=int(data["seat"]), - hand=[Card.from_json(c) for c in data.get("hand", [])], - captured=[Card.from_json(c) for c in data.get("captured", [])], - scope=int(data.get("scope", 0)), - ) - - -@dataclass -class ScoponeState: - target_score: int = DEFAULT_TARGET_SCORE - # Whether the napola rule is scored (denari run from the ace; a full - # suit wins the match instantly). Default on. - napola: bool = True - # 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. - asso_pigliatutto: bool = False - phase: str = PHASE_LOBBY - players: List[PlayerState] = field(default_factory=list) - table: List[Card] = field(default_factory=list) - dealer: int = 0 - turn: int = 0 - hand_number: int = 1 - scores: List[int] = field(default_factory=lambda: [0, 0]) - winner: Optional[int] = None - last_taker: Optional[int] = None - # Per-hand points awarded, for a compact audit trail in the API. - hand_scores: List[Dict[str, Any]] = field(default_factory=list) - # The most recent play in the current hand, for move announcements. - last_move: Optional[Move] = None - # While phase == "hand_end": seats that acknowledged the summary, and - # when the auto-continue timeout fires. - acked: List[int] = field(default_factory=list) - hand_end_deadline: Optional[str] = None - # Seconds the hand-end summary waits before dealing anyway. - hand_ack_timeout: int = 30 - # While phase == "playing": when the server plays a random legal card - # for the player on turn. - turn_deadline: Optional[str] = None - turn_timeout: int = 30 - - # -- serialization ---------------------------------------------------- - - def to_json(self) -> Dict[str, Any]: - return { - "target_score": self.target_score, - "napola": self.napola, - "asso_pigliatutto": self.asso_pigliatutto, - "phase": self.phase, - "players": [p.to_json() for p in self.players], - "table": [c.to_json() for c in self.table], - "dealer": self.dealer, - "turn": self.turn, - "hand_number": self.hand_number, - "scores": list(self.scores), - "winner": self.winner, - "last_taker": self.last_taker, - "hand_scores": list(self.hand_scores), - "last_move": self.last_move.to_json() if self.last_move else None, - "acked": list(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, - } - - @staticmethod - def from_json(data: Dict[str, Any]) -> "ScoponeState": - return ScoponeState( - target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)), - napola=bool(data.get("napola", True)), - asso_pigliatutto=bool(data.get("asso_pigliatutto", False)), - phase=str(data.get("phase", PHASE_LOBBY)), - players=[PlayerState.from_json(p) for p in data.get("players", [])], - table=[Card.from_json(c) for c in data.get("table", [])], - dealer=int(data.get("dealer", 0)), - turn=int(data.get("turn", 0)), - hand_number=int(data.get("hand_number", 1)), - scores=[int(x) for x in data.get("scores", [0, 0])], - winner=data.get("winner"), - last_taker=data.get("last_taker"), - hand_scores=list(data.get("hand_scores", [])), - last_move=Move.from_json(data["last_move"]) if data.get("last_move") else None, - acked=[int(s) for s in data.get("acked", [])], - hand_end_deadline=data.get("hand_end_deadline"), - hand_ack_timeout=int(data.get("hand_ack_timeout", 30)), - turn_deadline=data.get("turn_deadline"), - turn_timeout=int(data.get("turn_timeout", 30)), - ) - - # -- helpers ---------------------------------------------------------- - - def player_for(self, sub: str) -> Optional[PlayerState]: - for player in self.players: - if player.sub == sub: - return player - return None - - def seated(self, sub: str) -> bool: - return self.player_for(sub) is not None diff --git a/server/packages/tavolo-scopone/tests/test_engine.py b/server/packages/tavolo-scopone/tests/test_engine.py deleted file mode 100644 index 53c159f..0000000 --- a/server/packages/tavolo-scopone/tests/test_engine.py +++ /dev/null @@ -1,750 +0,0 @@ -"""Rule engine tests: captures, scope, scoring and full-match simulation.""" -from __future__ import annotations - -import random -import unittest - -from tavolo.scopone import engine -from tavolo.scopone.errors import ( - CardNotInHand, - GameFinished, - GameNotStarted, - IllegalMove, - NotYourTurn, -) -from tavolo.scopone.state import ( - PHASE_FINISHED, - PHASE_PLAYING, - Card, - ScoponeState, - PlayerState, -) - - -def card(code: str) -> Card: - return Card.parse(code) - - -def make_state( - hands, - table, - turn: int = 0, - *, - captured=None, - scope=None, - target: int = 11, - last_taker=None, -) -> ScoponeState: - """Build a controlled game state directly (bypassing the deal).""" - state = ScoponeState( - target_score=target, - phase=PHASE_PLAYING, - turn=turn, - last_taker=last_taker, - ) - for seat, hand in enumerate(hands): - state.players.append( - PlayerState(sub=f"p{seat}", name=f"p{seat}", seat=seat, - hand=[card(c) for c in hand]) - ) - if captured is not None: - for player, codes in zip(state.players, captured): - player.captured = [card(c) for c in codes] - if scope is not None: - for player, value in zip(state.players, scope): - player.scope = value - state.table = [card(c) for c in table] - return state - - -class DeckTest(unittest.TestCase): - def test_full_deck_has_40_unique_cards(self) -> None: - deck = engine.full_deck() - self.assertEqual(40, len(deck)) - self.assertEqual(40, len({c.code for c in deck})) - self.assertEqual(4, len({c.suit for c in deck})) - self.assertEqual(4, sum(1 for c in deck if c.rank == 7)) - - def test_shuffled_deck_is_permutation(self) -> None: - deck = engine.shuffled_deck() - self.assertEqual( - sorted(c.code for c in engine.full_deck()), - sorted(c.code for c in deck), - ) - - -class CaptureTest(unittest.TestCase): - def test_equal_card_is_mandatory(self) -> None: - table = [card("05C"), card("02D"), card("03S")] - options = engine.legal_captures(table, card("05D")) - self.assertEqual([["05C"]], [[c.code for c in o] for o in options]) - - def test_sum_combination(self) -> None: - table = [card("01C"), card("03C"), card("02S")] - options = engine.legal_captures(table, card("04D")) - self.assertEqual([["01C", "03C"]], [[c.code for c in o] for o in options]) - - def test_multiple_equal_cards_each_a_separate_option(self) -> None: - table = [card("05C"), card("05S")] - options = engine.legal_captures(table, card("05D")) - self.assertEqual( - [["05C"], ["05S"]], sorted([[c.code for c in o] for o in options]) - ) - - def test_no_capture(self) -> None: - table = [card("09C"), card("08S")] - self.assertEqual([], engine.legal_captures(table, card("02D"))) - - def test_play_without_capture_places_card_on_table(self) -> None: - state = make_state([["02D", "03D"], ["01C"], ["01S"], ["01B"]], - table=["09C"]) - engine.play(state, "p0", "02D") - self.assertIn("02D", [c.code for c in state.table]) - self.assertNotIn("02D", [c.code for c in state.players[0].hand]) - self.assertEqual(1, state.turn) - - def test_play_capture_and_scopa(self) -> None: - state = make_state([["02D", "09C"], ["01C"], ["01S"], ["01B"]], - table=["02C"]) - engine.play(state, "p0", "02D", ["02C"]) - self.assertEqual(1, state.players[0].scope) - self.assertEqual([], state.table) - self.assertEqual( - ["02C", "02D"], [c.code for c in state.players[0].captured] - ) - # The move is recorded for the "who played what" announcement. - assert state.last_move is not None - self.assertEqual(0, state.last_move.seat) - self.assertEqual("p0", state.last_move.name) - self.assertEqual("02D", state.last_move.card) - self.assertEqual(["02C"], state.last_move.captured) - self.assertTrue(state.last_move.scopa) - - def test_play_without_capture_records_move(self) -> None: - state = make_state([["02D", "03D"], ["01C"], ["01S"], ["01B"]], - table=["09C"]) - engine.play(state, "p0", "02D") - assert state.last_move is not None - self.assertEqual("02D", state.last_move.card) - self.assertEqual([], state.last_move.captured) - self.assertFalse(state.last_move.scopa) - - def test_illegal_combination_when_equal_card_present(self) -> None: - state = make_state([["05D"], ["01C"], ["01S"], ["01B"]], - table=["05C", "02D", "03S"]) - with self.assertRaises(IllegalMove): - engine.play(state, "p0", "05D", ["02D", "03S"]) - - def test_illegal_capture_rejected(self) -> None: - state = make_state([["04D"], ["01C"], ["01S"], ["01B"]], - table=["02C", "03S"]) - with self.assertRaises(IllegalMove): - engine.play(state, "p0", "04D", ["02C"]) - - def test_no_capture_requested_when_capture_possible(self) -> None: - state = make_state([["05D"], ["01C"], ["01S"], ["01B"]], - table=["05C"]) - with self.assertRaises(IllegalMove): - engine.play(state, "p0", "05D") - - def test_not_your_turn(self) -> None: - state = make_state([["02D"], ["03D"], ["04D"], ["05D"]], - table=[], turn=1) - with self.assertRaises(NotYourTurn): - engine.play(state, "p0", "02D") - - def test_card_not_in_hand(self) -> None: - state = make_state([["02D"], ["03D"], ["04D"], ["05D"]], table=[]) - with self.assertRaises(CardNotInHand): - engine.play(state, "p0", "07D") - - def test_finished_game_rejects_moves(self) -> None: - state = make_state([["02D"], ["03D"], ["04D"], ["05D"]], table=[]) - state.phase = PHASE_FINISHED - with self.assertRaises(GameFinished): - engine.play(state, "p0", "02D") - - -class LastPlayTest(unittest.TestCase): - def test_no_scopa_on_last_play_of_hand(self) -> None: - # p0 plays the last card of the hand (everyone else is already - # empty): the capture empties the table but must NOT count as a - # scopa. Team A still reaches the target of 2 with carte + denara. - state = make_state([["02D"], [], [], []], - table=["02C"], target=2) - engine.play(state, "p0", "02D", ["02C"]) - self.assertEqual(PHASE_FINISHED, state.phase) - self.assertEqual(0, state.winner) - self.assertEqual(0, state.hand_scores[-1]["scope"]["A"]) - - def test_table_swept_to_last_taker(self) -> None: - # target 2 so the game ends on this hand and the capture piles are - # not reset by the next deal. - state = make_state([["02D"], [], [], []], - table=["05C", "04D"], last_taker=1, target=2) - engine.play(state, "p0", "02D") - captured = {c.code for c in state.players[1].captured} - self.assertEqual({"05C", "04D", "02D"}, captured) - self.assertEqual(PHASE_FINISHED, state.phase) - self.assertEqual(1, state.winner) - - -class ScoringTest(unittest.TestCase): - def test_primiera_values_and_all_suits_requirement(self) -> None: - self.assertEqual(70, engine.primiera_score( - [card(c) for c in ["07D", "06C", "01S", "05B"]])) - self.assertEqual(0, engine.primiera_score( - [card(c) for c in ["07D", "06C", "01S"]])) - self.assertEqual(40, engine.primiera_score( - [card(c) for c in ["08D", "09C", "10S", "10B"]])) - - def test_hand_points_carte_denara_settebello_primiera_scope(self) -> None: - state = make_state( - [[], [], [], []], - table=[], - captured=[ - ["07D", "06C", "01S", "05B"], # seat 0, team A - ["03D", "04C", "07S", "02B"], # seat 1, team B - ["02D"], # seat 2, team A - ["10D", "10C", "10S", "10B"], # seat 3, team B - ], - scope=[1, 0, 0, 2], - ) - points, details = engine.hand_points(state) - self.assertEqual([3, 3], points) - self.assertEqual({"A": 5, "B": 8}, details["cards"]) - self.assertEqual({"A": 2, "B": 2}, details["denara"]) - self.assertEqual({"A": True, "B": False}, details["settebello"]) - self.assertEqual({"A": 70, "B": 60}, details["primiera"]) - self.assertEqual({"A": 1, "B": 2}, details["scope"]) - - def test_ties_award_nothing(self) -> None: - state = make_state( - [[], [], [], []], - table=[], - captured=[ - ["06C", "01S", "05B", "02D"], - ["06S", "01B", "05D", "02C"], - [], - [], - ], - scope=[0, 0, 0, 0], - ) - points, _ = engine.hand_points(state) - # Equal cards, equal denara, equal primiera and no settebello: - # everything ties, so no points at all. - self.assertEqual([0, 0], points) - - -class NapolaTest(unittest.TestCase): - def test_napola_score_runs(self) -> None: - self.assertEqual(0, engine.napola_score( - [card(c) for c in ["02D", "03D", "04D"]])) # no ace - self.assertEqual(0, engine.napola_score( - [card(c) for c in ["01D", "02D"]])) # too short - self.assertEqual(3, engine.napola_score( - [card(c) for c in ["03D", "01D", "02D"]])) # order-independent - self.assertEqual(4, engine.napola_score( - [card(c) for c in ["01D", "02D", "03D", "04D", "07C"]])) - self.assertEqual(3, engine.napola_score( - [card(c) for c in ["01D", "02D", "03D", "05D"]])) # broken run - self.assertEqual(10, engine.napola_score( - [card(f"{rank:02d}D") for rank in range(1, 11)])) - - def test_hand_points_napola(self) -> None: - state = make_state( - [[], [], [], []], - table=[], - captured=[ - ["01D", "02D", "03D", "04C"], # seat 0, team A - ["05D", "06D", "07D", "08D"], # seat 1, team B - ["09D", "10D", "01C", "02C"], # seat 2, team A - ["03C", "05C", "06C", "07C"], # seat 3, team B - ], - ) - points, details = engine.hand_points(state) - # Team A has the ace-led run 01D-03D (3 points); team B's denari - # start at the 5, so no napola. Carte tie (8 each), denara to A - # (5 vs 4), settebello to B, primiere tied at 0 (missing suits). - self.assertEqual({"A": 3, "B": 0}, details["napola"]) - self.assertEqual("A", details["award"]["napola"]) - self.assertEqual([4, 1], points) - - def test_napola_disabled(self) -> None: - state = make_state( - [[], [], [], []], - table=[], - captured=[ - ["01D", "02D", "03D", "04C"], - ["05D", "06D", "07D", "08D"], - ["09D", "10D", "01C", "02C"], - ["03C", "05C", "06C", "07C"], - ], - ) - state.napola = False - points, details = engine.hand_points(state) - self.assertNotIn("napola", details) - self.assertEqual([1, 1], points) - - def test_full_denari_sweep_wins_match_instantly(self) -> None: - # Team A already captured the whole denari suit; the last play of - # the hand cannot capture. Team B leads 50-0, yet the napola ends - # the match in team A's favour, well below the target of 100. - state = make_state( - [["02C"], [], [], []], - table=[], - target=100, - captured=[ - [f"{rank:02d}D" for rank in range(1, 11)], - [], - [], - [], - ], - ) - state.scores = [0, 50] - engine.play(state, "p0", "02C") - self.assertEqual(PHASE_FINISHED, state.phase) - self.assertEqual(0, state.winner) - self.assertLess(state.scores[0], 100) - self.assertEqual(10, state.hand_scores[-1]["napola"]["A"]) - - def test_napola_serialization_roundtrip(self) -> None: - state = make_state([["02D"], [], [], []], table=[]) - self.assertTrue(state.napola) - state.napola = False - self.assertFalse(ScoponeState.from_json(state.to_json()).napola) - # States serialized before the option existed default to enabled. - data = state.to_json() - del data["napola"] - self.assertTrue(ScoponeState.from_json(data).napola) - - def test_create_game_napola_default_and_override(self) -> None: - self.assertTrue(engine.create_game("p0", "p0").napola) - self.assertFalse( - engine.create_game("p0", "p0", napola=False).napola - ) - - -class AssoPigliatuttoTest(unittest.TestCase): - def test_ace_sweeps_the_whole_table_without_scopa(self) -> None: - # The other players still hold cards, so emptying the table would - # normally score a scopa: the sweep must suppress it. - state = make_state( - [["01D", "09C"], ["04D"], ["05D"], ["06D"]], - table=["05C", "02D", "03S"], - ) - state.asso_pigliatutto = True - engine.play(state, "p0", "01D", ["05C", "02D", "03S"]) - self.assertEqual([], state.table) - self.assertEqual( - {"05C", "02D", "03S", "01D"}, - {c.code for c in state.players[0].captured}, - ) - self.assertEqual(0, state.players[0].scope) - assert state.last_move is not None - self.assertTrue(state.last_move.pigliatutto) - self.assertFalse(state.last_move.scopa) - - def test_legal_captures_offer_only_the_whole_table(self) -> None: - table = [card("05C"), card("02D"), card("03S")] - options = engine.legal_captures( - table, card("01D"), asso_pigliatutto=True - ) - self.assertEqual( - [["05C", "02D", "03S"]], [[c.code for c in o] for o in options] - ) - # A bare table leaves only the self-capture option; without the - # rule the ace stays on the table. - self.assertEqual( - [[]], engine.legal_captures([], card("01D"), asso_pigliatutto=True) - ) - self.assertEqual( - [], engine.legal_captures([], card("01D"), asso_pigliatutto=False) - ) - # Non-ace cards keep the normal rules. - self.assertEqual( - [], - engine.legal_captures(table, card("04B"), asso_pigliatutto=True), - ) - - def test_equal_rank_ace_blocks_the_sweep(self) -> None: - table = ["01C", "02D"] - state = make_state([["01D"], ["04D"], ["05D"], ["06D"]], table=table) - state.asso_pigliatutto = True - options = engine.legal_captures( - state.table, card("01D"), asso_pigliatutto=True - ) - self.assertEqual([["01C"]], [[c.code for c in o] for o in options]) - with self.assertRaises(IllegalMove): - engine.play(state, "p0", "01D", ["01C", "02D"]) - # A rejected play consumes the card, so retry on a fresh state. - state = make_state([["01D"], ["04D"], ["05D"], ["06D"]], table=table) - state.asso_pigliatutto = True - engine.play(state, "p0", "01D", ["01C"]) - self.assertEqual(["02D"], [c.code for c in state.table]) - assert state.last_move is not None - self.assertFalse(state.last_move.pigliatutto) - - def test_disabled_by_default(self) -> None: - state = make_state( - [["01D", "09C"], ["04D"], ["05D"], ["06D"]], - table=["05C", "02D"], - ) - engine.play(state, "p0", "01D") - # Without the rule an ace captures nothing here and stays on the - # table. - self.assertEqual({"05C", "02D", "01D"}, {c.code for c in state.table}) - assert state.last_move is not None - self.assertFalse(state.last_move.pigliatutto) - # ... and on a bare table it stays there too. - state = make_state( - [["01D", "09C"], ["04D"], ["05D"], ["06D"]], table=[] - ) - engine.play(state, "p0", "01D") - self.assertEqual(["01D"], [c.code for c in state.table]) - self.assertEqual([], state.players[0].captured) - self.assertEqual( - [], - engine.legal_captures( - [card("05C")], card("01D"), asso_pigliatutto=False - ), - ) - - def test_ace_on_empty_table_captures_itself(self) -> None: - # Other players still hold cards, so the bare table would trigger a - # (nonsensical) scopa without the pigliatutto suppression. The - # self-capture makes p0 the last taker, replacing seat 1. - state = make_state( - [["01D", "09C"], ["04D"], ["05D"], ["06D"]], - table=[], - last_taker=1, - ) - state.asso_pigliatutto = True - engine.play(state, "p0", "01D") - self.assertEqual([], state.table) - self.assertEqual(["01D"], [c.code for c in state.players[0].captured]) - self.assertEqual(0, state.players[0].scope) - self.assertEqual(0, state.last_taker) - assert state.last_move is not None - self.assertTrue(state.last_move.pigliatutto) - self.assertEqual([], state.last_move.captured) - self.assertFalse(state.last_move.scopa) - - def test_non_ace_on_empty_table_stays(self) -> None: - state = make_state( - [["02D", "09C"], ["04D"], ["05D"], ["06D"]], table=[] - ) - state.asso_pigliatutto = True - engine.play(state, "p0", "02D") - self.assertEqual(["02D"], [c.code for c in state.table]) - self.assertEqual([], state.players[0].captured) - assert state.last_move is not None - self.assertFalse(state.last_move.pigliatutto) - - def test_move_pigliatutto_serialization(self) -> None: - move = engine.Move( - seat=0, name="p0", card="01D", captured=["05C"], pigliatutto=True - ) - restored = engine.Move.from_json(move.to_json()) - self.assertTrue(restored.pigliatutto) - legacy = move.to_json() - del legacy["pigliatutto"] - self.assertFalse(engine.Move.from_json(legacy).pigliatutto) - - def test_state_serialization_roundtrip(self) -> None: - state = make_state([["02D"], [], [], []], table=[]) - self.assertFalse(state.asso_pigliatutto) - state.asso_pigliatutto = True - self.assertTrue( - ScoponeState.from_json(state.to_json()).asso_pigliatutto - ) - # States serialized before the option existed default to disabled. - data = state.to_json() - del data["asso_pigliatutto"] - self.assertFalse(ScoponeState.from_json(data).asso_pigliatutto) - - def test_create_game_default_and_override(self) -> None: - self.assertFalse(engine.create_game("p0", "p0").asso_pigliatutto) - self.assertTrue( - engine.create_game("p0", "p0", asso_pigliatutto=True).asso_pigliatutto - ) - - def test_state_for_player_exposes_rule_and_legal_moves(self) -> None: - state = make_state( - [["01D"], ["04D"], ["05D"], ["06D"]], - table=["05C", "02D", "03S"], - ) - state.asso_pigliatutto = True - view = engine.state_for_player(state, "p0") - self.assertTrue(view["asso_pigliatutto"]) - self.assertEqual( - {"01D": [["05C", "02D", "03S"]]}, view["legal_moves"] - ) - # On a bare table the ace advertises the self-capture. - state = make_state( - [["01D"], ["04D"], ["05D"], ["06D"]], table=[] - ) - state.asso_pigliatutto = True - view = engine.state_for_player(state, "p0") - self.assertEqual({"01D": [[]]}, view["legal_moves"]) - - def test_auto_play_sweeps_with_the_rule(self) -> None: - state = make_state( - [["01D"], ["04D"], ["05D"], ["06D"]], - table=["05C", "02D"], - ) - state.asso_pigliatutto = True - engine.auto_play(state) - assert state.last_move is not None - self.assertTrue(state.last_move.pigliatutto) - self.assertEqual(["05C", "02D"], state.last_move.captured) - - def test_auto_play_self_captures_on_bare_table(self) -> None: - state = make_state( - [["01D"], ["04D"], ["05D"], ["06D"]], table=[] - ) - state.asso_pigliatutto = True - engine.auto_play(state) - assert state.last_move is not None - self.assertTrue(state.last_move.pigliatutto) - self.assertEqual(["01D"], [c.code for c in state.players[0].captured]) - self.assertEqual([], state.table) - - def test_full_random_match_with_rule_keeps_all_cards(self) -> None: - state = engine.create_game( - "p0", "p0", target_score=11, asso_pigliatutto=True - ) - for i in range(1, 4): - engine.join_game(state, f"p{i}", f"p{i}") - - moves = 0 - while state.phase != PHASE_FINISHED and moves < 200000: - if state.phase == "hand_end": - for p in state.players: - engine.acknowledge_hand(state, p.sub) - continue - player = next(p for p in state.players if p.seat == state.turn) - played = player.hand[0] - options = engine.legal_captures( - state.table, played, asso_pigliatutto=state.asso_pigliatutto - ) - capture = [c.code for c in options[0]] if options else None - engine.play(state, player.sub, played.code, capture) - moves += 1 - - self.assertEqual(PHASE_FINISHED, state.phase) - self.assertIn(state.winner, (0, 1)) - # Self-captures included, all 40 cards end up in capture piles. - self.assertEqual([], state.table) - self.assertTrue(all(not p.hand for p in state.players)) - self.assertEqual(40, sum(len(p.captured) for p in state.players)) - - -class MatchFlowTest(unittest.TestCase): - def test_join_starts_when_full(self) -> None: - state = engine.create_game("p0", "p0", target_score=11) - self.assertEqual(1, len(state.players)) - engine.join_game(state, "p1", "p1") - engine.join_game(state, "p2", "p2") - self.assertEqual("lobby", state.phase) - engine.join_game(state, "p3", "p3") - self.assertEqual(PHASE_PLAYING, state.phase) - self.assertEqual(4, len(state.players)) - for player in state.players: - self.assertEqual(10, len(player.hand)) - self.assertEqual([], state.table) - self.assertEqual(1, state.turn) # dealer is seat 0 - - def test_match_ends_when_target_reached(self) -> None: - state = make_state([["02D"], [], [], []], - table=["02C"], target=1) - engine.play(state, "p0", "02D", ["02C"]) - self.assertEqual(PHASE_FINISHED, state.phase) - self.assertEqual(0, state.winner) - self.assertGreaterEqual(state.scores[0], 1) - - def test_state_for_player_hides_other_hands(self) -> None: - state = make_state([["02D", "03C"], ["04D"], ["05D"], ["06D"]], - table=["07C"]) - view = engine.state_for_player(state, "p0") - players = {p["seat"]: p for p in view["players"]} - self.assertEqual(["02D", "03C"], players[0]["hand"]) - self.assertNotIn("hand", players[1]) - self.assertEqual(1, players[1]["cards_left"]) - self.assertEqual(["07C"], view["table"]) - self.assertTrue(view.get("your_turn")) - - def test_legal_moves_only_for_player_on_turn(self) -> None: - state = make_state([["02D", "03C"], ["04D"], ["05D"], ["06D"]], - table=["07C"]) - view = engine.state_for_player(state, "p0") - legal = view["legal_moves"] - # 02D can capture nothing; 03C has no combination either (only 07C - # on the table). - self.assertEqual({}, legal) - - state = make_state([["09D"], ["04D"], ["05D"], ["06D"]], - table=["07C", "02S"]) - view = engine.state_for_player(state, "p0") - self.assertEqual({"09D": [["07C", "02S"]]}, view["legal_moves"]) - - # A player who is not on turn gets no legal_moves key. - other = engine.state_for_player(state, "p1") - self.assertNotIn("legal_moves", other) - self.assertNotIn("your_turn", other) - - def test_full_random_match_reaches_completion(self) -> None: - state = engine.create_game("p0", "p0", target_score=11) - for i in range(1, 4): - engine.join_game(state, f"p{i}", f"p{i}") - - moves = 0 - while state.phase != PHASE_FINISHED and moves < 200000: - if state.phase == "hand_end": - for p in state.players: - engine.acknowledge_hand(state, p.sub) - continue - player = next(p for p in state.players if p.seat == state.turn) - played = player.hand[0] - options = engine.legal_captures(state.table, played) - capture = [c.code for c in options[0]] if options else None - engine.play(state, player.sub, played.code, capture) - moves += 1 - - self.assertEqual(PHASE_FINISHED, state.phase) - self.assertIn(state.winner, (0, 1)) - # At the end all 40 cards are captured and no hand is left. - self.assertEqual([], state.table) - self.assertTrue(all(not p.hand for p in state.players)) - self.assertEqual(40, sum(len(p.captured) for p in state.players)) - - -class HandEndAckTest(unittest.TestCase): - def _hand_end_state(self) -> ScoponeState: - """Drive a game into the hand_end phase with a one-card hand.""" - state = make_state([["02D"], [], [], []], - table=["02C"], target=11) - engine.play(state, "p0", "02D", ["02C"]) - return state - - def test_end_of_hand_pauses_for_acknowledgement(self) -> None: - state = self._hand_end_state() - self.assertEqual("hand_end", state.phase) - # Nobody has acknowledged yet, and no new hand was dealt. - self.assertEqual([], state.acked) - self.assertEqual(1, state.hand_number) - self.assertTrue(state.hand_end_deadline) - # Capture piles stay visible during the summary. - self.assertEqual(["02C", "02D"], - [c.code for c in state.players[0].captured]) - # The summary carries the award map. - summary = state.hand_scores[-1] - self.assertEqual(1, summary["hand"]) - self.assertIn("award", summary) - - def test_play_during_hand_end_is_rejected(self) -> None: - state = self._hand_end_state() - with self.assertRaises(IllegalMove): - engine.play(state, "p0", "02D") - - def test_ack_all_four_deals_next_hand(self) -> None: - state = self._hand_end_state() - dealer_before = state.dealer - for i, sub in enumerate(("p0", "p1", "p2")): - engine.acknowledge_hand(state, sub) - self.assertEqual(list(range(i + 1)), state.acked) - self.assertEqual("hand_end", state.phase) - engine.acknowledge_hand(state, "p3") - self.assertEqual("playing", state.phase) - self.assertEqual(2, state.hand_number) - self.assertEqual((dealer_before + 1) % 4, state.dealer) - self.assertEqual([], state.acked) - self.assertIsNone(state.hand_end_deadline) - self.assertIsNone(state.last_move) - for player in state.players: - self.assertEqual(10, len(player.hand)) - self.assertEqual([], player.captured) - self.assertEqual((dealer_before + 2) % 4, state.turn) - - def test_double_ack_is_idempotent(self) -> None: - state = self._hand_end_state() - engine.acknowledge_hand(state, "p0") - engine.acknowledge_hand(state, "p0") - self.assertEqual([0], state.acked) - - def test_ack_outside_hand_end_is_rejected(self) -> None: - state = make_state([["02D"], ["03D"], ["04D"], ["05D"]], table=[]) - with self.assertRaises(IllegalMove): - engine.acknowledge_hand(state, "p0") - - def test_ack_by_non_player_is_rejected(self) -> None: - state = self._hand_end_state() - with self.assertRaises(NotYourTurn): - engine.acknowledge_hand(state, "mallory") - - def test_state_exposes_ack_progress(self) -> None: - state = self._hand_end_state() - engine.acknowledge_hand(state, "p1") - view = engine.state_for_player(state, "p0") - self.assertEqual([1], view["acknowledged"]) - self.assertTrue(view["hand_end_deadline"]) - self.assertIsNotNone(view["last_hand"]) - self.assertIn("award", view["last_hand"]) - - -class AutoPlayTest(unittest.TestCase): - def test_auto_play_plays_a_card_and_advances_turn(self) -> None: - state = make_state([["02D", "03C"], ["04D"], ["05D"], ["06D"]], - table=["09B"]) - state.turn_deadline = "2000-01-01T00:00:00+00:00" - engine.auto_play(state, random.Random(7)) - self.assertEqual(1, state.turn) - self.assertEqual(1, len(state.players[0].hand)) - # The played card could not capture the nine, so the table grew. - self.assertEqual(2, len(state.table)) - self.assertIsNotNone(state.last_move) - assert state.last_move is not None - self.assertEqual(0, state.last_move.seat) - self.assertNotEqual("2000-01-01T00:00:00+00:00", state.turn_deadline) - - def test_auto_play_takes_a_mandatory_capture(self) -> None: - # p0 holds only the five of denari, which must capture the equal - # five of coppe instead of the unrelated nine on the table. - state = make_state([["05D"], ["04D"], ["05D"], ["06D"]], - table=["05C", "09B"]) - engine.auto_play(state) - self.assertIsNotNone(state.last_move) - assert state.last_move is not None - self.assertEqual("05D", state.last_move.card) - self.assertEqual(["05C"], state.last_move.captured) - self.assertEqual(["09B"], [c.code for c in state.table]) - self.assertEqual(["05C", "05D"], - [c.code for c in state.players[0].captured]) - - def test_auto_play_can_end_the_hand_and_clears_deadline(self) -> None: - state = make_state([["02D"], [], [], []], table=["02C"]) - state.turn_deadline = "2000-01-01T00:00:00+00:00" - engine.auto_play(state) - self.assertEqual("hand_end", state.phase) - self.assertIsNone(state.turn_deadline) - self.assertTrue(state.hand_end_deadline) - - def test_auto_play_requires_playing_phase(self) -> None: - state = make_state([["02D"], ["04D"], ["05D"], ["06D"]], table=[]) - state.phase = "hand_end" - with self.assertRaises(GameNotStarted): - engine.auto_play(state) - - def test_create_game_copies_turn_timeout_and_arms_deadline(self) -> None: - state = engine.create_game("p0", "p0", turn_timeout=7) - self.assertEqual(7, state.turn_timeout) - for i in range(1, 4): - engine.join_game(state, f"p{i}", f"p{i}") - self.assertEqual(PHASE_PLAYING, state.phase) - self.assertTrue(state.turn_deadline) - view = engine.state_for_player(state, "p0") - self.assertTrue(view["turn_deadline"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/server/packages/tavolo-scopone/tests/test_plugin.py b/server/packages/tavolo-scopone/tests/test_plugin.py deleted file mode 100644 index 1016d2f..0000000 --- a/server/packages/tavolo-scopone/tests/test_plugin.py +++ /dev/null @@ -1,299 +0,0 @@ -"""ScoponeEngine adapter tests: the platform contract over the pure rules.""" -from __future__ import annotations - -import unittest -from datetime import datetime, timedelta, timezone - -from tavolo.platform import GameSession, Seat -from tavolo.platform.errors import GameError, IllegalMove -from tavolo.scopone import ScoponeEngine -from tavolo.scopone.state import PHASE_FINISHED, PHASE_HAND_END, PHASE_PLAYING, ScoponeState - - -def _session(engine: ScoponeEngine, **options) -> GameSession: - session = GameSession( - id="s1", - game_type=engine.id, - join_code="CODE01", - creator_sub="alice", - players=[Seat(user_sub="alice", display_name="Alice")], - ) - engine.create(session, options) - return session - - -def _started(engine: ScoponeEngine, **options) -> GameSession: - session = _session(engine, **options) - for name in ("bob", "carol", "dave"): - engine.join(session, name, name.capitalize()) - return session - - -class CreateTest(unittest.TestCase): - def test_create_seats_creator_on_team_a(self) -> None: - engine = ScoponeEngine() - session = _session(engine) - self.assertEqual("A", session.players[0].team) - self.assertIsInstance(session.state, ScoponeState) - self.assertEqual(11, session.state.target_score) - self.assertTrue(session.state.napola) - - def test_create_options(self) -> None: - engine = ScoponeEngine() - session = _session(engine, target_score=16, napola=False) - self.assertEqual(16, session.state.target_score) - self.assertFalse(session.state.napola) - self.assertFalse(session.state.asso_pigliatutto) - - def test_create_asso_pigliatutto_option(self) -> None: - engine = ScoponeEngine() - session = _session(engine, asso_pigliatutto=True) - self.assertTrue(session.state.asso_pigliatutto) - - def test_create_rejects_bad_options(self) -> None: - engine = ScoponeEngine() - with self.assertRaises(IllegalMove): - _session(engine, target_score=0) - with self.assertRaises(IllegalMove): - _session(engine, target_score="eleven") - with self.assertRaises(IllegalMove): - _session(engine, napola="yes") - with self.assertRaises(IllegalMove): - _session(engine, asso_pigliatutto="yes") - - def test_timeouts_come_from_the_engine(self) -> None: - engine = ScoponeEngine(turn_timeout_seconds=7, hand_ack_timeout_seconds=9) - session = _session(engine) - self.assertEqual(7, session.state.turn_timeout) - self.assertEqual(9, session.state.hand_ack_timeout) - - -class JoinTest(unittest.TestCase): - def test_join_assigns_teams_and_starts(self) -> None: - engine = ScoponeEngine() - session = _session(engine) - self.assertTrue(engine.in_lobby(session)) - for name, team in (("bob", "B"), ("carol", "A"), ("dave", "B")): - engine.join(session, name, name.capitalize()) - self.assertEqual(team, session.players[-1].team) - self.assertFalse(engine.in_lobby(session)) - self.assertEqual(PHASE_PLAYING, session.state.phase) - - def test_join_errors(self) -> None: - from tavolo.platform.errors import AlreadyJoined, GameNotStarted - - engine = ScoponeEngine() - session = _session(engine) - with self.assertRaises(AlreadyJoined): - engine.join(session, "alice", "Alice") - for name in ("bob", "carol", "dave"): - engine.join(session, name, name.capitalize()) - # The lobby filled up and the match started: late joins and even - # re-joins are rejected as "already started". - with self.assertRaises(GameNotStarted): - engine.join(session, "erin", "Erin") - with self.assertRaises(GameNotStarted): - engine.join(session, "alice", "Alice") - - -class ActionTest(unittest.TestCase): - def test_unknown_action_rejected(self) -> None: - engine = ScoponeEngine() - session = _started(engine) - with self.assertRaises(IllegalMove): - engine.handle_action(session, "alice", "dance", {}) - - def test_play_validates_payload(self) -> None: - engine = ScoponeEngine() - session = _started(engine) - with self.assertRaises(IllegalMove): - engine.handle_action(session, "bob", "play", {"card": 42}) - with self.assertRaises(IllegalMove): - engine.handle_action(session, "bob", "play", {}) - with self.assertRaises(IllegalMove): - engine.handle_action(session, "bob", "play", {"card": "01D", "capture": "02C"}) - - def test_invalid_card_code_is_illegal_move(self) -> None: - engine = ScoponeEngine() - session = _started(engine) - with self.assertRaises(IllegalMove): - engine.handle_action(session, "bob", "play", {"card": "nope"}) - - def test_finished_match_rejects_actions(self) -> None: - from tavolo.platform.errors import GameFinished - - engine = ScoponeEngine() - session = _started(engine, target_score=1) - # Drive to completion: keep playing legal moves until finished. - from tavolo.scopone import engine as rules - - moves = 0 - while not engine.is_finished(session) and moves < 200000: - state = session.state - if state.phase == "hand_end": - for p in state.players: - engine.handle_action(session, p.sub, "ack", {}) - continue - player = next(p for p in state.players if p.seat == state.turn) - played = player.hand[0] - options = rules.legal_captures(state.table, played) - payload = {"card": played.code} - if options: - payload["capture"] = [c.code for c in options[0]] - engine.handle_action(session, player.sub, "play", payload) - moves += 1 - self.assertTrue(engine.is_finished(session)) - with self.assertRaises(GameFinished): - engine.handle_action(session, "alice", "play", {"card": "01D"}) - - -class ViewTest(unittest.TestCase): - def test_view_for_hides_other_hands(self) -> None: - engine = ScoponeEngine() - session = _started(engine) - view = engine.view_for(session, "alice") - players = {p["seat"]: p for p in view["players"]} - self.assertIn("hand", players[0]) - self.assertNotIn("hand", players[1]) - # The envelope is the platform's job, not the view's. - self.assertNotIn("id", view) - self.assertNotIn("join_code", view) - self.assertNotIn("game_type", view) - - def test_lobby_view(self) -> None: - engine = ScoponeEngine() - session = _session(engine, target_score=16) - lobby = engine.lobby_view(session) - self.assertEqual("lobby", lobby["phase"]) - self.assertEqual(16, lobby["target_score"]) - self.assertTrue(lobby["napola"]) - self.assertFalse(lobby["asso_pigliatutto"]) - - -class SerializationTest(unittest.TestCase): - def test_state_roundtrip(self) -> None: - engine = ScoponeEngine() - session = _started(engine) - restored = engine.state_from_json(engine.state_to_json(session.state)) - self.assertIsInstance(restored, ScoponeState) - self.assertEqual(session.state.phase, restored.phase) - self.assertEqual(session.state.turn, restored.turn) - self.assertEqual( - [p.sub for p in session.state.players], - [p.sub for p in restored.players], - ) - - -class DeadlineTest(unittest.TestCase): - def test_no_deadline_in_lobby(self) -> None: - engine = ScoponeEngine() - self.assertIsNone(engine.next_deadline(_session(engine))) - - def test_turn_deadline_and_revalidation(self) -> None: - engine = ScoponeEngine() - session = _started(engine) - deadline = engine.next_deadline(session) - assert deadline is not None - self.assertEqual("turn", deadline.kind) - # A forged token is stale. - with self.assertRaises(GameError): - engine.fire_deadline(session, "turn", "turn:1:1:0") - turn_before = session.state.turn - engine.fire_deadline(session, deadline.kind, deadline.token) - self.assertNotEqual(turn_before, session.state.turn) - - def test_stale_deadline_after_play(self) -> None: - from tavolo.scopone import engine as rules - - engine = ScoponeEngine() - session = _started(engine) - deadline = engine.next_deadline(session) - assert deadline is not None - # A play lands in time: the armed deadline is overtaken. - state = session.state - player = next(p for p in state.players if p.seat == state.turn) - played = player.hand[0] - options = rules.legal_captures(state.table, played) - payload = {"card": played.code} - if options: - payload["capture"] = [c.code for c in options[0]] - engine.handle_action(session, player.sub, "play", payload) - with self.assertRaises(GameError): - engine.fire_deadline(session, deadline.kind, deadline.token) - - def test_hand_end_deadline_advances(self) -> None: - engine = ScoponeEngine() - session = _started(engine) - state = session.state - # Force the hand-end phase with an imminent deadline. - state.phase = PHASE_HAND_END - state.hand_end_deadline = ( - datetime.now(timezone.utc) + timedelta(seconds=60) - ).isoformat() - deadline = engine.next_deadline(session) - assert deadline is not None - self.assertEqual("hand_end", deadline.kind) - engine.fire_deadline(session, deadline.kind, deadline.token) - self.assertEqual(PHASE_PLAYING, session.state.phase) - self.assertEqual(2, session.state.hand_number) - - -class ResultTest(unittest.TestCase): - def test_result_of_finished_match(self) -> None: - from tavolo.scopone import engine as rules - - engine = ScoponeEngine() - session = _started(engine, target_score=1) - moves = 0 - while not engine.is_finished(session) and moves < 200000: - state = session.state - if state.phase == "hand_end": - for p in state.players: - engine.handle_action(session, p.sub, "ack", {}) - continue - player = next(p for p in state.players if p.seat == state.turn) - played = player.hand[0] - options = rules.legal_captures(state.table, played) - payload = {"card": played.code} - if options: - payload["capture"] = [c.code for c in options[0]] - engine.handle_action(session, player.sub, "play", payload) - moves += 1 - result = engine.result(session) - self.assertEqual(2, len(result.teams)) - self.assertIn(result.winner_team, (0, 1)) - self.assertEqual(4, len(result.players)) - for player in result.players: - self.assertEqual( - player.won, player.team == ("A" if result.winner_team == 0 else "B") - ) - self.assertIn("team_a_score", result.summary) - self.assertIn("hands_played", result.summary) - - def test_result_requires_finished_match(self) -> None: - engine = ScoponeEngine() - with self.assertRaises(GameError): - engine.result(_started(engine)) - - def test_game_over_view(self) -> None: - engine = ScoponeEngine() - session = _started(engine) - session.state.phase = PHASE_FINISHED - session.state.winner = 1 - session.state.scores = [3, 11] - over = engine.game_over_view(session, "alice") - self.assertEqual({"A": 3, "B": 11}, over["scores"]) - self.assertEqual("B", over["winner"]) - - def test_registry_metadata(self) -> None: - engine = ScoponeEngine() - self.assertEqual("scopone_scientifico", engine.id) - self.assertEqual(4, engine.min_players) - self.assertEqual(4, engine.max_players) - self.assertIn("target_score", engine.options_schema["properties"]) - self.assertIn("napola", engine.options_schema["properties"]) - self.assertIn("asso_pigliatutto", engine.options_schema["properties"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/server/pyproject.toml b/server/pyproject.toml deleted file mode 100644 index 405d6bb..0000000 --- a/server/pyproject.toml +++ /dev/null @@ -1,16 +0,0 @@ -# NOTE: the tavolo-app distribution lives in packages/tavolo-app/ (same -# kaya-style layout as tavolo-platform and tavolo-scopone). This file -# exists only for tooling that reads configuration from the working -# directory: -# - aerich reads [tool.aerich] from here (server/ locally, /app in the -# db-migrate compose service), next to ./migrations; -# - mypy reads [tool.mypy] from here when run from server/. -# See the Migrations section in README.md. -[tool.aerich] -tortoise_orm = "tavolo.aerich_config.TORTOISE_ORM" -location = "./migrations" - -[tool.mypy] -python_version = "3.12" -ignore_missing_imports = true -plugins = [] diff --git a/server/requirements.txt b/server/requirements.txt deleted file mode 100644 index 24d07e8..0000000 --- a/server/requirements.txt +++ /dev/null @@ -1,181 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.14 -# by the following command (run from server/packages/tavolo-app/): -# -# pip-compile --allow-unsafe --extra otel --extra-index-url=https://pypi.org/simple --index-url=https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --output-file=../../requirements.txt pyproject.toml -# -# NOTE: the tavolo-platform, tavolo-scopone and tavolo-app sibling packages -# are NOT pinned here: they are installed from ./packages in the Dockerfile -# and in development (pip install -e ./packages/tavolo-platform -# -e ./packages/tavolo-scopone -e ./packages/tavolo-app). All of their -# third-party dependencies are direct dependencies of tavolo-app, so this -# lock covers the full closure. The otel extra (kaya-otel and its -# OpenTelemetry dependencies) IS included, so OTEL_ENABLED works in the -# Docker image; pip-compile cannot resolve the unpublishable sibling -# packages, so regeneration drops them from a copy of the input file (the -# existing pins constrain the rest). -# ---index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple ---extra-index-url https://pypi.org/simple - -aerich==0.10.1 - # via tavolo-app (pyproject.toml) -aiosqlite==0.22.1 - # via tortoise-orm -anyio==4.15.1 - # via - # aerich - # httpx - # tortoise-orm -asyncclick==8.4.2.1 - # via aerich -asyncpg==0.31.0 - # via tavolo-app (pyproject.toml) -certifi==2026.7.22 - # via - # httpcore - # httpx -cffi==2.1.1 - # via cryptography -click==8.5.0 - # via granian -cryptography==50.0.1 - # via pyjwt -dictdiffer==0.10.0 - # via aerich -googleapis-common-protos==1.75.4 - # via opentelemetry-exporter-otlp-proto-http -granian==2.8.3 - # via - # kaya-rsgi - # tavolo-app (pyproject.toml) -h11==0.16.0 - # via httpcore -httpcore==1.0.9 - # via httpx -httpx==0.28.1 - # via - # kaya-oidc - # tavolo-app (pyproject.toml) -idna==3.20 - # via - # anyio - # httpx -iso8601==2.1.0 - # via tortoise-orm -kaya-core==0.0.7 - # via - # kaya-cors - # kaya-oidc - # kaya-openapi - # kaya-otel - # kaya-rsgi - # kaya-session - # tavolo-app (pyproject.toml) -kaya-cors==0.0.7 - # via tavolo-app (pyproject.toml) -kaya-oidc==0.0.7 - # via tavolo-app (pyproject.toml) -kaya-openapi==0.0.7 - # via tavolo-app (pyproject.toml) -kaya-otel==0.0.7 - # via tavolo-app (pyproject.toml) -kaya-rsgi==0.0.7 - # via tavolo-app (pyproject.toml) -kaya-session==0.0.7 - # via - # kaya-oidc - # kaya-session-redis - # tavolo-app (pyproject.toml) -kaya-session-redis==0.0.7 - # via tavolo-app (pyproject.toml) -opentelemetry-api==1.45.0 - # via - # opentelemetry-exporter-http-transport - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-instrumentation - # opentelemetry-instrumentation-asyncpg - # opentelemetry-instrumentation-redis - # opentelemetry-instrumentation-system-metrics - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-exporter-http-transport[urllib3]==0.66b0 - # via opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-common==0.66b0 - # via opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-common==1.45.0 - # via opentelemetry-exporter-otlp-proto-http -opentelemetry-exporter-otlp-proto-http==1.45.0 - # via kaya-otel -opentelemetry-instrumentation==0.66b0 - # via - # opentelemetry-instrumentation-asyncpg - # opentelemetry-instrumentation-redis - # opentelemetry-instrumentation-system-metrics -opentelemetry-instrumentation-asyncpg==0.66b0 - # via tavolo-app (pyproject.toml) -opentelemetry-instrumentation-redis==0.66b0 - # via tavolo-app (pyproject.toml) -opentelemetry-instrumentation-system-metrics==0.66b0 - # via tavolo-app (pyproject.toml) -opentelemetry-proto==1.45.0 - # via - # opentelemetry-exporter-otlp-proto-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-sdk==1.45.0 - # via - # kaya-otel - # opentelemetry-exporter-otlp-common - # opentelemetry-exporter-otlp-proto-http -opentelemetry-semantic-conventions==0.66b0 - # via - # opentelemetry-instrumentation - # opentelemetry-instrumentation-asyncpg - # opentelemetry-instrumentation-redis - # opentelemetry-instrumentation-system-metrics - # opentelemetry-sdk -packaging==26.3 - # via opentelemetry-instrumentation -protobuf==7.36.2 - # via - # googleapis-common-protos - # opentelemetry-proto -psutil==7.2.2 - # via opentelemetry-instrumentation-system-metrics -pwo==0.1.2 - # via - # kaya-core - # kaya-rsgi - # kaya-session - # tavolo-app (pyproject.toml) -pycparser==3.0 - # via cffi -pyjwt[crypto]==2.15.1 - # via - # kaya-oidc - # tavolo-app (pyproject.toml) -pypika-tortoise==0.6.5 - # via tortoise-orm -pyyaml==6.0.3 - # via tavolo-app (pyproject.toml) -redis==8.1.0 - # via - # kaya-session-redis - # tavolo-app (pyproject.toml) -tortoise-orm==1.1.8 - # via aerich -typing-extensions==4.16.0 - # via - # anyio - # kaya-core - # opentelemetry-api - # opentelemetry-exporter-otlp-proto-http - # opentelemetry-sdk - # opentelemetry-semantic-conventions - # pwo -urllib3==2.8.0 - # via opentelemetry-exporter-http-transport -wrapt==2.5.0 - # via - # opentelemetry-instrumentation - # opentelemetry-instrumentation-redis diff --git a/server/tests/__init__.py b/server/tests/__init__.py deleted file mode 100644 index 09d06bd..0000000 --- a/server/tests/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Test package init. - -Sets environment overrides BEFORE any test module imports -:mod:`tavolo.app` (which evaluates :data:`tavolo.config.settings` -at import time). Works under both ``python -m unittest discover`` and -``pytest``; conftest.py mirrors this for pytest-only collection. -""" -from __future__ import annotations - -import os - -os.environ.setdefault("DATABASE_URL", "sqlite://:memory:") -os.environ.setdefault("OIDC_0_PROVIDER", "default") -os.environ.setdefault("OIDC_0_ISSUER", "http://localhost:8180/tavolo") -os.environ.setdefault("OIDC_0_CLIENT_ID", "tavolo") -os.environ.setdefault("OIDC_0_REDIRECT_URI", "http://localhost:8080/auth/default/callback") -# Unset REDIS_URL: sessions and live games use the in-memory fallbacks. -os.environ.pop("REDIS_URL", None) diff --git a/server/tests/helpers/__init__.py b/server/tests/helpers/__init__.py deleted file mode 100644 index 818336c..0000000 --- a/server/tests/helpers/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Test helpers package for the tavolo-app integration suite.""" -from .asynctest import async_test -from .oidc import make_user, oidc_user, ws_users - -__all__ = ["async_test", "make_user", "oidc_user", "ws_users"] diff --git a/server/tests/helpers/asynctest.py b/server/tests/helpers/asynctest.py deleted file mode 100644 index 3948ea7..0000000 --- a/server/tests/helpers/asynctest.py +++ /dev/null @@ -1,35 +0,0 @@ -"""An ``async_test`` that also closes the app's Tortoise context. - -``pwo.async_test`` runs every test in a fresh event loop (``asyncio.Runner``). -The app's :class:`~tavolo.platform.tortoise_mixin.TortoiseMixin` builds one -``TortoiseContext`` per loop, so without an explicit close each test orphans -an aiosqlite connection whose non-daemon worker thread keeps the interpreter -alive after the suite reports "OK". -""" -from __future__ import annotations - -import asyncio -from functools import wraps -from typing import Any, Callable, Coroutine - -from tavolo.app import scheduler, tortoise_mixin - - -def async_test(coro: Callable[..., Coroutine[Any, Any, None]]) -> Callable[..., None]: - """Like ``pwo.async_test``, but close the Tortoise context and stop the - deadline consumer afterwards.""" - - @wraps(coro) - def wrapper(*args: Any, **kwargs: Any) -> None: - async def run() -> None: - loop = asyncio.get_running_loop() - try: - await coro(*args, **kwargs) - finally: - scheduler.stop_consumer(loop) - await tortoise_mixin.aclose() - - with asyncio.Runner() as runner: - runner.run(run()) - - return wrapper diff --git a/server/tests/helpers/oidc.py b/server/tests/helpers/oidc.py deleted file mode 100644 index 8f866f0..0000000 --- a/server/tests/helpers/oidc.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Helpers for faking the OIDC authenticated user during tests. - -HTTP handlers funnel through ``OIDCProviders.get_users`` (wrapped by -``tavolo.platform.auth.current_user``); websocket handlers through -:func:`tavolo.platform.auth.get_ws_user`. Patching those two entry points -lets route and websocket tests run entirely in-process with no IdP. -""" -from __future__ import annotations - -import contextlib -import unittest.mock as _mock -from typing import Iterator, Optional, Sequence - -from kaya.oidc import OIDCUser - -# Import the app first: it pulls in the route modules, which import -# ``tavolo.platform.auth`` themselves. -from tavolo.app import oidc -from tavolo.platform import auth - -# The app's first configured provider (the test environment configures one -# named "default"). -_PROVIDER = next(iter(oidc.providers)) - - -def make_user(sub: str, name: Optional[str] = None) -> OIDCUser: - return OIDCUser({"sub": sub, "preferred_username": name or sub}) - - -@contextlib.contextmanager -def oidc_user(sub: str, name: Optional[str] = None) -> Iterator[OIDCUser]: - """Context manager: make ``oidc.get_users`` return this user.""" - user = make_user(sub, name) - patcher = _mock.patch.object(oidc, "get_users", return_value={_PROVIDER: 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.""" - remaining = list(users) - last = remaining[-1] if remaining else None - - def _next(_ws): - if remaining: - return remaining.pop(0) - return last - - patcher = _mock.patch.object(auth, "get_ws_user", side_effect=_next) - patcher.start() - try: - yield - finally: - patcher.stop() diff --git a/server/tests/test_config.py b/server/tests/test_config.py deleted file mode 100644 index 3f02d29..0000000 --- a/server/tests/test_config.py +++ /dev/null @@ -1,317 +0,0 @@ -"""Unit tests for the database DSN assembly in :mod:`tavolo.config`. - -``Settings.from_env`` is called directly with a fully replaced -``os.environ`` so no test leaks its ``DATABASE_*`` overrides into the -suite (``tests/__init__.py`` sets ``DATABASE_URL=sqlite://:memory:`` -globally for the application tests). -""" -from __future__ import annotations - -import os -import unittest -from unittest.mock import patch - -from tavolo.config import Settings - - -def _settings(env: dict) -> Settings: - with patch.dict(os.environ, env, clear=True): - return Settings.from_env() - - -class DatabaseUrlTests(unittest.TestCase): - def test_defaults_assemble_from_parts(self): - settings = _settings({}) - self.assertEqual( - settings.database_url, - "postgres://tavolo:password@localhost/tavolo", - ) - - def test_components_override_defaults(self): - settings = _settings({ - "DATABASE_ENGINE": "postgres", - "DATABASE_HOST": "db.internal", - "DATABASE_PORT": "5433", - "DATABASE_NAME": "cards", - "DATABASE_USER": "scopa", - "DATABASE_PASSWORD": "s3cret", - }) - self.assertEqual( - settings.database_url, - "postgres://scopa:s3cret@db.internal:5433/cards", - ) - - def test_options_are_appended_as_query_string(self): - settings = _settings({"DATABASE_OPTIONS": "ssl=require"}) - self.assertEqual( - settings.database_url, - "postgres://tavolo:password@localhost/tavolo?ssl=require", - ) - - def test_options_leading_question_mark_is_stripped(self): - settings = _settings({"DATABASE_OPTIONS": "?ssl=require"}) - self.assertEqual( - settings.database_url, - "postgres://tavolo:password@localhost/tavolo?ssl=require", - ) - - def test_credentials_are_percent_encoded(self): - settings = _settings({ - "DATABASE_USER": "u@x", - "DATABASE_PASSWORD": "p@ss/word:1", - }) - self.assertEqual( - settings.database_url, - "postgres://u%40x:p%40ss%2Fword%3A1@localhost/tavolo", - ) - - def test_database_url_takes_precedence_over_parts(self): - settings = _settings({ - "DATABASE_URL": "sqlite://:memory:", - "DATABASE_HOST": "db.internal", - "DATABASE_PASSWORD": "ignored", - }) - self.assertEqual(settings.database_url, "sqlite://:memory:") - - def test_empty_database_url_falls_back_to_parts(self): - settings = _settings({"DATABASE_URL": ""}) - self.assertEqual( - settings.database_url, - "postgres://tavolo:password@localhost/tavolo", - ) - - -class CorsSettingsTests(unittest.TestCase): - def test_cors_disabled_by_default(self): - settings = _settings({}) - self.assertIsNone(settings.cors_allow_origins) - self.assertIsNone(settings.cors_allow_origin_regex) - self.assertIsNone(settings.cors_allow_methods) - self.assertIsNone(settings.cors_allow_headers) - self.assertFalse(settings.cors_allow_credentials) - self.assertIsNone(settings.cors_expose_headers) - self.assertEqual(600, settings.cors_max_age) - - def test_allow_origins_parses_comma_separated_list(self): - settings = _settings({ - "CORS_ALLOW_ORIGINS": "https://a.example, https://b.example ,,https://c.example", - }) - self.assertEqual( - ("https://a.example", "https://b.example", "https://c.example"), - settings.cors_allow_origins, - ) - - def test_allow_origins_star_is_passed_through(self): - settings = _settings({"CORS_ALLOW_ORIGINS": "*"}) - self.assertEqual(("*",), settings.cors_allow_origins) - - def test_allow_origin_regex_is_passed_through(self): - settings = _settings({"CORS_ALLOW_ORIGIN_REGEX": r"https://.*\.example\.com"}) - self.assertEqual(r"https://.*\.example\.com", settings.cors_allow_origin_regex) - - def test_allow_methods_and_headers_parse_as_lists(self): - settings = _settings({ - "CORS_ALLOW_METHODS": "GET,POST", - "CORS_ALLOW_HEADERS": "Authorization, X-Custom-Header", - "CORS_EXPOSE_HEADERS": "X-Total-Count", - }) - self.assertEqual(("GET", "POST"), settings.cors_allow_methods) - self.assertEqual(("Authorization", "X-Custom-Header"), settings.cors_allow_headers) - self.assertEqual(("X-Total-Count",), settings.cors_expose_headers) - - def test_allow_credentials_parses_boolean(self): - for value in ("1", "true", "TRUE", "yes", "on"): - self.assertTrue(_settings({"CORS_ALLOW_CREDENTIALS": value}).cors_allow_credentials) - for value in ("0", "false", "no", "off", "anything-else"): - self.assertFalse(_settings({"CORS_ALLOW_CREDENTIALS": value}).cors_allow_credentials) - - def test_max_age_parses_int(self): - settings = _settings({"CORS_MAX_AGE": "3600"}) - self.assertEqual(3600, settings.cors_max_age) - - -class OTelSettingsTests(unittest.TestCase): - def test_otel_disabled_by_default(self): - settings = _settings({}) - self.assertFalse(settings.otel_enabled) - self.assertEqual("tavolo", settings.otel_service_name) - self.assertIsNone(settings.otel_exporter_endpoint) - self.assertIsNone(settings.otel_exporter_headers) - - def test_otel_enabled_parses_boolean(self): - for value in ("1", "true", "TRUE", "yes", "on"): - self.assertTrue(_settings({"OTEL_ENABLED": value}).otel_enabled) - for value in ("0", "false", "no", "off", "anything-else"): - self.assertFalse(_settings({"OTEL_ENABLED": value}).otel_enabled) - - def test_otel_settings_are_passed_through(self): - settings = _settings({ - "OTEL_SERVICE_NAME": "cards", - "OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector:4318", - "OTEL_EXPORTER_OTLP_HEADERS": "Authorization=Bearer t, X-Tenant=one", - }) - self.assertEqual("cards", settings.otel_service_name) - self.assertEqual("http://collector:4318", settings.otel_exporter_endpoint) - self.assertEqual( - ("Authorization=Bearer t", "X-Tenant=one"), - settings.otel_exporter_headers, - ) - - def test_otel_excluded_paths_defaults_to_health_endpoint(self): - self.assertEqual(("/api/health",), _settings({}).otel_excluded_paths) - - def test_otel_excluded_paths_parses_comma_separated_list(self): - settings = _settings({"OTEL_EXCLUDED_PATHS": "/api/health, /metrics"}) - self.assertEqual(("/api/health", "/metrics"), settings.otel_excluded_paths) - - def test_otel_interval_defaults(self): - settings = _settings({}) - self.assertEqual(60000, settings.otel_metric_export_interval_millis) - self.assertEqual(15, settings.otel_metrics_poll_interval_seconds) - - def test_otel_intervals_parse(self): - settings = _settings({ - "OTEL_METRIC_EXPORT_INTERVAL_MILLIS": "5000", - "OTEL_METRICS_POLL_INTERVAL_SECONDS": "3", - }) - self.assertEqual(5000, settings.otel_metric_export_interval_millis) - self.assertEqual(3, settings.otel_metrics_poll_interval_seconds) - - -class OidcSettingsTests(unittest.TestCase): - def test_defaults_to_the_dev_mock_provider(self): - settings = _settings({}) - self.assertEqual(1, len(settings.oidc_providers)) - provider = settings.oidc_providers[0] - self.assertEqual("default", provider.provider) - self.assertEqual("http://localhost:8180/tavolo", provider.issuer) - self.assertEqual("tavolo", provider.client_id) - self.assertIsNone(provider.client_secret) - self.assertEqual( - "http://localhost:8080/auth/default/callback", provider.redirect_uri - ) - self.assertEqual("/", provider.post_login_redirect) - self.assertEqual("/", provider.post_logout_redirect) - - def test_single_indexed_provider_defaults(self): - settings = _settings({ - "OIDC_0_PROVIDER": "google", - "OIDC_0_ISSUER": "https://accounts.google.com", - "OIDC_0_CLIENT_ID": "client-id", - "OIDC_0_CLIENT_SECRET": "client-secret", - }) - self.assertEqual(1, len(settings.oidc_providers)) - provider = settings.oidc_providers[0] - self.assertEqual("google", provider.provider) - self.assertEqual("https://accounts.google.com", provider.issuer) - self.assertEqual("client-id", provider.client_id) - self.assertEqual("client-secret", provider.client_secret) - # The callback default mirrors kaya's /auth//callback. - self.assertEqual( - "http://localhost:8080/auth/google/callback", provider.redirect_uri - ) - self.assertEqual("/", provider.post_login_redirect) - self.assertEqual("/", provider.post_logout_redirect) - - def test_multiple_providers_are_ordered_by_index(self): - settings = _settings({ - "OIDC_0_PROVIDER": "google", - "OIDC_0_ISSUER": "https://accounts.google.com", - "OIDC_0_CLIENT_ID": "g", - "OIDC_1_PROVIDER": "github", - "OIDC_1_ISSUER": "https://github.com/login/oauth", - "OIDC_1_CLIENT_ID": "h", - "OIDC_1_REDIRECT_URI": "https://tavolo.example/auth/github/callback", - "OIDC_1_POST_LOGIN_REDIRECT": "/lobby", - "OIDC_1_POST_LOGOUT_REDIRECT": "/bye", - }) - self.assertEqual( - ["google", "github"], [p.provider for p in settings.oidc_providers] - ) - github = settings.oidc_providers[1] - self.assertEqual( - "https://tavolo.example/auth/github/callback", github.redirect_uri - ) - self.assertEqual("/lobby", github.post_login_redirect) - self.assertEqual("/bye", github.post_logout_redirect) - - def test_empty_optional_values_fall_back_to_defaults(self): - settings = _settings({ - "OIDC_0_PROVIDER": "google", - "OIDC_0_ISSUER": "https://accounts.google.com", - "OIDC_0_CLIENT_ID": "g", - "OIDC_0_CLIENT_SECRET": "", - "OIDC_0_REDIRECT_URI": "", - }) - provider = settings.oidc_providers[0] - self.assertIsNone(provider.client_secret) - self.assertEqual( - "http://localhost:8080/auth/google/callback", provider.redirect_uri - ) - - def test_missing_provider_name(self): - with self.assertRaisesRegex(RuntimeError, "OIDC_0_PROVIDER"): - _settings({"OIDC_0_ISSUER": "https://idp", "OIDC_0_CLIENT_ID": "c"}) - - def test_missing_issuer(self): - with self.assertRaisesRegex(RuntimeError, "OIDC_0_ISSUER"): - _settings({"OIDC_0_PROVIDER": "google", "OIDC_0_CLIENT_ID": "c"}) - - def test_missing_client_id(self): - with self.assertRaisesRegex(RuntimeError, "OIDC_0_CLIENT_ID"): - _settings({"OIDC_0_PROVIDER": "google", "OIDC_0_ISSUER": "https://idp"}) - - def test_indices_must_start_at_zero(self): - with self.assertRaisesRegex(RuntimeError, "contiguous"): - _settings({ - "OIDC_3_PROVIDER": "google", - "OIDC_3_ISSUER": "https://idp", - "OIDC_3_CLIENT_ID": "c", - }) - - def test_indices_must_be_contiguous(self): - with self.assertRaisesRegex(RuntimeError, "OIDC_1_"): - _settings({ - "OIDC_0_PROVIDER": "google", - "OIDC_0_ISSUER": "https://idp", - "OIDC_0_CLIENT_ID": "c", - "OIDC_2_PROVIDER": "github", - "OIDC_2_ISSUER": "https://idp2", - "OIDC_2_CLIENT_ID": "c2", - }) - - def test_duplicate_provider_names_are_rejected(self): - with self.assertRaisesRegex(RuntimeError, "duplicate"): - _settings({ - "OIDC_0_PROVIDER": "google", - "OIDC_0_ISSUER": "https://idp", - "OIDC_0_CLIENT_ID": "c", - "OIDC_1_PROVIDER": "google", - "OIDC_1_ISSUER": "https://idp2", - "OIDC_1_CLIENT_ID": "c2", - }) - - def test_invalid_provider_name_is_rejected(self): - with self.assertRaisesRegex(RuntimeError, "OIDC_0_PROVIDER"): - _settings({ - "OIDC_0_PROVIDER": "not a name", - "OIDC_0_ISSUER": "https://idp", - "OIDC_0_CLIENT_ID": "c", - }) - - def test_unknown_indexed_variable_is_rejected(self): - with self.assertRaisesRegex(RuntimeError, "OIDC_0_ISUER"): - _settings({ - "OIDC_0_PROVIDER": "google", - "OIDC_0_ISSUER": "https://idp", - "OIDC_0_CLIENT_ID": "c", - "OIDC_0_ISUER": "typo", - }) - - def test_legacy_unindexed_variables_get_a_migration_hint(self): - with self.assertRaisesRegex(RuntimeError, "OIDC_ISSUER -> OIDC_0_ISSUER"): - _settings({"OIDC_ISSUER": "https://idp", "OIDC_CLIENT_ID": "c"}) - - -if __name__ == "__main__": - unittest.main() diff --git a/server/tests/test_cors.py b/server/tests/test_cors.py deleted file mode 100644 index d8f1781..0000000 --- a/server/tests/test_cors.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Integration tests for the CORS configuration in :mod:`tavolo.app`. - -The mixin under test is kaya-cors' :class:`~kaya.cors.CorsMixin`; these -tests only verify that :func:`tavolo.app.cors_mixin_from_settings` maps the -environment-driven :class:`~tavolo.config.Settings` onto it correctly. A -minimal ``KayaApp`` is used instead of the global ``app`` so the tests do -not depend on the environment the suite was imported with. -""" -from __future__ import annotations - -import os -import unittest -from unittest.mock import patch - -from httpx import ASGITransport, AsyncClient -from kaya.core import HttpContext, KayaApp - -from tavolo.app import cors_mixin_from_settings -from tavolo.config import Settings -from tests.helpers import async_test - -ORIGIN = "https://cards.example" - - -def _settings(env: dict) -> Settings: - with patch.dict(os.environ, env, clear=True): - return Settings.from_env() - - -def _app(settings: Settings) -> KayaApp: - mixin = cors_mixin_from_settings(settings) - assert mixin is not None - app = KayaApp(mixins=[mixin]) - - @app.GET("/api/health") - async def health(ctx: HttpContext) -> None: - await ctx.send_str(200, "ok") - - return app - - -class CorsMixinFromSettingsTests(unittest.TestCase): - def test_disabled_when_unconfigured(self): - self.assertIsNone(cors_mixin_from_settings(_settings({}))) - - def test_enabled_by_allow_origins(self): - self.assertIsNotNone(cors_mixin_from_settings( - _settings({"CORS_ALLOW_ORIGINS": ORIGIN}))) - - def test_enabled_by_allow_origin_regex_alone(self): - self.assertIsNotNone(cors_mixin_from_settings( - _settings({"CORS_ALLOW_ORIGIN_REGEX": r"https://.*\.example\.com"}))) - - -class CorsBehaviorTests(unittest.TestCase): - @async_test - async def test_request_without_origin_is_untouched(self) -> None: - app = _app(_settings({"CORS_ALLOW_ORIGINS": ORIGIN})) - async with AsyncClient(transport=ASGITransport(app=app), - base_url="http://127.0.0.1") as client: - response = await client.get("/api/health") - self.assertEqual(200, response.status_code) - self.assertNotIn("access-control-allow-origin", response.headers) - - @async_test - async def test_simple_request_with_allowed_origin(self) -> None: - app = _app(_settings({"CORS_ALLOW_ORIGINS": ORIGIN})) - async with AsyncClient(transport=ASGITransport(app=app), - base_url="http://127.0.0.1") as client: - response = await client.get("/api/health", headers={"Origin": ORIGIN}) - self.assertEqual(200, response.status_code) - self.assertEqual(ORIGIN, response.headers["access-control-allow-origin"]) - - @async_test - async def test_simple_request_with_disallowed_origin(self) -> None: - app = _app(_settings({"CORS_ALLOW_ORIGINS": ORIGIN})) - async with AsyncClient(transport=ASGITransport(app=app), - base_url="http://127.0.0.1") as client: - response = await client.get( - "/api/health", headers={"Origin": "https://mallory.example"}) - self.assertEqual(200, response.status_code) - self.assertNotIn("access-control-allow-origin", response.headers) - - @async_test - async def test_preflight_allowed(self) -> None: - app = _app(_settings({ - "CORS_ALLOW_ORIGINS": ORIGIN, - "CORS_ALLOW_METHODS": "GET,POST", - "CORS_MAX_AGE": "3600", - })) - async with AsyncClient(transport=ASGITransport(app=app), - base_url="http://127.0.0.1") as client: - response = await client.options("/api/health", headers={ - "Origin": ORIGIN, - "Access-Control-Request-Method": "POST", - }) - self.assertEqual(200, response.status_code) - self.assertEqual(ORIGIN, response.headers["access-control-allow-origin"]) - self.assertEqual("GET, POST", response.headers["access-control-allow-methods"]) - self.assertEqual("3600", response.headers["access-control-max-age"]) - - @async_test - async def test_preflight_disallowed_origin(self) -> None: - app = _app(_settings({"CORS_ALLOW_ORIGINS": ORIGIN})) - async with AsyncClient(transport=ASGITransport(app=app), - base_url="http://127.0.0.1") as client: - response = await client.options("/api/health", headers={ - "Origin": "https://mallory.example", - "Access-Control-Request-Method": "GET", - }) - self.assertEqual(400, response.status_code) - self.assertIn("Disallowed CORS", response.text) - - @async_test - async def test_credentials_echo_origin_and_set_flag(self) -> None: - app = _app(_settings({ - "CORS_ALLOW_ORIGINS": "*", - "CORS_ALLOW_CREDENTIALS": "true", - })) - async with AsyncClient(transport=ASGITransport(app=app), - base_url="http://127.0.0.1") as client: - response = await client.get("/api/health", headers={"Origin": ORIGIN}) - self.assertEqual(200, response.status_code) - self.assertEqual(ORIGIN, response.headers["access-control-allow-origin"]) - self.assertEqual("true", response.headers["access-control-allow-credentials"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/server/tests/test_deadlines.py b/server/tests/test_deadlines.py deleted file mode 100644 index d37bdc0..0000000 --- a/server/tests/test_deadlines.py +++ /dev/null @@ -1,242 +0,0 @@ -"""Deadline-queue timeout tests (scopone game, full platform stack). - -Timeouts must be driven by the persisted deadlines and the shared queue, -not by connected sockets: these tests seed sessions, queue their -deadlines and let the background consumer fire them without a single -websocket. -""" -from __future__ import annotations - -import asyncio -import unittest -from datetime import datetime, timedelta, timezone -from typing import Optional - -from tavolo.app import game_store, platform, scheduler, tortoise_mixin -from tavolo.platform import GameSession, Seat -from tavolo.platform.deadlines import encode -from tavolo.platform.models import Match -from tavolo.scopone.state import Card, PlayerState, ScoponeState -from tests.helpers import async_test - -PLAYERS = ("alice", "bob", "carol", "dave") - - -def _started_session( - game_id: str, - code: str, - turn_timeout: int = 3600, - hand_ack_timeout: int = 3600, -) -> GameSession: - engine = platform.registry.require("scopone_scientifico") - session = GameSession( - id=game_id, - game_type=engine.id, - join_code=code, - creator_sub="alice", - players=[Seat(user_sub="alice", display_name="Alice")], - ) - engine.create(session, {}) - session.state.hand_ack_timeout = hand_ack_timeout - session.state.turn_timeout = turn_timeout - for name in PLAYERS[1:]: - engine.join(session, name, name.capitalize()) - return session - - -def _hand_end_session(game_id: str, deadline: str) -> GameSession: - """A session paused on the hand-end summary, waiting for acks.""" - engine = platform.registry.require("scopone_scientifico") - session = GameSession( - id=game_id, - game_type=engine.id, - join_code="DLhend", - creator_sub="alice", - players=[ - Seat(user_sub=name, display_name=name.capitalize(), team=team) - for name, team in zip(PLAYERS, ("A", "B", "A", "B")) - ], - ) - session.state = ScoponeState( - target_score=11, - phase="hand_end", - players=[ - PlayerState(sub=name, name=name.capitalize(), seat=i) - for i, name in enumerate(PLAYERS) - ], - hand_ack_timeout=3600, - # Long turn timeout: the next hand's auto-play must not interfere - # with later tests sharing this store. - turn_timeout=3600, - hand_end_deadline=deadline, - ) - return session - - -async def _wait_for(predicate, timeout: float = 5.0) -> Optional[GameSession]: - """Poll the store until ``predicate`` holds for the loaded session.""" - deadline = asyncio.get_running_loop().time() + timeout - while asyncio.get_running_loop().time() < deadline: - session = await predicate() - if session is not None: - return session - await asyncio.sleep(0.05) - return None - - -class ConnectionIndependenceTest(unittest.TestCase): - @async_test - async def test_turn_timeout_fires_with_no_connections(self) -> None: - session = _started_session("dl-turn-1", "DLT001", turn_timeout=1) - assert session.state.turn_deadline is not None - await game_store.save(session) - await scheduler.sync_deadline(session) - - # Nobody ever connects: the consumer must still auto-play for Bob - # (seat 1, first to act). - result = await _wait_for( - lambda: _turn_is(session.id, 2), - ) - self.assertIsNotNone(result, "turn deadline never fired") - assert result is not None - self.assertEqual( - 1, result.state.last_move.seat if result.state.last_move else None - ) - - # Defuse the follow-on turn deadlines so this game cannot keep - # auto-playing while later tests run. - result.state.turn_timeout = 3600 - await game_store.save(result) - - @async_test - async def test_hand_end_timeout_fires_with_no_connections(self) -> None: - deadline = (datetime.now(timezone.utc) + timedelta(seconds=1)).isoformat() - session = _hand_end_session("dl-handend-1", deadline) - await game_store.save(session) - await scheduler.sync_deadline(session) - - # Nobody acks (nobody is even connected): the deadline must deal - # the next hand. - result = await _wait_for( - lambda: _phase_is("dl-handend-1", "playing"), - ) - self.assertIsNotNone(result, "hand-end deadline never fired") - assert result is not None - self.assertEqual(2, result.state.hand_number) - self.assertEqual([], result.state.acked) - - @async_test - async def test_turn_timeout_finishing_match_persists_result(self) -> None: - # The match-deciding last turn auto-played by the deadline consumer - # runs in a task with no request context: the result must still - # reach Postgres. Previously the write raised and the deadline - # retried forever, leaving the game stuck on the last turn. - session = _started_session("dl-final-1", "DLF001", turn_timeout=1) - state = session.state - state.players[0].hand = [Card.parse("02D")] - for player in state.players[1:]: - player.hand = [] - state.table = [Card.parse("02C")] - state.turn = 0 - state.target_score = 1 - state.turn_deadline = ( - datetime.now(timezone.utc) + timedelta(seconds=1) - ).isoformat() - - await game_store.save(session) - await scheduler.sync_deadline(session) - - # Nobody plays: the consumer auto-plays the last card, which ends - # the hand and the match. - result = await _wait_for(lambda: _phase_is(session.id, "finished")) - self.assertIsNotNone(result, "turn deadline never finished the match") - assert result is not None - self.assertEqual(0, result.state.winner) - self.assertTrue(result.stats_saved) - - await tortoise_mixin.ensure_context() - ctx = tortoise_mixin._ctx - assert ctx is not None - with ctx: - self.assertEqual(1, await Match.all().count()) - - -async def _turn_is(game_id: str, turn: int) -> Optional[GameSession]: - session = await game_store.load(game_id) - return session if session is not None and session.state.turn == turn else None - - -async def _phase_is(game_id: str, phase: str) -> Optional[GameSession]: - session = await game_store.load(game_id) - return session if session is not None and session.state.phase == phase else None - - -class ProcessDueTest(unittest.TestCase): - """Direct ``process_due`` behaviour: engine revalidation and idempotency.""" - - @async_test - async def test_processing_twice_is_a_no_op(self) -> None: - # Simulates a worker dying after firing but before removing the - # entry: another worker re-delivers the same entry. - engine = platform.registry.require("scopone_scientifico") - deadline = (datetime.now(timezone.utc) - timedelta(seconds=1)).isoformat() - session = _hand_end_session("dl-idem-1", deadline) - await game_store.save(session) - current = engine.next_deadline(session) - assert current is not None - member = encode({ - "game_id": session.id, - "kind": current.kind, - "token": current.token, - }) - - await scheduler.process_due(member) - await scheduler.process_due(member) - - result = await game_store.load(session.id) - assert result is not None - # Advanced exactly once: hand 2, not hand 3. - self.assertEqual("playing", result.state.phase) - self.assertEqual(2, result.state.hand_number) - - @async_test - async def test_stale_entry_is_discarded(self) -> None: - # A turn entry enqueued with a forged token: the live state carries - # a different deadline, so the entry must not fire. - session = _started_session("dl-stale-1", "DLS001") - await game_store.save(session) - member = encode({ - "game_id": session.id, - "kind": "turn", - "token": "turn:1:1:0", # not the live token - }) - await game_store.add_deadline(member, due_at=0.0) - - await scheduler.process_due(member) - - result = await game_store.load(session.id) - assert result is not None - self.assertEqual(session.state.turn, result.state.turn) - # The entry was removed after processing. - self.assertNotIn(member, await game_store.due_deadlines(float("inf"))) - - @async_test - async def test_entry_for_expired_game_is_dropped(self) -> None: - member = encode({ - "game_id": "dl-gone", - "kind": "turn", - "token": "turn:1:0:0", - }) - await game_store.add_deadline(member, due_at=0.0) - await scheduler.process_due(member) - self.assertNotIn(member, await game_store.due_deadlines(float("inf"))) - - @async_test - async def test_malformed_entry_is_dropped(self) -> None: - await game_store.add_deadline("not json", due_at=0.0) - await scheduler.process_due("not json") - self.assertNotIn("not json", await game_store.due_deadlines(float("inf"))) - - -if __name__ == "__main__": - unittest.main() diff --git a/server/tests/test_logging_config.py b/server/tests/test_logging_config.py deleted file mode 100644 index 602cb0a..0000000 --- a/server/tests/test_logging_config.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Tests for the logging configuration entry point.""" -from __future__ import annotations - -import logging -import tempfile -import unittest -from pathlib import Path - -from tavolo.logging_config import configure_logging - - -class LoggingConfigTest(unittest.TestCase): - def setUp(self) -> None: - # configure_logging mutates the global logging state; snapshot and - # restore it so the rest of the suite is unaffected. - root = logging.getLogger() - self._root_handlers = root.handlers[:] - self._root_level = root.level - tavolo = logging.getLogger("tavolo") - self._tavolo_level = tavolo.level - pool = logging.getLogger("urllib3.connectionpool") - self._pool_level = pool.level - - def tearDown(self) -> None: - root = logging.getLogger() - root.handlers = self._root_handlers - root.level = self._root_level - logging.getLogger("tavolo").level = self._tavolo_level - logging.getLogger("urllib3.connectionpool").level = self._pool_level - - def test_default_config_when_unset(self) -> None: - configure_logging(None) - root = logging.getLogger() - self.assertEqual(logging.DEBUG, root.level) - self.assertTrue( - any(isinstance(h, logging.StreamHandler) for h in root.handlers), - "default config installs a console stream handler", - ) - # The noisy per-request HTTP logger is capped at INFO while the - # root stays at DEBUG. - self.assertEqual( - logging.INFO, - logging.getLogger("urllib3.connectionpool").getEffectiveLevel(), - ) - - def test_yaml_config_is_applied(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - config = Path(tmp) / "logging.yaml" - config.write_text( - "version: 1\n" - "disable_existing_loggers: false\n" - "root:\n" - " level: WARNING\n" - "loggers:\n" - " tavolo:\n" - " level: DEBUG\n" - ) - configure_logging(str(config)) - self.assertEqual(logging.DEBUG, logging.getLogger("tavolo").getEffectiveLevel()) - self.assertEqual(logging.WARNING, logging.getLogger().getEffectiveLevel()) - - def test_missing_file_raises(self) -> None: - with self.assertRaises(RuntimeError): - configure_logging("/nonexistent/logging.yaml") - - def test_non_mapping_yaml_raises(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - config = Path(tmp) / "logging.yaml" - config.write_text("- just\n- a\n- list\n") - with self.assertRaises(RuntimeError): - configure_logging(str(config)) - - -if __name__ == "__main__": - unittest.main() diff --git a/server/tests/test_otel.py b/server/tests/test_otel.py deleted file mode 100644 index 5a42c31..0000000 --- a/server/tests/test_otel.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Unit tests for the OpenTelemetry wiring in :mod:`tavolo.app`. - -The mixin under test is kaya-otel's :class:`~kaya.otel.OTelMixin`, an -optional dependency (the ``otel`` extra); these tests only verify that -:func:`tavolo.app.otel_mixin_from_settings` maps the ``OTEL_*`` settings -onto mixin construction. The ``kaya.otel`` module is stubbed in -``sys.modules`` so the suite does not need the extra installed. -""" -from __future__ import annotations - -import os -import sys -import types -import unittest -from unittest.mock import patch - -from tavolo.app import otel_mixin_from_settings -from tavolo.config import Settings -from tavolo.telemetry import session_tag_hook - - -def _settings(env: dict) -> Settings: - with patch.dict(os.environ, env, clear=True): - return Settings.from_env() - - -class _StubOTelMixin: - def __init__(self, **kwargs): - self.kwargs = kwargs - - -def _stub_kaya_otel(): - """Install a fake ``kaya.otel`` module and return it.""" - module = types.ModuleType("kaya.otel") - module.OTelMixin = _StubOTelMixin # type: ignore[attr-defined] - return patch.dict(sys.modules, {"kaya.otel": module}) - - -class OTelMixinFromSettingsTests(unittest.TestCase): - def test_disabled_by_default(self): - self.assertIsNone(otel_mixin_from_settings(_settings({}))) - - def test_enabled_by_otel_enabled(self): - with _stub_kaya_otel(): - mixin = otel_mixin_from_settings(_settings({"OTEL_ENABLED": "1"})) - self.assertIsNotNone(mixin) - - def test_settings_are_passed_through(self): - with _stub_kaya_otel(): - mixin = otel_mixin_from_settings(_settings({ - "OTEL_ENABLED": "true", - "OTEL_SERVICE_NAME": "cards", - "OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector:4318", - "OTEL_EXPORTER_OTLP_HEADERS": "Authorization=Bearer t, X-Tenant=one", - })) - assert isinstance(mixin, _StubOTelMixin) - self.assertEqual({ - "service_name": "cards", - "endpoint": "http://collector:4318", - "headers": {"Authorization": "Bearer t", "X-Tenant": "one"}, - "excluded_paths": ("/api/health",), - "server_response_hook": session_tag_hook, - "websocket_close_hook": session_tag_hook, - }, mixin.kwargs) - - def test_session_tag_hooks_are_wired(self): - with _stub_kaya_otel(): - mixin = otel_mixin_from_settings(_settings({"OTEL_ENABLED": "1"})) - assert isinstance(mixin, _StubOTelMixin) - self.assertIs(session_tag_hook, mixin.kwargs["server_response_hook"]) - self.assertIs(session_tag_hook, mixin.kwargs["websocket_close_hook"]) - - def test_defaults_when_only_enabled(self): - with _stub_kaya_otel(): - mixin = otel_mixin_from_settings(_settings({"OTEL_ENABLED": "on"})) - assert isinstance(mixin, _StubOTelMixin) - self.assertEqual("tavolo", mixin.kwargs["service_name"]) - self.assertIsNone(mixin.kwargs["endpoint"]) - self.assertIsNone(mixin.kwargs["headers"]) - self.assertEqual(("/api/health",), mixin.kwargs["excluded_paths"]) - - def test_meter_provider_is_passed_through(self): - with _stub_kaya_otel(): - provider = object() - mixin = otel_mixin_from_settings( - _settings({"OTEL_ENABLED": "1"}), meter_provider=provider - ) - assert isinstance(mixin, _StubOTelMixin) - self.assertIs(provider, mixin.kwargs["meter_provider"]) - - def test_tracer_provider_is_passed_through(self): - with _stub_kaya_otel(): - provider = object() - mixin = otel_mixin_from_settings( - _settings({"OTEL_ENABLED": "1"}), tracer_provider=provider - ) - assert isinstance(mixin, _StubOTelMixin) - self.assertIs(provider, mixin.kwargs["tracer_provider"]) - - def test_excluded_paths_are_passed_through(self): - with _stub_kaya_otel(): - mixin = otel_mixin_from_settings(_settings({ - "OTEL_ENABLED": "1", - "OTEL_EXCLUDED_PATHS": "/api/health,/metrics", - })) - assert isinstance(mixin, _StubOTelMixin) - self.assertEqual(("/api/health", "/metrics"), mixin.kwargs["excluded_paths"]) - - def test_missing_extra_raises_runtime_error(self): - with patch.dict(sys.modules, {"kaya.otel": None}): - with self.assertRaises(RuntimeError): - otel_mixin_from_settings(_settings({"OTEL_ENABLED": "1"})) - - -if __name__ == "__main__": - unittest.main() diff --git a/server/tests/test_routes_auth.py b/server/tests/test_routes_auth.py deleted file mode 100644 index 8863a6d..0000000 --- a/server/tests/test_routes_auth.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Auth routes on the fully wired application. - -The integration suite's app is built from the indexed test environment -(one provider named ``default``); these tests pin the public surface the -SPA and bookmarks rely on. -""" -from __future__ import annotations - -import unittest - -from httpx import ASGITransport, AsyncClient - -from tavolo.app import app -from tests.helpers import async_test - - -class AuthRoutesTest(unittest.TestCase): - @async_test - async def test_providers_endpoint_reports_the_configured_provider(self) -> None: - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - response = await client.get("/api/auth/providers") - self.assertEqual(200, response.status_code) - self.assertEqual( - {"providers": [{"name": "default", "login_url": "/auth/default/login"}]}, - response.json(), - ) - - @async_test - async def test_generic_login_redirects_to_the_provider(self) -> None: - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - response = await client.get("/auth/login", follow_redirects=False) - self.assertEqual(302, response.status_code) - self.assertEqual("/auth/default/login", response.headers["location"]) diff --git a/server/tests/test_routes_games.py b/server/tests/test_routes_games.py deleted file mode 100644 index 9253b7d..0000000 --- a/server/tests/test_routes_games.py +++ /dev/null @@ -1,278 +0,0 @@ -"""Game lobby route tests via kaya's ASGI transport (scopone game).""" -from __future__ import annotations - -import unittest - -from httpx import ASGITransport, AsyncClient - -from tavolo.app import app -from tests.helpers import async_test, oidc_user - - -class GamesRouteTest(unittest.TestCase): - @async_test - async def test_create_requires_auth(self) -> None: - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - response = await client.post("/api/games", json={}) - self.assertEqual(401, response.status_code) - self.assertEqual({"error": "unauthenticated"}, response.json()) - - @async_test - async def test_create_and_read_lobby(self) -> None: - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - with oidc_user("alice"): - created = await client.post( - "/api/games", json={"options": {"target_score": 16}} - ) - self.assertEqual(201, created.status_code) - body = created.json() - self.assertEqual("lobby", body["phase"]) - self.assertEqual(3, body["seats_open"]) - self.assertEqual(16, body["target_score"]) - self.assertEqual(6, len(body["join_code"])) - game_id = body["id"] - - with oidc_user("alice"): - snapshot = await client.get(f"/api/games/{game_id}") - self.assertEqual(200, snapshot.status_code) - self.assertEqual("alice", snapshot.json()["players"][0]["sub"]) - - with oidc_user("mallory"): - forbidden = await client.get(f"/api/games/{game_id}") - self.assertEqual(403, forbidden.status_code) - - @async_test - async def test_join_fills_seats_and_starts_game(self) -> None: - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - with oidc_user("alice"): - created = await client.post("/api/games", json={}) - code = created.json()["join_code"] - - for player in ("bob", "carol"): - with oidc_user(player): - joined = await client.post("/api/games/join", json={"code": code}) - self.assertEqual(200, joined.status_code) - self.assertEqual("lobby", joined.json()["phase"]) - - with oidc_user("dave"): - started = await client.post("/api/games/join", json={"code": code}) - self.assertEqual(200, started.status_code) - state = started.json() - self.assertEqual("playing", state["phase"]) - self.assertEqual(4, len(state["players"])) - self.assertEqual([], state["table"]) - for participant in state["players"]: - self.assertEqual(10, participant["cards_left"]) - # The view is personalized to Dave: he sees his own hand in - # seat 3 but not Alice's in seat 0. - self.assertIn("hand", state["players"][3]) - self.assertNotIn("hand", state["players"][0]) - self.assertEqual(1, state["turn"]) - - @async_test - async def test_create_napola_option(self) -> None: - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - with oidc_user("alice"): - default = await client.post("/api/games", json={}) - self.assertEqual(201, default.status_code) - self.assertTrue(default.json()["napola"]) - - with oidc_user("alice"): - disabled = await client.post( - "/api/games", json={"options": {"napola": False}} - ) - self.assertEqual(201, disabled.status_code) - self.assertFalse(disabled.json()["napola"]) - - with oidc_user("alice"): - invalid = await client.post( - "/api/games", json={"options": {"napola": "yes"}} - ) - self.assertEqual(400, invalid.status_code) - - @async_test - async def test_create_asso_pigliatutto_option(self) -> None: - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - with oidc_user("alice"): - default = await client.post("/api/games", json={}) - self.assertEqual(201, default.status_code) - self.assertFalse(default.json()["asso_pigliatutto"]) - - with oidc_user("alice"): - enabled = await client.post( - "/api/games", json={"options": {"asso_pigliatutto": True}} - ) - self.assertEqual(201, enabled.status_code) - self.assertTrue(enabled.json()["asso_pigliatutto"]) - - with oidc_user("alice"): - invalid = await client.post( - "/api/games", json={"options": {"asso_pigliatutto": "yes"}} - ) - self.assertEqual(400, invalid.status_code) - - @async_test - async def test_preview_join_code(self) -> None: - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - with oidc_user("alice"): - created = await client.post( - "/api/games", - json={ - "options": { - "target_score": 16, - "napola": False, - "asso_pigliatutto": True, - } - }, - ) - code = created.json()["join_code"] - - response = await client.get(f"/api/games/preview?code={code}") - self.assertEqual(401, response.status_code) - - with oidc_user("bob"): - missing = await client.get("/api/games/preview") - unknown = await client.get("/api/games/preview?code=ZZZZZZ") - self.assertEqual(400, missing.status_code) - self.assertEqual(404, unknown.status_code) - - with oidc_user("bob"): - preview = await client.get(f"/api/games/preview?code={code}") - self.assertEqual(200, preview.status_code) - body = preview.json() - self.assertEqual("scopone_scientifico", body["game_type"]) - self.assertEqual("Scopone scientifico", body["game_name"]) - self.assertTrue(body["game_description"]) - self.assertEqual("lobby", body["phase"]) - self.assertEqual(16, body["target_score"]) - self.assertFalse(body["napola"]) - self.assertTrue(body["asso_pigliatutto"]) - self.assertEqual(3, body["seats_open"]) - self.assertEqual(["alice"], [p["sub"] for p in body["players"]]) - - for player in ("bob", "carol", "dave"): - with oidc_user(player): - await client.post("/api/games/join", json={"code": code}) - with oidc_user("bob"): - started = await client.get(f"/api/games/preview?code={code}") - self.assertEqual(200, started.status_code) - self.assertEqual("playing", started.json()["phase"]) - self.assertEqual(0, started.json()["seats_open"]) - - @async_test - async def test_join_errors(self) -> None: - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - with oidc_user("alice"): - created = await client.post("/api/games", json={}) - code = created.json()["join_code"] - - with oidc_user("bob"): - unknown = await client.post("/api/games/join", json={"code": "ZZZZZZ"}) - self.assertEqual(404, unknown.status_code) - - with oidc_user("alice"): - duplicate = await client.post("/api/games/join", json={"code": code}) - self.assertEqual(409, duplicate.status_code) - - with oidc_user("bob"): - missing = await client.post("/api/games/join", json={}) - self.assertEqual(400, missing.status_code) - - for player in ("bob", "carol", "dave"): - with oidc_user(player): - await client.post("/api/games/join", json={"code": code}) - with oidc_user("erin"): - late = await client.post("/api/games/join", json={"code": code}) - self.assertEqual(409, late.status_code) - - @async_test - async def test_create_rejects_bad_target_score(self) -> None: - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - with oidc_user("alice"): - zero = await client.post( - "/api/games", json={"options": {"target_score": 0}} - ) - text = await client.post( - "/api/games", json={"options": {"target_score": "eleven"}} - ) - huge = await client.post( - "/api/games", json={"options": {"target_score": 1000}} - ) - self.assertEqual(400, zero.status_code) - self.assertEqual(400, text.status_code) - self.assertEqual(400, huge.status_code) - - @async_test - async def test_get_unknown_game(self) -> None: - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - with oidc_user("alice"): - response = await client.get("/api/games/does-not-exist") - self.assertEqual(404, response.status_code) - - -class GameTypesRouteTest(unittest.TestCase): - @async_test - async def test_lists_available_game_types(self) -> None: - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - response = await client.get("/api/game-types") - self.assertEqual(200, response.status_code) - results = response.json()["results"] - self.assertEqual(["scopone_scientifico"], [g["id"] for g in results]) - self.assertEqual("Scopone scientifico", results[0]["name"]) - self.assertTrue(results[0]["description"]) - self.assertEqual(4, results[0]["min_players"]) - self.assertEqual(4, results[0]["max_players"]) - self.assertIn("target_score", results[0]["options_schema"]["properties"]) - self.assertIn("napola", results[0]["options_schema"]["properties"]) - self.assertIn( - "asso_pigliatutto", results[0]["options_schema"]["properties"] - ) - - @async_test - async def test_create_defaults_game_type(self) -> None: - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - with oidc_user("alice"): - created = await client.post("/api/games", json={}) - self.assertEqual(201, created.status_code) - self.assertEqual("scopone_scientifico", created.json()["game_type"]) - - @async_test - async def test_create_with_explicit_game_type(self) -> None: - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - with oidc_user("alice"): - created = await client.post( - "/api/games", json={"game_type": "scopone_scientifico"} - ) - self.assertEqual(201, created.status_code) - body = created.json() - self.assertEqual("scopone_scientifico", body["game_type"]) - - with oidc_user("alice"): - snapshot = await client.get(f"/api/games/{body['id']}") - self.assertEqual("scopone_scientifico", snapshot.json()["game_type"]) - - @async_test - async def test_create_rejects_unknown_game_type(self) -> None: - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - with oidc_user("alice"): - unknown = await client.post("/api/games", json={"game_type": "briscola"}) - non_string = await client.post("/api/games", json={"game_type": 42}) - self.assertEqual(400, unknown.status_code) - self.assertEqual(400, non_string.status_code) - - -if __name__ == "__main__": - unittest.main() diff --git a/server/tests/test_routes_me.py b/server/tests/test_routes_me.py deleted file mode 100644 index 0c84ef1..0000000 --- a/server/tests/test_routes_me.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Tests for the whoami endpoint and the static SPA host.""" -from __future__ import annotations - -import dataclasses -import tempfile -import unittest -from pathlib import Path -from unittest import mock - -from httpx import ASGITransport, AsyncClient - -from tavolo.app import app -from tavolo.config import settings -from tests.helpers import async_test, oidc_user - - -class MeRouteTest(unittest.TestCase): - @async_test - async def test_me_authenticated(self) -> None: - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - with oidc_user("alice"): - response = await client.get("/api/me") - self.assertEqual(200, response.status_code) - self.assertEqual({"sub": "alice", "name": "alice"}, response.json()) - - @async_test - async def test_me_unauthenticated(self) -> None: - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - response = await client.get("/api/me") - self.assertEqual(401, response.status_code) - - -class StaticRouteTest(unittest.TestCase): - """The app only serves the SPA shell; asset files under /static are - served by Granian and are not reachable through the ASGI transport.""" - - @async_test - async def test_serves_shell_and_spa_fallback(self) -> None: - with tempfile.TemporaryDirectory() as dist: - (Path(dist) / "index.html").write_text("spa") - - patched = dataclasses.replace(settings, static_dir=dist) - with mock.patch("tavolo.static.settings", patched): - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - index = await client.get("/") - self.assertEqual(200, index.status_code) - self.assertEqual("text/html; charset=utf-8", index.headers["content-type"]) - self.assertIn(b"spa", index.content) - - # Unknown client-side route falls back to the app shell. - fallback = await client.get("/game/some-id") - self.assertEqual(200, fallback.status_code) - self.assertIn(b"spa", fallback.content) - - @async_test - async def test_missing_dist_returns_404(self) -> None: - patched = dataclasses.replace(settings, static_dir="/nonexistent-dist") - with mock.patch("tavolo.static.settings", patched): - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - response = await client.get("/") - self.assertEqual(404, response.status_code) - - -if __name__ == "__main__": - unittest.main() diff --git a/server/tests/test_stats.py b/server/tests/test_stats.py deleted file mode 100644 index 7a0e544..0000000 --- a/server/tests/test_stats.py +++ /dev/null @@ -1,140 +0,0 @@ -"""Scopone result-persistence tests: engine result to Postgres to API. - -The platform suite covers the generic machinery against a toy game; -these tests pin the scopone-specific shape: the match ``result`` summary, -per-player scores/teams, Elo deltas and what the history and leaderboard -endpoints expose for a finished scopone match. -""" -from __future__ import annotations - -import unittest - -from httpx import ASGITransport, AsyncClient - -from tavolo.app import app, platform, tortoise_mixin -from tavolo.platform import GameSession, Seat -from tavolo.platform.elo import INITIAL_RATING -from tavolo.platform.models import Match, MatchPlayer, PlayerRating -from tavolo.platform.stats import save_match_result -from tavolo.scopone import engine as rules -from tests.helpers import async_test, oidc_user - -PLAYERS = ("alice", "bob", "carol", "dave") - - -async def _finished_session(target_score: int = 1) -> GameSession: - engine = platform.registry.require("scopone_scientifico") - session = GameSession( - id="stats-scope-1", - game_type=engine.id, - join_code="SS0001", - creator_sub="alice", - players=[Seat(user_sub="alice", display_name="Alice")], - ) - engine.create(session, {"target_score": target_score}) - for name in PLAYERS[1:]: - engine.join(session, name, name.capitalize()) - moves = 0 - while not engine.is_finished(session) and moves < 200000: - state = session.state - if state.phase == "hand_end": - for player in state.players: - engine.handle_action(session, player.sub, "ack", {}) - continue - player = next(p for p in state.players if p.seat == state.turn) - played = player.hand[0] - options = rules.legal_captures(state.table, played) - payload = {"card": played.code} - if options: - payload["capture"] = [c.code for c in options[0]] - engine.handle_action(session, player.sub, "play", payload) - moves += 1 - assert engine.is_finished(session) - return session - - -class ScoponeStatsTest(unittest.TestCase): - @async_test - async def test_finished_match_persisted_with_scopone_summary(self) -> None: - await tortoise_mixin._bind() - ctx = tortoise_mixin._ctx - assert ctx is not None - engine = platform.registry.require("scopone_scientifico") - session = await _finished_session() - with ctx: - await save_match_result(session, engine) - self.assertEqual(1, await Match.all().count()) - self.assertEqual(4, await MatchPlayer.all().count()) - - match = await Match.all().first() - assert match is not None - self.assertEqual("scopone_scientifico", match.game_type) - summary = match.result - self.assertEqual(1, summary["target_score"]) - self.assertIn("team_a_score", summary) - self.assertIn("team_b_score", summary) - self.assertIn("winner_team", summary) - self.assertIn("hands_played", summary) - self.assertIn("hand_scores", summary) - - players = {p.user_sub: p for p in await MatchPlayer.all()} - winner_team = summary["winner_team"] - for sub, row in players.items(): - if row.won: - self.assertEqual(winner_team, row.team) - else: - self.assertNotEqual(winner_team, row.team) - self.assertEqual( - summary["team_a_score"] if row.team == "A" - else summary["team_b_score"], - row.score, - ) - # Winners share a team, losers the other. - winners = {sub for sub, row in players.items() if row.won} - self.assertEqual(2, len(winners)) - - @async_test - async def test_finished_match_updates_elo(self) -> None: - await tortoise_mixin._bind() - ctx = tortoise_mixin._ctx - assert ctx is not None - engine = platform.registry.require("scopone_scientifico") - session = await _finished_session() - with ctx: - await save_match_result(session, engine) - ratings = {r.user_sub: r.rating for r in await PlayerRating.all()} - self.assertEqual(4, len(ratings)) - self.assertEqual( - {INITIAL_RATING + 16, INITIAL_RATING - 16}, set(ratings.values()) - ) - - @async_test - async def test_history_and_leaderboard_expose_scopone_result(self) -> None: - await tortoise_mixin._bind() - ctx = tortoise_mixin._ctx - assert ctx is not None - engine = platform.registry.require("scopone_scientifico") - session = await _finished_session() - with ctx: - await save_match_result(session, engine) - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: - with oidc_user("alice"): - history = await client.get("/api/me/matches") - self.assertEqual(200, history.status_code) - results = history.json()["results"] - self.assertEqual(1, len(results)) - self.assertEqual("scopone_scientifico", results[0]["game_type"]) - self.assertIn("team_a_score", results[0]["result"]) - self.assertIn("your_elo_delta", results[0]) - self.assertEqual(4, len(results[0]["players"])) - - board = await client.get("/api/leaderboard") - self.assertEqual(200, board.status_code) - by_sub = {r["user_sub"]: r for r in board.json()["results"]} - self.assertEqual(4, len(by_sub)) - self.assertTrue(all(r["matches"] == 1 for r in by_sub.values())) - - -if __name__ == "__main__": - unittest.main() diff --git a/server/tests/test_telemetry.py b/server/tests/test_telemetry.py deleted file mode 100644 index 713c26d..0000000 --- a/server/tests/test_telemetry.py +++ /dev/null @@ -1,427 +0,0 @@ -"""Telemetry unit tests: app gauges, process metrics, log export and counts. - -The OpenTelemetry packages are an optional extra (``tavolo-app[otel]``), -so the provider-dependent tests skip when they are not installed. -""" -from __future__ import annotations - -import hashlib -import logging -import os -import sys -import unittest -from types import SimpleNamespace -from typing import Optional -from unittest.mock import patch - -from kaya.session import Session -from tavolo.telemetry import ( - CountingInMemorySessionStore, - CountingRedisSessionStore, - TelemetryMixin, - _NoOtelLogs, - session_tag_hook, - telemetry_from_settings, -) -from tavolo.config import Settings - -from tests.helpers import async_test - -try: - from opentelemetry.sdk._logs import LoggingHandler, LoggerProvider - from opentelemetry.sdk._logs.export import ( - InMemoryLogRecordExporter, - SimpleLogRecordProcessor, - ) - from opentelemetry.sdk.metrics import MeterProvider - from opentelemetry.sdk.metrics.export import InMemoryMetricReader - from opentelemetry.sdk.resources import Resource - from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.sdk.trace.export import SimpleSpanProcessor - from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( - InMemorySpanExporter, - ) - - HAVE_OTEL = True -except ImportError: # pragma: no cover - installs without the otel extra - HAVE_OTEL = False - - -def _settings(env: dict) -> Settings: - with patch.dict(os.environ, env, clear=True): - return Settings.from_env() - - -class _FakeSessions: - def __init__(self, count: int = 0) -> None: - self.value: Optional[int] = count - self.error: Optional[Exception] = None - - async def count(self) -> int: - if self.error is not None: - raise self.error - assert self.value is not None - return self.value - - -class _FakeGames: - def __init__(self, count: int = 0) -> None: - self.value: Optional[int] = count - - async def count_ongoing(self) -> int: - assert self.value is not None - return self.value - - -class _FakeRedis: - """Minimal async Redis double covering the session-count scan.""" - - def __init__(self, keys: list) -> None: - self.keys = keys - - async def scan_iter(self, match: str = "", count: int = 0): - prefix = match.rstrip("*") - for key in self.keys: - if key.startswith(prefix): - yield key - - -class TelemetrySetupTest(unittest.TestCase): - def test_disabled_returns_none(self) -> None: - self.assertIsNone( - telemetry_from_settings( - _settings({}), _FakeSessions(), _FakeGames() - ) - ) - - def test_missing_otel_dependencies_raise(self) -> None: - settings = _settings({"OTEL_ENABLED": "true"}) - missing = "opentelemetry.exporter.otlp.proto.http._log_exporter" - with patch.dict(sys.modules, {missing: None}): - with self.assertRaises(RuntimeError): - telemetry_from_settings(settings, _FakeSessions(), _FakeGames()) - - -class SessionCountTest(unittest.TestCase): - @async_test - async def test_in_memory_counts_live_sessions(self) -> None: - now = [1000.0] - store = CountingInMemorySessionStore(clock=lambda: now[0]) - await store.save("a", Session("a", {"user": "alice"}), max_age=10) - await store.save("b", Session("b", {"user": "bob"}), max_age=10) - await store.save("c", Session("c", {"user": "carol"})) # no expiry - self.assertEqual(3, await store.count()) - - now[0] += 11 # a and b have expired - self.assertEqual(1, await store.count()) - await store.delete("c") - self.assertEqual(0, await store.count()) - - @async_test - async def test_redis_counts_only_session_keys(self) -> None: - store = CountingRedisSessionStore( - _FakeRedis(["kaya:session:a", "kaya:session:b", "tavolo:code:X"]) - ) - self.assertEqual(2, await store.count()) - - -class NoOtelLogsFilterTest(unittest.TestCase): - def test_filters_opentelemetry_records_only(self) -> None: - filter_ = _NoOtelLogs() - self.assertFalse( - filter_.filter(logging.makeLogRecord({"name": "opentelemetry.sdk"})) - ) - self.assertTrue( - filter_.filter(logging.makeLogRecord({"name": "tavolo.app"})) - ) - self.assertTrue( - filter_.filter(logging.makeLogRecord({"name": "granian.server"})) - ) - - -class _RecordedSpan: - def __init__(self) -> None: - self.attributes: dict = {} - - def set_attribute(self, key: str, value) -> None: - self.attributes[key] = value - - -class SessionTagHookTest(unittest.TestCase): - def test_tags_authenticated_session(self) -> None: - span = _RecordedSpan() - carrier = SimpleNamespace( - session=Session("sid", {"oidc:default:user": {"sub": "alice"}}) - ) - session_tag_hook(span, carrier) - self.assertEqual( - hashlib.sha256(b"sid").hexdigest()[:16], - span.attributes["session.id"], - ) - self.assertEqual("alice", span.attributes["user.id"]) - - def test_tags_user_of_any_provider(self) -> None: - span = _RecordedSpan() - carrier = SimpleNamespace( - session=Session("sid", {"oidc:google:user": {"sub": "alice"}}) - ) - session_tag_hook(span, carrier) - self.assertEqual("alice", span.attributes["user.id"]) - - def test_ignores_legacy_unprefixed_user_key(self) -> None: - span = _RecordedSpan() - carrier = SimpleNamespace( - session=Session("sid", {"oidc_user": {"sub": "alice"}}) - ) - session_tag_hook(span, carrier) - self.assertIn("session.id", span.attributes) - self.assertNotIn("user.id", span.attributes) - - def test_tags_session_without_user(self) -> None: - span = _RecordedSpan() - session_tag_hook(span, SimpleNamespace(session=Session("sid", {}))) - self.assertIn("session.id", span.attributes) - self.assertNotIn("user.id", span.attributes) - - def test_tags_user_when_session_id_not_yet_minted(self) -> None: - span = _RecordedSpan() - carrier = SimpleNamespace( - session=Session(None, {"oidc:default:user": {"sub": "alice"}}) - ) - session_tag_hook(span, carrier) - self.assertNotIn("session.id", span.attributes) - self.assertEqual("alice", span.attributes["user.id"]) - - def test_missing_session_is_a_noop(self) -> None: - span = _RecordedSpan() - session_tag_hook(span, SimpleNamespace()) - self.assertEqual({}, span.attributes) - - def test_malformed_user_is_ignored(self) -> None: - span = _RecordedSpan() - carrier = SimpleNamespace(session=Session("sid", {"oidc:default:user": "alice"})) - session_tag_hook(span, carrier) - self.assertIn("session.id", span.attributes) - self.assertNotIn("user.id", span.attributes) - - -@unittest.skipUnless(HAVE_OTEL, "tavolo-app[otel] not installed") -class SessionTagIntegrationTest(unittest.TestCase): - """The hook is wired as kaya-otel's response hook, so it sees the - session after the session mixin persisted and minted it.""" - - @async_test - async def test_spans_carry_hashed_session_and_oidc_sub(self) -> None: - from httpx import ASGITransport, AsyncClient - from kaya.core import HttpContext, KayaApp - from kaya.otel import OTelMixin - from kaya.session import InMemorySessionStore, SessionMixin - - span_exporter = InMemorySpanExporter() - tracer_provider = TracerProvider( - resource=Resource.create({"service.name": "test"}) - ) - tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter)) - app = KayaApp( - mixins=[ - OTelMixin( - service_name="test", - tracer_provider=tracer_provider, - meter_provider=MeterProvider( - metric_readers=[InMemoryMetricReader()] - ), - server_response_hook=session_tag_hook, - websocket_close_hook=session_tag_hook, - ), - SessionMixin(InMemorySessionStore()), - ] - ) - - @app.GET("/login") - async def login(ctx: HttpContext) -> None: - session = ctx.session - assert session is not None - session["oidc:default:user"] = {"sub": "alice"} - await ctx.send_str(200, "ok") - - @app.GET("/me") - async def me(ctx: HttpContext) -> None: - await ctx.send_str(200, "ok") - - transport = ASGITransport(app=app) # type: ignore[arg-type] - async with AsyncClient( - transport=transport, base_url="http://127.0.0.1" - ) as client: - first = await client.get("/login") - second = await client.get("/me") - self.assertEqual(200, first.status_code) - self.assertEqual(200, second.status_code) - # The cookie value is the session id; only its digest is exported. - cookie = first.cookies["session_id"] - digest = hashlib.sha256(cookie.encode()).hexdigest()[:16] - - spans = {span.name: span for span in span_exporter.get_finished_spans()} - self.assertEqual({"GET /login", "GET /me"}, set(spans)) - for name, span in spans.items(): - attributes = span.attributes - assert attributes is not None - self.assertEqual(digest, attributes["session.id"], name) - self.assertEqual("alice", attributes["user.id"], name) - - -def _metric_points(reader) -> dict: - """Map metric name -> list of (attributes, value) data points.""" - data = reader.get_metrics_data() - assert data is not None - points = {} - for resource_metrics in data.resource_metrics: - for scope_metrics in resource_metrics.scope_metrics: - for metric in scope_metrics.metrics: - points[metric.name] = [ - (dict(point.attributes), point.value) - for point in metric.data.data_points - ] - return points - - -@unittest.skipUnless(HAVE_OTEL, "tavolo-app[otel] not installed") -class MetricExportTest(unittest.TestCase): - # The instrumentation packages are process-wide singletons whose - # callbacks cannot be re-registered, so the providers and mixin are - # built once for the whole class (as the application does). - sessions = _FakeSessions(0) - games = _FakeGames(0) - - @classmethod - def setUpClass(cls) -> None: - resource = Resource.create({"service.name": "test"}) - cls.reader = InMemoryMetricReader() - cls.provider = MeterProvider(resource=resource, metric_readers=[cls.reader]) - cls.span_exporter = InMemorySpanExporter() - cls.tracer_provider = TracerProvider(resource=resource) - cls.tracer_provider.add_span_processor( - SimpleSpanProcessor(cls.span_exporter) - ) - cls.mixin = TelemetryMixin( - meter_provider=cls.provider, - tracer_provider=cls.tracer_provider, - logger_provider=None, - log_handler=None, - session_store=cls.sessions, - game_store=cls.games, - poll_interval_seconds=1, - ) - cls.mixin.apply(None) # type: ignore[arg-type] - - @classmethod - def tearDownClass(cls) -> None: - cls.mixin.shutdown(None) # type: ignore[arg-type] - - @async_test - async def test_app_gauges_report_polled_counts(self) -> None: - self.sessions.value = 3 - self.sessions.error = None - self.games.value = 2 - await self.mixin.refresh_counts() - points = _metric_points(self.reader) - self.assertEqual([({}, 3)], points["tavolo.active_users"]) - self.assertEqual([({}, 2)], points["tavolo.ongoing_games"]) - - @async_test - async def test_failed_refresh_keeps_last_value(self) -> None: - self.sessions.value = 5 - self.sessions.error = None - await self.mixin.refresh_counts() - self.sessions.error = RuntimeError("redis down") - await self.mixin.refresh_counts() - points = _metric_points(self.reader) - self.assertEqual([({}, 5)], points["tavolo.active_users"]) - - @async_test - async def test_process_and_gc_metrics_are_exported(self) -> None: - await self.mixin.refresh_counts() - points = _metric_points(self.reader) - for name in ( - "process.cpu.time", - "process.cpu.utilization", - "process.memory.usage", - "process.memory.virtual", - "cpython.gc.collections", - "cpython.gc.collected_objects", - "cpython.gc.uncollectable_objects", - ): - self.assertIn(name, points) - self.assertGreater(points["process.memory.usage"][0][1], 0) - modes = {attrs["type"] for attrs, _ in points["process.cpu.time"]} - self.assertEqual({"user", "system"}, modes) - generations = { - attrs["cpython.gc.generation"] - for attrs, _ in points["cpython.gc.collections"] - } - self.assertEqual({0, 1, 2}, generations) - # Host-level metrics are deliberately not configured. - self.assertNotIn("system.cpu.time", points) - - @async_test - async def test_database_clients_are_instrumented(self) -> None: - import asyncpg.connection - import redis.asyncio - - self.assertTrue(hasattr(redis.asyncio.Redis.execute_command, "__wrapped__")) - self.assertTrue(hasattr(asyncpg.connection.Connection.fetch, "__wrapped__")) - - # Exercise each wrapper through stubs: the downstream call fails (no - # server), but the span is recorded before it does. - client = object.__new__(redis.asyncio.Redis) - client.connection_pool = SimpleNamespace( - connection_kwargs={"host": "127.0.0.1", "port": 6379, "db": 0} - ) - with self.assertRaises(AttributeError): - await client.execute_command("GET", "key") - - connection = object.__new__(asyncpg.connection.Connection) - connection._aborted = True # keep __del__ quiet on the stub - with self.assertRaises(Exception): - await connection.fetch("SELECT 1") - - spans = { - span.name: span for span in self.span_exporter.get_finished_spans() - } - self.assertEqual("GET ?", spans["GET"].attributes["db.statement"]) - self.assertEqual("redis", spans["GET"].attributes["db.system"]) - self.assertEqual("SELECT 1", spans["SELECT"].attributes["db.statement"]) - self.assertEqual("postgresql", spans["SELECT"].attributes["db.system"]) - - -@unittest.skipUnless(HAVE_OTEL, "tavolo-app[otel] not installed") -class LogExportTest(unittest.TestCase): - def test_logs_are_exported_and_otel_records_filtered(self) -> None: - exporter = InMemoryLogRecordExporter() - resource = Resource.create({"service.name": "test"}) - provider = LoggerProvider(resource=resource) - provider.add_log_record_processor(SimpleLogRecordProcessor(exporter)) - handler = LoggingHandler(level=logging.NOTSET, logger_provider=provider) - handler.addFilter(_NoOtelLogs()) - root = logging.getLogger() - previous_level = root.level - root.addHandler(handler) - root.setLevel(logging.DEBUG) - try: - logging.getLogger("tavolo.test").info("exported message") - logging.getLogger("opentelemetry.test").info("filtered message") - bodies = [r.log_record.body for r in exporter.get_finished_logs()] - self.assertIn("exported message", bodies) - self.assertNotIn("filtered message", bodies) - records = exporter.get_finished_logs() - self.assertEqual( - "test", records[0].resource.attributes["service.name"] - ) - finally: - root.removeHandler(handler) - handler.close() - root.setLevel(previous_level) - provider.shutdown() - - -if __name__ == "__main__": - unittest.main() diff --git a/server/tests/test_websocket.py b/server/tests/test_websocket.py deleted file mode 100644 index b904bf3..0000000 --- a/server/tests/test_websocket.py +++ /dev/null @@ -1,324 +0,0 @@ -"""WebSocket live-play tests via kaya's ASGI websocket transport.""" -from __future__ import annotations - -import asyncio -import unittest - -from httpx import ASGITransport, AsyncClient -from httpx_ws import WebSocketDisconnect, aconnect_ws -from httpx_ws.transport import ASGIWebSocketTransport - -from tavolo.app import app, game_store, platform -from tavolo.platform import GameSession, Seat -from tavolo.scopone.state import Card, PlayerState, ScoponeState -from tests.helpers import async_test, make_user, oidc_user, ws_users - -PLAYERS = ("alice", "bob", "carol", "dave") - - -def _started_session( - engine, - game_id: str, - code: str, - hand_ack_timeout: int = 30, - turn_timeout: int = 30, -) -> GameSession: - session = GameSession( - id=game_id, - game_type=engine.id, - join_code=code, - creator_sub="alice", - players=[Seat(user_sub="alice", display_name="Alice")], - ) - engine.create( - session, - { - "target_score": 11, - "napola": True, - }, - ) - # Apply per-test timeouts (the plugin normally copies them from its - # own constructor arguments). - session.state.hand_ack_timeout = hand_ack_timeout - session.state.turn_timeout = turn_timeout - for name in PLAYERS[1:]: - engine.join(session, name, name.capitalize()) - return session - - -class WebSocketTest(unittest.TestCase): - async def _started_game(self, client: AsyncClient) -> dict: - """Create a game and seat four players; return the playing state.""" - with oidc_user("alice"): - created = await client.post("/api/games", json={}) - code = created.json()["join_code"] - response = created - for player in PLAYERS[1:]: - with oidc_user(player): - response = await client.post("/api/games/join", json={"code": code}) - return response.json() - - async def _bob_view(self, client: AsyncClient, game_id: str) -> dict: - with oidc_user("bob"): - return (await client.get(f"/api/games/{game_id}")).json() - - @async_test - async def test_move_updates_all_connections(self) -> None: - api_transport = ASGITransport(app=app) - async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client: - state = await self._started_game(client) - game_id = state["id"] - bob_hand = (await self._bob_view(client, game_id))["players"][1]["hand"] - - ws_transport = ASGIWebSocketTransport(app=app) - async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: - with ws_users([make_user("bob"), make_user("alice")]): - async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws: - first = await bob_ws.receive_json() - self.assertEqual("state", first["type"]) - self.assertEqual(1, first["game"]["turn"]) - self.assertTrue(first["game"].get("your_turn")) - - async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws: - alice_first = await alice_ws.receive_json() - self.assertEqual("state", alice_first["type"]) - self.assertEqual( - "alice", alice_first["game"]["players"][0]["sub"] - ) - self.assertNotIn("hand", alice_first["game"]["players"][1]) - - await bob_ws.send_json( - {"action": "play", "card": bob_hand[0]} - ) - bob_update = await bob_ws.receive_json() - alice_update = await alice_ws.receive_json() - - for update in (bob_update, alice_update): - self.assertEqual("state", update["type"]) - self.assertEqual(2, update["game"]["turn"]) - self.assertEqual(1, len(update["game"]["table"])) - - @async_test - async def test_illegal_move_returns_error(self) -> None: - api_transport = ASGITransport(app=app) - async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client: - state = await self._started_game(client) - game_id = state["id"] - bob_hand = (await self._bob_view(client, game_id))["players"][1]["hand"] - - ws_transport = ASGIWebSocketTransport(app=app) - async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: - with ws_users([make_user("bob")]): - async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws: - await bob_ws.receive_json() - await bob_ws.send_json( - {"action": "play", "card": bob_hand[0]} - ) - await bob_ws.receive_json() # the resulting state - # Bob cannot play twice in a row. - await bob_ws.send_json( - {"action": "play", "card": bob_hand[1]} - ) - error = await bob_ws.receive_json() - self.assertEqual("error", error["type"]) - self.assertEqual("illegal_move", error["code"]) - - @async_test - async def test_unknown_game_is_closed(self) -> None: - ws_transport = ASGIWebSocketTransport(app=app) - async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: - with ws_users([make_user("alice")]): - with self.assertRaises(WebSocketDisconnect) as caught: - async with aconnect_ws("/ws/games/no-such-game", ws_client): - pass - self.assertEqual(404, caught.exception.code) - - @async_test - async def test_non_player_is_closed(self) -> None: - api_transport = ASGITransport(app=app) - async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client: - state = await self._started_game(client) - game_id = state["id"] - - ws_transport = ASGIWebSocketTransport(app=app) - async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: - with ws_users([make_user("mallory")]): - with self.assertRaises(WebSocketDisconnect) as caught: - async with aconnect_ws(f"/ws/games/{game_id}", ws_client): - pass - self.assertEqual(403, caught.exception.code) - - @async_test - async def test_unauthenticated_is_closed(self) -> None: - ws_transport = ASGIWebSocketTransport(app=app) - async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: - with ws_users([]): - with self.assertRaises(WebSocketDisconnect) as caught: - async with aconnect_ws("/ws/games/whatever", ws_client): - pass - self.assertEqual(401, caught.exception.code) - - -async def _seed_last_play_state(hand_ack_timeout: int = 30) -> str: - """Seed a game where a single play ends the hand: p0 holds the only - card left and can capture the only table card.""" - engine = platform.registry.require("scopone_scientifico") - session = GameSession( - id="hand-end-1", - game_type=engine.id, - join_code="HEND01", - creator_sub="alice", - players=[ - Seat(user_sub="alice", display_name="Alice", team="A"), - Seat(user_sub="bob", display_name="Bob", team="B"), - Seat(user_sub="carol", display_name="Carol", team="A"), - Seat(user_sub="dave", display_name="Dave", team="B"), - ], - ) - session.state = ScoponeState( - target_score=11, - phase="playing", - turn=0, - table=[Card.parse("02C")], - players=[ - PlayerState(sub="alice", name="Alice", seat=0, hand=[Card.parse("02D")]), - PlayerState(sub="bob", name="Bob", seat=1), - PlayerState(sub="carol", name="Carol", seat=2), - PlayerState(sub="dave", name="Dave", seat=3), - ], - hand_ack_timeout=hand_ack_timeout, - ) - await game_store.save(session) - return session.id - - -class HandEndWebSocketTest(unittest.TestCase): - @async_test - async def test_hand_end_ack_flow(self) -> None: - import contextlib - - game_id = await _seed_last_play_state() - ws_transport = ASGIWebSocketTransport(app=app) - async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: - async with contextlib.AsyncExitStack() as stack: - with ws_users([make_user(name) for name in PLAYERS]): - sockets = [ - await stack.enter_async_context( - aconnect_ws(f"/ws/games/{game_id}", ws_client) - ) - for _ in PLAYERS - ] - for ws in sockets: - await ws.receive_json() # initial state - - # Alice plays the last card: the hand ends and the game - # pauses for acknowledgements. - await sockets[0].send_json( - {"action": "play", "card": "02D", "capture": ["02C"]} - ) - summaries = [await ws.receive_json() for ws in sockets] - for summary in summaries: - self.assertEqual("state", summary["type"]) - self.assertEqual("hand_end", summary["game"]["phase"]) - self.assertEqual([], summary["game"]["acknowledged"]) - self.assertIsNotNone(summary["game"]["hand_end_deadline"]) - award = summary["game"]["last_hand"]["award"] - self.assertEqual("A", award["carte"]) - self.assertEqual("A", award["denara"]) - - # Everyone acknowledges; the fourth ack deals the next hand. - for i, ws in enumerate(sockets): - await ws.send_json({"action": "ack"}) - updates = [await other.receive_json() for other in sockets] - for update in updates: - if i < 3: - self.assertEqual("hand_end", update["game"]["phase"]) - self.assertEqual( - list(range(i + 1)), - update["game"]["acknowledged"], - ) - else: - self.assertEqual("playing", update["game"]["phase"]) - self.assertEqual(2, update["game"]["hand_number"]) - self.assertEqual( - 10, update["game"]["players"][i]["cards_left"] - ) - - @async_test - async def test_hand_end_timeout_deals_next_hand(self) -> None: - game_id = await _seed_last_play_state(hand_ack_timeout=1) - ws_transport = ASGIWebSocketTransport(app=app) - async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: - with ws_users([make_user("alice")]): - async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws: - await ws.receive_json() # initial state - await ws.send_json( - {"action": "play", "card": "02D", "capture": ["02C"]} - ) - summary = await ws.receive_json() - self.assertEqual("hand_end", summary["game"]["phase"]) - # Nobody acks: the timer must deal the next hand. - update = None - for _ in range(20): - try: - update = await asyncio.wait_for( - ws.receive_json(), timeout=2 - ) - except asyncio.TimeoutError: - break - if ( - update.get("type") == "state" - and update["game"]["phase"] == "playing" - ): - break - self.assertIsNotNone(update) - assert update is not None - self.assertEqual("playing", update["game"]["phase"]) - self.assertEqual(2, update["game"]["hand_number"]) - - -class TurnTimeoutWebSocketTest(unittest.TestCase): - @async_test - async def test_turn_timeout_auto_plays_a_card(self) -> None: - engine = platform.registry.require("scopone_scientifico") - session = _started_session( - engine, "turn-timeout-1", "TT0001", turn_timeout=1 - ) - await game_store.save(session) - - ws_transport = ASGIWebSocketTransport(app=app) - async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: - with ws_users([make_user("alice")]): - async with aconnect_ws(f"/ws/games/{session.id}", ws_client) as ws: - first = await ws.receive_json() - # Bob (seat 1) is first to act and never connects. - self.assertEqual(1, first["game"]["turn"]) - deadline = first["game"]["turn_deadline"] - self.assertIsNotNone(deadline) - - # Nobody plays: the timer must play a random card for Bob. - update = None - for _ in range(20): - try: - update = await asyncio.wait_for( - ws.receive_json(), timeout=2 - ) - except asyncio.TimeoutError: - break - if ( - update.get("type") == "state" - and update["game"]["turn"] == 2 - ): - break - self.assertIsNotNone(update) - assert update is not None - self.assertEqual(2, update["game"]["turn"]) - self.assertEqual(1, update["game"]["last_move"]["seat"]) - self.assertEqual( - 9, update["game"]["players"][1]["cards_left"] - ) - self.assertNotEqual(deadline, update["game"]["turn_deadline"]) - - -if __name__ == "__main__": - unittest.main()