Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57c6ffb032
|
||
|
|
c10d45523a
|
||
|
|
658e3d6ec7
|
||
|
|
5e4e1310b4
|
||
|
|
db7ba30d13
|
||
|
|
f7fa8e78e5
|
+1
-1
@@ -14,6 +14,6 @@
|
|||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
web/target/
|
web/target/
|
||||||
web/dist/
|
web/dist/
|
||||||
server/target/
|
server/tests/
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
opencode.json
|
opencode.json
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ jobs:
|
|||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
builder: multiplatform-builder
|
|
||||||
file: server/Dockerfile
|
file: server/Dockerfile
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
push: true
|
push: true
|
||||||
|
|||||||
@@ -3,17 +3,11 @@
|
|||||||
A platform for multiplayer card games. The first game is **scopone
|
A platform for multiplayer card games. The first game is **scopone
|
||||||
scientifico** — the four-player, fixed-partnership Italian card game:
|
scientifico** — the four-player, fixed-partnership Italian card game:
|
||||||
|
|
||||||
- **`server/`** — backend: Rust + [Axum](https://github.com/tokio-rs/axum),
|
- **`server/`** — backend: Python + [kaya](https://github.com/woggioni/kaya)
|
||||||
OIDC login, live games in Redis, match statistics in Postgres. A Cargo
|
framework, OIDC login, live games in Redis, match statistics in Postgres.
|
||||||
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).
|
See [server/README.md](server/README.md).
|
||||||
- **`web/`** — frontend: Rust + [Sycamore](https://sycamore.dev) compiled to
|
- **`web/`** — frontend: Rust + [Sycamore](https://sycamore.dev) compiled to
|
||||||
WebAssembly, built with [Trunk](https://trunkrs.dev) and styled with
|
WebAssembly, built with [Trunk](https://trunkrs.dev). Card images are the
|
||||||
[Bootstrap](https://getbootstrap.com) 5.3 (MIT, vendored under
|
|
||||||
`web/vendor/` so builds work offline) plus the custom felt theme in
|
|
||||||
`web/theme.css`. Card images are the
|
|
||||||
CC0 *woodcut napoletane* deck traced from a 1902 Naples print
|
CC0 *woodcut napoletane* deck traced from a 1902 Naples print
|
||||||
([SONDLecT/woodcut-napoletane](https://github.com/SONDLecT/woodcut-napoletane)).
|
([SONDLecT/woodcut-napoletane](https://github.com/SONDLecT/woodcut-napoletane)).
|
||||||
|
|
||||||
@@ -38,8 +32,7 @@ echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
|
|||||||
|
|
||||||
When a hand ends but the match is not decided, the game pauses on a
|
When a hand ends but the match is not decided, the game pauses on a
|
||||||
**scoring summary screen**: every player sees how each category was won
|
**scoring summary screen**: every player sees how each category was won
|
||||||
(carte, denara, settebello, primiera, scope — plus napola and asso
|
(carte, denara, settebello, primiera, scope — plus napola when enabled)
|
||||||
pigliatutto when enabled)
|
|
||||||
with the running totals and
|
with the running totals and
|
||||||
must click "Understood" before the next hand is dealt. If someone is away
|
must click "Understood" before the next hand is dealt. If someone is away
|
||||||
the next hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS`
|
the next hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS`
|
||||||
@@ -58,16 +51,13 @@ Backend (from `server/`):
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd server
|
cd server
|
||||||
cargo test --workspace # unit + integration suites
|
python3 -m venv .venv
|
||||||
cargo run -p tavolo-app # serves on :8080 (Postgres required)
|
.venv/bin/pip install -r requirements.txt
|
||||||
cargo run -p tavolo-app -- migrate # apply the embedded sqlx migrations
|
.venv/bin/pip install -e '.[dev]'
|
||||||
|
.venv/bin/python -m unittest discover -s tests -t .
|
||||||
|
.venv/bin/mypy src
|
||||||
```
|
```
|
||||||
|
|
||||||
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:
|
Frontend (from `web/`), with the backend running on :8080:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -80,13 +70,9 @@ Trunk proxies `/api`, `/auth` and `/ws` to `127.0.0.1:8080` (see
|
|||||||
run the backend with:
|
run the backend with:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
OIDC_0_PROVIDER=default \
|
OIDC_POST_LOGIN_REDIRECT=http://localhost:8000/ \
|
||||||
OIDC_0_ISSUER=http://localhost:8180/tavolo \
|
OIDC_POST_LOGOUT_REDIRECT=http://localhost:8000/ \
|
||||||
OIDC_0_CLIENT_ID=tavolo \
|
.venv/bin/granian --host 127.0.0.1 --port 8080 tavolo.app:app
|
||||||
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`
|
Card images are committed under `web/assets/cards/`; `web/fetch-cards.sh`
|
||||||
|
|||||||
+17
-45
@@ -13,16 +13,15 @@
|
|||||||
#
|
#
|
||||||
# Reachability notes:
|
# Reachability notes:
|
||||||
# - The OIDC issuer URL must resolve from the browser AND from the pods
|
# - The OIDC issuer URL must resolve from the browser AND from the pods
|
||||||
# (the server discovers the provider lazily on the first login, so
|
# (kaya does discovery lazily on the first login, so startup succeeds
|
||||||
# startup succeeds even when the issuer is unreachable, but logins then
|
# even when the issuer is unreachable, but logins then fail). If the
|
||||||
# fail). If the provider is internal-only, you need split DNS or a public
|
# provider is internal-only, you need split DNS or a public issuer URL.
|
||||||
# issuer URL.
|
|
||||||
# - The Postgres NetworkPolicy (if any) must allow ingress from the
|
# - The Postgres NetworkPolicy (if any) must allow ingress from the
|
||||||
# tavolo namespace.
|
# tavolo namespace.
|
||||||
# - This file deploys a ClusterIP Service only. Until you add an Ingress,
|
# - This file deploys a ClusterIP Service only. Until you add an Ingress,
|
||||||
# reach the app with:
|
# reach the app with:
|
||||||
# kubectl port-forward -n tavolo svc/tavolo 8080:80
|
# kubectl port-forward -n tavolo svc/tavolo 8080:80
|
||||||
# and set OIDC_0_REDIRECT_URI to match whatever URL the browser uses.
|
# and set OIDC_REDIRECT_URI to match whatever URL the browser uses.
|
||||||
|
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
@@ -62,43 +61,15 @@ data:
|
|||||||
GAME_TTL_SECONDS: "86400"
|
GAME_TTL_SECONDS: "86400"
|
||||||
HAND_ACK_TIMEOUT_SECONDS: "30"
|
HAND_ACK_TIMEOUT_SECONDS: "30"
|
||||||
TURN_TIMEOUT_SECONDS: "30"
|
TURN_TIMEOUT_SECONDS: "30"
|
||||||
# CORS (Starlette-compatible middleware). Disabled unless
|
# OIDC (provider lives in another namespace).
|
||||||
# CORS_ALLOW_ORIGINS or
|
OIDC_CLIENT_ID: tavolo
|
||||||
# CORS_ALLOW_ORIGIN_REGEX is set — unneeded when the SPA and the API are
|
OIDC_POST_LOGIN_REDIRECT: /
|
||||||
# served from the same origin. See server/.env.example for details.
|
OIDC_POST_LOGOUT_REDIRECT: /
|
||||||
# CORS_ALLOW_ORIGINS: "https://example.com,https://app.example.com" # or "*"
|
|
||||||
# CORS_ALLOW_ORIGIN_REGEX: 'https://tavolo-[a-z0-9-]+\.vercel\.app'
|
|
||||||
# CORS_ALLOW_METHODS: "GET,POST" # default: GET; "*" = all
|
|
||||||
# CORS_ALLOW_HEADERS: "Authorization,Content-Type" # "*" mirrors the request
|
|
||||||
# CORS_ALLOW_CREDENTIALS: "false"
|
|
||||||
# CORS_EXPOSE_HEADERS: ""
|
|
||||||
# CORS_MAX_AGE: "600"
|
|
||||||
# OpenTelemetry (compiled into the image): traces (HTTP/WebSocket),
|
|
||||||
# process + tavolo metrics and logs via OTLP/HTTP, disabled unless
|
|
||||||
# OTEL_ENABLED is set.
|
|
||||||
# OTEL_ENABLED: "true"
|
|
||||||
# OTEL_SERVICE_NAME: "tavolo"
|
|
||||||
# OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector.observability:4318"
|
|
||||||
# OTEL_EXPORTER_OTLP_HEADERS: "Authorization=Bearer ..."
|
|
||||||
# OTEL_EXCLUDED_PATHS: "/api/health" # default; paths skipped by tracing
|
|
||||||
# OTEL_METRIC_EXPORT_INTERVAL_MILLIS: "60000"
|
|
||||||
# OTEL_METRICS_POLL_INTERVAL_SECONDS: "15"
|
|
||||||
# OIDC (the provider lives in another namespace). Each provider is an
|
|
||||||
# indexed block: OIDC_<n>_PROVIDER names it in the login URL
|
|
||||||
# (/auth/<provider>/login) and indices must be contiguous from 0. Add
|
|
||||||
# OIDC_1_*, OIDC_2_*, ... blocks for more providers; add their secrets
|
|
||||||
# to tavolo-secrets as well.
|
|
||||||
OIDC_0_PROVIDER: default
|
|
||||||
OIDC_0_CLIENT_ID: tavolo
|
|
||||||
OIDC_0_POST_LOGIN_REDIRECT: /
|
|
||||||
OIDC_0_POST_LOGOUT_REDIRECT: /
|
|
||||||
# Issuer URL as seen by the browser AND the pods (see notes above).
|
# Issuer URL as seen by the browser AND the pods (see notes above).
|
||||||
OIDC_0_ISSUER: REPLACE_ME
|
OIDC_ISSUER: REPLACE_ME
|
||||||
# Public callback URL of this deployment, e.g.
|
# Public callback URL of this deployment, e.g.
|
||||||
# http://<host>:<port>/auth/default/callback (must be allowed at the
|
# http://<host>:<port>/auth/callback (must be allowed at the provider).
|
||||||
# provider). When unset it defaults to
|
OIDC_REDIRECT_URI: REPLACE_ME
|
||||||
# http://localhost:8080/auth/<provider>/callback.
|
|
||||||
OIDC_0_REDIRECT_URI: REPLACE_ME
|
|
||||||
|
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
@@ -115,8 +86,8 @@ stringData:
|
|||||||
# The only Postgres secret: the password for DATABASE_USER at
|
# The only Postgres secret: the password for DATABASE_USER at
|
||||||
# DATABASE_HOST (both configured in the tavolo-config ConfigMap).
|
# DATABASE_HOST (both configured in the tavolo-config ConfigMap).
|
||||||
DATABASE_PASSWORD: REPLACE_ME
|
DATABASE_PASSWORD: REPLACE_ME
|
||||||
# Client secret for OIDC_0_CLIENT_ID at the provider.
|
# Client secret for OIDC_CLIENT_ID at the provider.
|
||||||
OIDC_0_CLIENT_SECRET: REPLACE_ME
|
OIDC_CLIENT_SECRET: REPLACE_ME
|
||||||
|
|
||||||
---
|
---
|
||||||
# Redis: sessions + live game state. Ephemeral by design (emptyDir): worst
|
# Redis: sessions + live game state. Ephemeral by design (emptyDir): worst
|
||||||
@@ -202,7 +173,7 @@ spec:
|
|||||||
|
|
||||||
---
|
---
|
||||||
# The app (backend + compiled SPA + WebSocket endpoint, one image).
|
# The app (backend + compiled SPA + WebSocket endpoint, one image).
|
||||||
# The "migrate" initContainer runs `tavolo migrate` before the app starts,
|
# The "migrate" initContainer runs `aerich upgrade` before the app starts,
|
||||||
# so this single file can be applied safely in any order. Migrations are
|
# 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
|
# idempotent, but if you ever scale above 1 replica, move this to a Job to
|
||||||
# avoid concurrent upgrades.
|
# avoid concurrent upgrades.
|
||||||
@@ -235,8 +206,9 @@ spec:
|
|||||||
initContainers:
|
initContainers:
|
||||||
- name: migrate
|
- name: migrate
|
||||||
image: gitea.woggioni.net/woggioni/tavolo:latest
|
image: gitea.woggioni.net/woggioni/tavolo:latest
|
||||||
# sqlx migrations are embedded in the binary.
|
# aerich reads [tool.aerich] from pyproject.toml in /app.
|
||||||
command: ["tavolo", "migrate"]
|
command: ["aerich", "upgrade"]
|
||||||
|
workingDir: /app
|
||||||
envFrom:
|
envFrom:
|
||||||
# Migrations need the non-secret DATABASE_* parts too.
|
# Migrations need the non-secret DATABASE_* parts too.
|
||||||
- configMapRef:
|
- configMapRef:
|
||||||
|
|||||||
+12
-29
@@ -61,14 +61,16 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
# One-shot: applies pending sqlx migrations before the app starts (the
|
# One-shot: applies pending aerich migrations before the app starts
|
||||||
# migrations are embedded in the image; build cache makes the second build
|
# (build cache makes the second build of the same Dockerfile instant).
|
||||||
# of the same Dockerfile instant).
|
|
||||||
db-migrate:
|
db-migrate:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: server/Dockerfile
|
dockerfile: server/Dockerfile
|
||||||
command: ["tavolo", "migrate"]
|
# 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"]
|
||||||
environment:
|
environment:
|
||||||
DATABASE_ENGINE: postgres
|
DATABASE_ENGINE: postgres
|
||||||
DATABASE_HOST: postgres
|
DATABASE_HOST: postgres
|
||||||
@@ -101,35 +103,16 @@ services:
|
|||||||
DATABASE_USER: tavolo
|
DATABASE_USER: tavolo
|
||||||
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-password}
|
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-password}
|
||||||
# By default the app and browsers reach the mock IdP under the same
|
# By default the app and browsers reach the mock IdP under the same
|
||||||
# name (see README /etc/hosts note); override the OIDC_0_* values to
|
# name (see README /etc/hosts note); override OIDC_ISSUER and
|
||||||
# use a real provider or a different host port. Additional providers
|
# OIDC_REDIRECT_URI to use a real provider or a different host port.
|
||||||
# take OIDC_1_*, OIDC_2_*, ... blocks (indices are contiguous from 0).
|
OIDC_ISSUER: ${OIDC_ISSUER:-http://mockoauth:8180/tavolo}
|
||||||
OIDC_0_PROVIDER: ${OIDC_0_PROVIDER:-default}
|
|
||||||
OIDC_0_ISSUER: ${OIDC_0_ISSUER:-http://mockoauth:8180/tavolo}
|
|
||||||
# The mock OIDC server does not validate clients: any id/secret works.
|
# The mock OIDC server does not validate clients: any id/secret works.
|
||||||
OIDC_0_CLIENT_ID: tavolo
|
OIDC_CLIENT_ID: tavolo
|
||||||
OIDC_0_CLIENT_SECRET: ${OIDC_0_CLIENT_SECRET:-dev-secret}
|
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-dev-secret}
|
||||||
OIDC_0_REDIRECT_URI: ${OIDC_0_REDIRECT_URI:-http://localhost:8080/auth/default/callback}
|
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:8080/auth/callback}
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30}
|
HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30}
|
||||||
TURN_TIMEOUT_SECONDS: ${TURN_TIMEOUT_SECONDS:-30}
|
TURN_TIMEOUT_SECONDS: ${TURN_TIMEOUT_SECONDS:-30}
|
||||||
# CORS is disabled unless CORS_ALLOW_ORIGINS or CORS_ALLOW_ORIGIN_REGEX
|
|
||||||
# is set (see server/.env.example for the full list of options).
|
|
||||||
CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-}
|
|
||||||
CORS_ALLOW_ORIGIN_REGEX: ${CORS_ALLOW_ORIGIN_REGEX:-}
|
|
||||||
CORS_ALLOW_METHODS: ${CORS_ALLOW_METHODS:-}
|
|
||||||
CORS_ALLOW_HEADERS: ${CORS_ALLOW_HEADERS:-}
|
|
||||||
CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-}
|
|
||||||
CORS_EXPOSE_HEADERS: ${CORS_EXPOSE_HEADERS:-}
|
|
||||||
CORS_MAX_AGE: ${CORS_MAX_AGE:-}
|
|
||||||
# OpenTelemetry (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:-}
|
|
||||||
OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-}
|
|
||||||
OTEL_EXCLUDED_PATHS: ${OTEL_EXCLUDED_PATHS:-}
|
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${APP_PORT:-8080}:8080"
|
- "127.0.0.1:${APP_PORT:-8080}:8080"
|
||||||
|
|
||||||
|
|||||||
+10
-67
@@ -9,33 +9,16 @@ DATABASE_NAME=tavolo
|
|||||||
DATABASE_USER=tavolo
|
DATABASE_USER=tavolo
|
||||||
DATABASE_PASSWORD=password
|
DATABASE_PASSWORD=password
|
||||||
DATABASE_OPTIONS=
|
DATABASE_OPTIONS=
|
||||||
# Full-DSN override: when set, the parts above are ignored (managed-DB
|
# Full-DSN override: when set, the parts above are ignored. Used by the
|
||||||
# URLs). The Rust server supports Postgres only.
|
# test suite (sqlite://:memory:) and handy for managed-DB URLs.
|
||||||
#DATABASE_URL=postgres://tavolo:password@localhost:5432/tavolo
|
#DATABASE_URL=postgres://tavolo:password@localhost:5432/tavolo
|
||||||
|
|
||||||
# OIDC providers. Each provider is an indexed block: OIDC_<n>_PROVIDER
|
# OIDC (mock-oauth2-server in dev; it does not validate clients, so any
|
||||||
# (required; names the provider in the login URL /auth/<provider>/login and
|
# client id/secret works. For a real IdP like Keycloak, use its values here.)
|
||||||
# in its session state), OIDC_<n>_ISSUER, OIDC_<n>_CLIENT_ID,
|
OIDC_ISSUER=http://localhost:8180/tavolo
|
||||||
# OIDC_<n>_CLIENT_SECRET (optional: leave unset for public clients),
|
OIDC_CLIENT_ID=tavolo
|
||||||
# OIDC_<n>_REDIRECT_URI (defaults to
|
OIDC_CLIENT_SECRET=dev-secret
|
||||||
# http://localhost:8080/auth/<provider>/callback), OIDC_<n>_POST_LOGIN_REDIRECT
|
OIDC_REDIRECT_URI=http://localhost:8080/auth/callback
|
||||||
# and OIDC_<n>_POST_LOGOUT_REDIRECT (default /). Indices must be contiguous
|
|
||||||
# from 0; add a second provider with an OIDC_1_* block (and so on).
|
|
||||||
#
|
|
||||||
# The values below target the dev mock-oauth2-server (it does not validate
|
|
||||||
# clients, so any client id/secret works). For a real IdP like Keycloak,
|
|
||||||
# use its values and register the redirect URI there.
|
|
||||||
OIDC_0_PROVIDER=default
|
|
||||||
OIDC_0_ISSUER=http://localhost:8180/tavolo
|
|
||||||
OIDC_0_CLIENT_ID=tavolo
|
|
||||||
OIDC_0_CLIENT_SECRET=dev-secret
|
|
||||||
OIDC_0_REDIRECT_URI=http://localhost:8080/auth/default/callback
|
|
||||||
# A second provider, e.g. Google:
|
|
||||||
#OIDC_1_PROVIDER=google
|
|
||||||
#OIDC_1_ISSUER=https://accounts.google.com
|
|
||||||
#OIDC_1_CLIENT_ID=...
|
|
||||||
#OIDC_1_CLIENT_SECRET=...
|
|
||||||
#OIDC_1_REDIRECT_URI=http://localhost:8080/auth/google/callback
|
|
||||||
|
|
||||||
# Redis (session and live-game storage). Unset to fall back to in-memory
|
# Redis (session and live-game storage). Unset to fall back to in-memory
|
||||||
# stores (only sensible for local development with a single worker).
|
# stores (only sensible for local development with a single worker).
|
||||||
@@ -52,49 +35,9 @@ HAND_ACK_TIMEOUT_SECONDS=30
|
|||||||
# for them (covers disconnects and idle players).
|
# for them (covers disconnects and idle players).
|
||||||
TURN_TIMEOUT_SECONDS=30
|
TURN_TIMEOUT_SECONDS=30
|
||||||
|
|
||||||
# Accepted for compatibility with the Python server but ignored: the Rust
|
# Path to a YAML logging configuration file (logging.config.dictConfig
|
||||||
# server configures logging through RUST_LOG (tracing env filter syntax).
|
# schema). Unset logs DEBUG to the console.
|
||||||
#LOGGING_CONFIG=/path/to/logging.yaml
|
#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
|
|
||||||
# CORS_ALLOW_ORIGIN_REGEX is set — the app serves the SPA and the API from
|
|
||||||
# the same origin, so no CORS headers are needed by default.
|
|
||||||
# Comma-separated list of origins allowed to make cross-origin requests,
|
|
||||||
# or "*" for any origin:
|
|
||||||
#CORS_ALLOW_ORIGINS=https://example.com,https://app.example.com
|
|
||||||
# Optional regex (fullmatch) allowed origins are additionally checked
|
|
||||||
# against — handy for dynamic preview URLs:
|
|
||||||
#CORS_ALLOW_ORIGIN_REGEX=https://tavolo-[a-z0-9-]+\.vercel\.app
|
|
||||||
# Comma-separated allowed methods, or "*" for all (default GET):
|
|
||||||
#CORS_ALLOW_METHODS=GET,POST
|
|
||||||
# Comma-separated allowed request headers, or "*" to mirror back whatever
|
|
||||||
# the browser requests (default: only the CORS-safelisted headers):
|
|
||||||
#CORS_ALLOW_HEADERS=Authorization,Content-Type
|
|
||||||
# Allow cookies/credentials on cross-origin requests (1/true/yes/on):
|
|
||||||
#CORS_ALLOW_CREDENTIALS=false
|
|
||||||
# Comma-separated response headers exposed to the browser:
|
|
||||||
#CORS_EXPOSE_HEADERS=
|
|
||||||
# Seconds browsers may cache the preflight response (default 600):
|
|
||||||
#CORS_MAX_AGE=600
|
|
||||||
|
|
||||||
# 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 http://localhost:4318 when unset and must use
|
|
||||||
# http:// (the OTLP client is built without a TLS backend; terminate TLS in
|
|
||||||
# front of a remote collector).
|
|
||||||
#OTEL_ENABLED=true
|
|
||||||
#OTEL_SERVICE_NAME=tavolo
|
|
||||||
#OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
|
|
||||||
#OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer token,X-Tenant=one
|
|
||||||
# Paths excluded from tracing and metrics (default /api/health).
|
|
||||||
#OTEL_EXCLUDED_PATHS=/api/health
|
|
||||||
# Metric export interval (ms, default 60000) and session/game count poll
|
|
||||||
# interval (seconds, default 15).
|
|
||||||
#OTEL_METRIC_EXPORT_INTERVAL_MILLIS=60000
|
|
||||||
#OTEL_METRICS_POLL_INTERVAL_SECONDS=15
|
|
||||||
|
|
||||||
# App server
|
# App server
|
||||||
APP_HOST=0.0.0.0
|
APP_HOST=0.0.0.0
|
||||||
|
|||||||
Generated
-3534
File diff suppressed because it is too large
Load Diff
@@ -1,46 +0,0 @@
|
|||||||
[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
|
|
||||||
strip = "symbols"
|
|
||||||
+60
-54
@@ -1,37 +1,24 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
# Multi-stage build for the tavolo stack (Rust/Axum backend +
|
# Multi-stage build for the tavolo stack (kaya backend + Sycamore/WASM
|
||||||
# Sycamore/WASM frontend). The Docker build context is the REPOSITORY ROOT
|
# frontend). The Docker build context is the REPOSITORY ROOT (see
|
||||||
# (see docker-compose.yml) so this single image assembles both parts:
|
# docker-compose.yml) so this single image assembles both parts:
|
||||||
#
|
#
|
||||||
# web-builder rust + trunk -> compiles web/ into web/dist
|
# web-builder rust + trunk -> compiles web/ into web/dist
|
||||||
# rust-builder rust (alpine) -> compiles server/ into a static musl binary
|
# builder alpine python -> python venv with the backend + deps
|
||||||
# runtime scratch -> static binary + compiled frontend
|
# runtime alpine python + the venv + the compiled frontend
|
||||||
#
|
#
|
||||||
# The runtime is a scratch image: the backend is a fully static musl binary,
|
# apk and pip both use BuildKit cache mounts (type=cache): the package
|
||||||
# embeds its sqlx migrations and serves /static, /assets and the SPA shell
|
# caches persist in the builder's cache across builds instead of being
|
||||||
# itself, so no shell, package manager or libc is needed.
|
# re-downloaded, and never land in the image layers.
|
||||||
#
|
|
||||||
# TLS trust model: the OIDC client (reqwest 0.12) and Postgres (sqlx) validate
|
|
||||||
# against Mozilla's roots embedded in the binary by the webpki-roots crate, so
|
|
||||||
# no certificate files exist in the image. The OTLP exporter is built without
|
|
||||||
# a TLS backend, so OTEL_EXPORTER_OTLP_ENDPOINT must be http:// (terminate TLS
|
|
||||||
# in front of the collector if needed).
|
|
||||||
#
|
|
||||||
# There is deliberately no HEALTHCHECK (no curl in scratch): Kubernetes
|
|
||||||
# probes /api/health over HTTP, and Compose gates on its dependency checks.
|
|
||||||
#
|
|
||||||
# Build and run it with (from the repository root):
|
|
||||||
# docker compose up --build
|
|
||||||
|
|
||||||
# --- Web builder -------------------------------------------------------------
|
# --- Web builder -------------------------------------------------------------
|
||||||
FROM rust:1-slim-bookworm AS web-builder
|
FROM rust:1-slim AS web-builder
|
||||||
|
|
||||||
ARG TRUNK_VERSION=0.21.14
|
ARG TRUNK_VERSION=0.21.14
|
||||||
# The build environment blocks plain HTTP: force the apt mirrors to HTTPS.
|
# The build environment blocks plain HTTP: force the apt mirrors to HTTPS.
|
||||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||||
--mount=type=cache,target=/var/lib/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 \
|
||||||
&& { 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 update \
|
||||||
&& apt-get install -y --no-install-recommends curl ca-certificates \
|
&& 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" \
|
&& curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
|
||||||
@@ -42,53 +29,72 @@ WORKDIR /web
|
|||||||
|
|
||||||
# Card images are committed in the repository (CC0 woodcut napoletane deck);
|
# Card images are committed in the repository (CC0 woodcut napoletane deck);
|
||||||
# web/fetch-cards.sh can regenerate them.
|
# web/fetch-cards.sh can regenerate them.
|
||||||
COPY web/Cargo.toml web/Cargo.lock web/index.html web/style.css web/theme.css web/Trunk.toml ./
|
COPY web/Cargo.toml web/Cargo.lock web/index.html web/style.css web/Trunk.toml ./
|
||||||
COPY web/assets ./assets
|
COPY web/assets ./assets
|
||||||
# Vendored Bootstrap (MIT) loaded by index.html; kept out of the CDN so the
|
|
||||||
# image builds and runs without network access.
|
|
||||||
COPY web/vendor ./vendor
|
|
||||||
COPY web/src ./src
|
COPY web/src ./src
|
||||||
|
|
||||||
# --public-url makes trunk emit asset URLs under /static, the prefix the
|
# --public-url makes trunk emit asset URLs under /static, the prefix Granian
|
||||||
# server mounts in the runtime image. Dev builds (trunk serve) keep the
|
# serves in the runtime image (see GRANIAN_STATIC_PATH_* below). Dev builds
|
||||||
# default "/" public URL.
|
# (trunk serve) keep the default "/" public URL.
|
||||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,target=/web/target \
|
--mount=type=cache,target=/web/target \
|
||||||
trunk build --release --public-url /static/
|
trunk build --release --public-url /static/
|
||||||
|
|
||||||
# --- Rust builder (static musl) ----------------------------------------------
|
# --- Python builder ----------------------------------------------------------
|
||||||
FROM rust:1-alpine AS rust-builder
|
FROM alpine:3.24 AS builder
|
||||||
|
|
||||||
# ring (the only C dependency) compiles with the musl toolchain from build-base.
|
RUN --mount=type=cache,target=/var/cache/apk \
|
||||||
RUN apk add --no-cache build-base
|
apk add python3 py3-pip build-base python3-dev
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
COPY server/Cargo.toml server/Cargo.lock ./
|
COPY server/pyproject.toml server/README.md server/requirements.txt ./
|
||||||
COPY server/crates ./crates
|
COPY server/src/ ./src/
|
||||||
|
# aerich migration files are a release artifact: the db-migrate compose
|
||||||
|
# service runs `aerich upgrade` from this image before the app starts.
|
||||||
|
COPY server/migrations/ ./migrations/
|
||||||
|
|
||||||
# The target directory lives in a cache mount (so repeated builds reuse it)
|
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||||
# and the binary is copied out of the mount into the image layer.
|
python3 -m venv /opt/venv \
|
||||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
&& /opt/venv/bin/pip install --upgrade pip \
|
||||||
--mount=type=cache,target=/build/target \
|
&& /opt/venv/bin/pip install -r requirements.txt .
|
||||||
cargo build --release --locked --target x86_64-unknown-linux-musl \
|
|
||||||
&& cp target/x86_64-unknown-linux-musl/release/tavolo /tavolo
|
|
||||||
|
|
||||||
# --- Runtime (scratch) --------------------------------------------------------
|
# --- Runtime ---------------------------------------------------------------
|
||||||
FROM scratch
|
FROM alpine:3.24
|
||||||
|
|
||||||
# Fully static musl binary; no libc, shell or package manager in the image.
|
RUN --mount=type=cache,target=/var/cache/apk \
|
||||||
# Installed on PATH so `tavolo migrate` / `tavolo serve` work as commands.
|
apk add python3 ca-certificates tzdata \
|
||||||
COPY --from=rust-builder --chmod=755 /tavolo /usr/local/bin/tavolo
|
&& addgroup -S app && adduser -S -G app app
|
||||||
# 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=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=web-builder /web/dist /app/web/dist
|
COPY --from=web-builder /web/dist /app/web/dist
|
||||||
|
|
||||||
ENV STATIC_DIR=/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
|
||||||
|
|
||||||
# Numeric user: scratch has no /etc/passwd and Kubernetes verifies runAsNonRoot.
|
USER app
|
||||||
USER 10001:10001
|
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
CMD ["tavolo", "serve"]
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
|
||||||
|
CMD wget -q -O- http://127.0.0.1:8080/api/health || exit 1
|
||||||
|
|
||||||
|
CMD ["granian", "tavolo.app:app"]
|
||||||
|
|||||||
+146
-314
@@ -1,25 +1,14 @@
|
|||||||
# tavolo (backend)
|
# tavolo
|
||||||
|
|
||||||
The backend for a multiplayer card-game platform, written in Rust with
|
The backend for a multiplayer card-game platform, built on the
|
||||||
[Axum](https://github.com/tokio-rs/axum). It is a Cargo workspace of three
|
[kaya](../kaya) framework. The first game implemented is **scopone
|
||||||
crates mirroring the original Python layout:
|
scientifico**, the four-player, fixed-partnership variant of the classic
|
||||||
|
Italian card game.
|
||||||
- **`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
|
Players authenticate with the configured **OIDC** provider. Live game state
|
||||||
is kept in **Redis** (with real-time play over WebSocket), and completed
|
is kept in **Redis** (with real-time play over WebSocket), and completed
|
||||||
matches — every participant and the game-specific result — are persisted to
|
matches — every participant and the final score — are persisted to
|
||||||
**Postgres** for history and leaderboard statistics. The HTTP/WebSocket
|
**Postgres** for history and leaderboard statistics.
|
||||||
contract is byte-compatible with the SPA in `../web/`.
|
|
||||||
|
|
||||||
## Quick start (Docker Compose)
|
## Quick start (Docker Compose)
|
||||||
|
|
||||||
@@ -30,10 +19,10 @@ docker compose up --build
|
|||||||
|
|
||||||
The stack starts Postgres, Redis, a mock OIDC provider
|
The stack starts Postgres, Redis, a mock OIDC provider
|
||||||
(navikt/mock-oauth2-server) with four ready-made players
|
(navikt/mock-oauth2-server) with four ready-made players
|
||||||
(`alice`, `bob`, `carol`, `dave`), a one-shot database migration service
|
(`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
|
and the app itself on `http://127.0.0.1:8080`. The same container also
|
||||||
container also serves the Sycamore/WASM frontend (built from `../web/` by the
|
serves the Sycamore/WASM frontend (built from `../web/` by the Docker
|
||||||
Docker image), so the UI is available at that address.
|
image), so the UI is available at that address.
|
||||||
|
|
||||||
Because the browser and the app both talk to the OIDC issuer at
|
Because the browser and the app both talk to the OIDC issuer at
|
||||||
`http://mockoauth:8180/tavolo`, add a host entry once:
|
`http://mockoauth:8180/tavolo`, add a host entry once:
|
||||||
@@ -42,205 +31,106 @@ Because the browser and the app both talk to the OIDC issuer at
|
|||||||
echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
|
echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
|
||||||
```
|
```
|
||||||
|
|
||||||
Then log in: `GET http://localhost:8080/auth/login` (a provider-agnostic
|
Then log in: `GET http://localhost:8080/auth/login`, type any username
|
||||||
alias that redirects to the first configured provider), type any username
|
|
||||||
(e.g. `alice`) in the interactive login, and you are redirected back with a
|
(e.g. `alice`) in the interactive login, and you are redirected back with a
|
||||||
session cookie. That cookie authenticates both the REST API and the
|
session cookie. That cookie authenticates both the REST API and the
|
||||||
WebSocket endpoint.
|
WebSocket endpoint.
|
||||||
|
|
||||||
OpenAPI documentation is served at `/api/docs` (`/api/openapi.json`).
|
OpenAPI documentation is served at `/api/docs` (`/api/openapi.json`).
|
||||||
|
|
||||||
The release image is a **scratch image** containing a fully static musl
|
|
||||||
binary and the compiled frontend: no shell, package manager or libc. It runs
|
|
||||||
as the numeric user `10001:10001` and has no Docker `HEALTHCHECK` (scratch has
|
|
||||||
no `curl`); Kubernetes probes `/api/health` over HTTP, and Compose gates on
|
|
||||||
the Postgres/Redis/OIDC dependency checks. To debug a running container use
|
|
||||||
`docker debug` (Docker Desktop) or `kubectl debug` with an ephemeral
|
|
||||||
container.
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
All configuration comes from environment variables (see `.env.example`):
|
All configuration comes from environment variables (see `.env.example`):
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `DATABASE_ENGINE` | `postgres` | Database DSN scheme/driver (Postgres only) |
|
| `DATABASE_ENGINE` | `postgres` | Database DSN scheme/driver |
|
||||||
| `DATABASE_HOST` | `localhost` | Postgres host |
|
| `DATABASE_HOST` | `localhost` | Postgres host |
|
||||||
| `DATABASE_PORT` | unset | Postgres port; omitted from the DSN when empty (driver default, 5432) |
|
| `DATABASE_PORT` | unset | Postgres port; omitted from the DSN when empty (driver default, 5432 for Postgres) |
|
||||||
| `DATABASE_NAME` | `tavolo` | Postgres database name |
|
| `DATABASE_NAME` | `tavolo` | Postgres database name |
|
||||||
| `DATABASE_USER` | `tavolo` | Postgres user |
|
| `DATABASE_USER` | `tavolo` | Postgres user |
|
||||||
| `DATABASE_PASSWORD` | `password` | Postgres password |
|
| `DATABASE_PASSWORD` | `password` | Postgres password |
|
||||||
| `DATABASE_OPTIONS` | unset | Extra DSN query parameters, e.g. `ssl=require` |
|
| `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 (managed-DB URLs) |
|
| `DATABASE_URL` | unset | Full-DSN override; when set, the `DATABASE_*` parts above are ignored (used for sqlite in tests and for managed-DB URLs) |
|
||||||
| `REDIS_URL` | unset | Redis DSN for sessions + live games. Unset falls back to in-memory stores |
|
| `REDIS_URL` | unset | Redis DSN for sessions + live games. Unset falls back to in-memory stores |
|
||||||
| `OIDC_<n>_PROVIDER` | `default` ¹ | Provider name (required when OIDC is configured): used in the login URL `/auth/<provider>/login` and to namespace its session keys. Unique, `[A-Za-z0-9_-]+` |
|
| `OIDC_ISSUER` | `http://localhost:8180/tavolo` | OIDC issuer URL |
|
||||||
| `OIDC_<n>_ISSUER` | `http://localhost:8180/tavolo` ¹ | OIDC issuer URL |
|
| `OIDC_CLIENT_ID` | `tavolo` | OIDC client id |
|
||||||
| `OIDC_<n>_CLIENT_ID` | `tavolo` ¹ | OIDC client id |
|
| `OIDC_CLIENT_SECRET` | unset | OIDC client secret |
|
||||||
| `OIDC_<n>_CLIENT_SECRET` | unset | OIDC client secret; leave unset for public clients |
|
| `OIDC_REDIRECT_URI` | `http://localhost:8080/auth/callback` | Login callback URL |
|
||||||
| `OIDC_<n>_REDIRECT_URI` | `http://localhost:8080/auth/<provider>/callback` | Login callback URL; must be registered at the provider |
|
|
||||||
| `OIDC_<n>_POST_LOGIN_REDIRECT` | `/` | Where the browser is sent after login |
|
|
||||||
| `OIDC_<n>_POST_LOGOUT_REDIRECT` | `/` | Where the browser is sent after logout |
|
|
||||||
| `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis |
|
| `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis |
|
||||||
| `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the scopone between-hands scoring summary waits for acknowledgements (wired into the `ScoponeEngine`) |
|
| `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the between-hands scoring summary waits for acknowledgements |
|
||||||
| `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`) |
|
| `TURN_TIMEOUT_SECONDS` | `30` | Seconds a player has to play before the server plays a random legal card for them |
|
||||||
| `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 |
|
| `DEADLINE_HEARTBEAT_MS` | `1000` | Upper bound on the deadline consumer's poll interval (locally enqueued deadlines fire on time regardless) |
|
||||||
| `LOGGING_CONFIG` | unset | Accepted for compatibility; **ignored** (the Rust server logs via `RUST_LOG`) |
|
| `LOGGING_CONFIG` | unset | Path to a YAML logging configuration file (see below). Unset logs DEBUG to the console |
|
||||||
| `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 |
|
|
||||||
| `CORS_ALLOW_HEADERS` | unset | Comma-separated request headers allowed in cross-origin requests, or `*` to mirror back the requested ones. The CORS-safelisted headers are always allowed |
|
|
||||||
| `CORS_ALLOW_CREDENTIALS` | `false` | `1`/`true`/`yes`/`on` allow cookies/credentials on cross-origin requests |
|
|
||||||
| `CORS_EXPOSE_HEADERS` | unset | Comma-separated response headers exposed to the browser |
|
|
||||||
| `CORS_MAX_AGE` | `600` | Seconds browsers may cache the preflight response |
|
|
||||||
| `OTEL_ENABLED` | `false` | `1`/`true`/`yes`/`on` enable OpenTelemetry traces, 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 that default). Must be `http://`: the OTLP client is built without a TLS backend |
|
|
||||||
| `OTEL_EXPORTER_OTLP_HEADERS` | unset | Comma-separated `key=value` headers sent to the collector (e.g. authentication) |
|
|
||||||
| `OTEL_EXCLUDED_PATHS` | `/api/health` | Comma-separated paths excluded from tracing and metrics (exact matches) |
|
|
||||||
| `OTEL_METRIC_EXPORT_INTERVAL_MILLIS` | `60000` | How often the OTLP metric reader exports |
|
|
||||||
| `OTEL_METRICS_POLL_INTERVAL_SECONDS` | `15` | How often the session/game count gauges are refreshed |
|
|
||||||
| `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address |
|
| `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address |
|
||||||
| `STATIC_DIR` | `web/dist` | Directory holding the compiled frontend (the Docker image sets `/app/web/dist`) |
|
|
||||||
|
|
||||||
¹ When no `OIDC_<n>_*` variable is set at all, a single provider named
|
|
||||||
`default` pointing at the dev mock IdP is used. Indices must be contiguous
|
|
||||||
from 0 (`OIDC_0_*`, `OIDC_1_*`, ...).
|
|
||||||
|
|
||||||
### Multiple OIDC providers
|
|
||||||
|
|
||||||
Providers are configured in indexed blocks; each becomes its own login route
|
|
||||||
`/auth/<provider>/login` with its own callback URL and session namespace:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
OIDC_0_PROVIDER=google
|
|
||||||
OIDC_0_ISSUER=https://accounts.google.com
|
|
||||||
OIDC_0_CLIENT_ID=...
|
|
||||||
OIDC_0_CLIENT_SECRET=...
|
|
||||||
OIDC_0_REDIRECT_URI=https://tavolo.example.com/auth/google/callback
|
|
||||||
|
|
||||||
OIDC_1_PROVIDER=github
|
|
||||||
OIDC_1_ISSUER=https://github.com/login/oauth
|
|
||||||
OIDC_1_CLIENT_ID=...
|
|
||||||
OIDC_1_CLIENT_SECRET=...
|
|
||||||
OIDC_1_REDIRECT_URI=https://tavolo.example.com/auth/github/callback
|
|
||||||
```
|
|
||||||
|
|
||||||
The login page reads `GET /api/auth/providers` and shows one button per
|
|
||||||
provider. `GET /auth/login` redirects to the first provider (useful for
|
|
||||||
bookmarks and scripts), and `GET /auth/logout` signs out whichever provider
|
|
||||||
the session is logged in with. Sessions hold a single identity: logging in
|
|
||||||
with another provider replaces the previous one.
|
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
The Rust server logs through `tracing`, configured with `RUST_LOG`
|
The application logs through the Python stdlib `logging` module, one
|
||||||
(`tracing-subscriber` env-filter syntax). The default filter is `debug` for
|
`getLogger(__name__)` per module: lifecycle and business events at INFO
|
||||||
the application with noisy dependencies capped (`sqlx`, `redis`, `hyper`,
|
(game created/joined, websocket connections, match results, auto-plays),
|
||||||
`reqwest` at WARN). `LOGGING_CONFIG` (a Python `logging.config.dictConfig`
|
per-move and store detail at DEBUG.
|
||||||
YAML path) is accepted so existing deployments do not break, but its value is
|
|
||||||
ignored with a warning.
|
|
||||||
|
|
||||||
Lifecycle and business events are logged at INFO (game created/joined,
|
By default everything at DEBUG level goes to the console. Set
|
||||||
websocket connections, match results, auto-plays); per-move and store detail
|
`LOGGING_CONFIG` to the path of a YAML file to take over the
|
||||||
at DEBUG.
|
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):
|
||||||
|
|
||||||
## OpenTelemetry
|
```yaml
|
||||||
|
version: 1
|
||||||
With `OTEL_ENABLED` set, the app exports over OTLP/HTTP:
|
disable_existing_loggers: false
|
||||||
|
formatters:
|
||||||
- **Traces**: one span per HTTP request and one per WebSocket connection,
|
default:
|
||||||
tagged with the request's session and user: `session.id` is a truncated
|
format: "{asctime} [{levelname}] ({processName:s}/{threadName:s}) - {name} - {message}"
|
||||||
SHA-256 of the session id (the cookie is a credential, so it is never
|
style: "{"
|
||||||
exported verbatim) and `user.id` is the OIDC `sub`, present once logged in.
|
handlers:
|
||||||
Database-client auto-instrumentation is not included.
|
console:
|
||||||
- **Metrics**: the `http.server.request.duration` histogram, plus
|
class: logging.StreamHandler
|
||||||
`tavolo.active_users` (sessions in the session store),
|
formatter: default
|
||||||
`tavolo.ongoing_games` (live matches not finished yet), and process metrics:
|
root:
|
||||||
`process.cpu.time`, `process.cpu.utilization`, `process.memory.usage`,
|
level: WARNING
|
||||||
`process.memory.virtual`. The Python build's CPython gc metrics have no
|
handlers: [console]
|
||||||
Rust equivalent.
|
loggers:
|
||||||
- **Logs**: tracing events forwarded to OTLP (OpenTelemetry's own records are
|
tavolo:
|
||||||
filtered out to avoid export loops).
|
level: INFO
|
||||||
|
```
|
||||||
The session/game gauges are polled every
|
|
||||||
`OTEL_METRICS_POLL_INTERVAL_SECONDS` (Redis-backed stores report
|
|
||||||
cluster-wide counts, in-memory stores the process's own). With multiple
|
|
||||||
replicas each process reports the same global counts: aggregate with `max`,
|
|
||||||
not `sum`.
|
|
||||||
|
|
||||||
The OTLP client is built without a TLS backend, so
|
|
||||||
`OTEL_EXPORTER_OTLP_ENDPOINT` must use `http://`; terminate TLS in front of
|
|
||||||
the collector (or run a local sidecar) if the collector is remote. The
|
|
||||||
OIDC/Postgres clients do not use this client and validate HTTPS against
|
|
||||||
Mozilla's roots embedded in the binary (no certificate files are needed, and
|
|
||||||
private CAs are not trusted by them).
|
|
||||||
|
|
||||||
## Data model
|
## Data model
|
||||||
|
|
||||||
### Redis (live games)
|
### Redis (live games)
|
||||||
|
|
||||||
- `tavolo:game:<uuid>` — the whole session as JSON: the platform-owned
|
- `tavolo:game:<uuid>` — the whole match as JSON: players (seat 0/2 = team A,
|
||||||
envelope (`id`, `game_type`, `join_code`, seats, timestamps) plus the
|
1/3 = team B), hands, table, captured piles, scope, current turn, dealer,
|
||||||
game-specific `state` blob, which only the registered engine interprets.
|
scores, phase (`lobby` → `playing` → `finished`). Sliding TTL
|
||||||
Sliding TTL (`GAME_TTL_SECONDS`). The layout is identical to the original
|
(`GAME_TTL_SECONDS`).
|
||||||
Python implementation, so in-flight games survive the backend swap.
|
|
||||||
- `tavolo:code:<JOINCODE>` — the 6-character join code → game id index.
|
- `tavolo:code:<JOINCODE>` — the 6-character join code → game id index.
|
||||||
- `tavolo:game:<uuid>:lock` — a short-lived lock serializing every mutation.
|
- `tavolo:game:<uuid>:lock` — a short-lived lock serializing every mutation.
|
||||||
- `tavolo:game:<uuid>:events` — a pub/sub channel carrying "state changed"
|
- `tavolo:game:<uuid>:events` — a pub/sub channel carrying "state changed"
|
||||||
signals; every open WebSocket reloads the session and pushes the
|
signals; every open WebSocket reloads the state and pushes the
|
||||||
personalized view to its player.
|
personalized view to its player.
|
||||||
- `tavolo:deadlines` — a sorted set (score = due timestamp) of pending
|
- `tavolo:deadlines` — a sorted set (score = due timestamp) of pending
|
||||||
timeouts (scopone's turn auto-plays and hand-end auto-continues). Every
|
timeouts: turn auto-plays and hand-end auto-continues. Every worker runs
|
||||||
worker runs a consumer that fires due entries under the per-game lock by
|
a consumer that fires due entries under the per-game lock, so timeouts
|
||||||
calling the engine's `fire_deadline`, so timeouts do not depend on any
|
do not depend on any player being connected and survive the death of
|
||||||
player being connected and survive the death of any worker (delivery is
|
any worker (delivery is at-least-once; entries are revalidated against
|
||||||
at-least-once; engines revalidate the entry's token against the live state
|
the live state before firing).
|
||||||
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.
|
|
||||||
|
|
||||||
Sessions live under `tavolo:session:<id>` as JSON (the Python server used
|
### Postgres (statistics, via Tortoise ORM + aerich migrations)
|
||||||
pickled values under `kaya:session:`; existing sessions are invalidated once
|
|
||||||
on cutover, users log in again).
|
|
||||||
|
|
||||||
### Postgres (statistics, via sqlx migrations)
|
- `match` — one row per finished match: the game played (`game_type`, one
|
||||||
|
of the ids from `GET /api/game-types`, indexed so statistics can be
|
||||||
- `match` — one row per finished match: the game played (`game_type`,
|
scoped per game), both teams' final scores, winner, target score, hands
|
||||||
indexed so statistics can be scoped per game), start/finish timestamps, and
|
played, start/finish timestamps.
|
||||||
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,
|
- `match_player` — one row per participant: the OIDC `sub`, display name,
|
||||||
seat, game-defined team label (nullable), whether they won, the points they
|
seat, team and whether they won. Unique per `(match, user_sub)`.
|
||||||
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
|
When a match ends, the result is written transactionally to Postgres
|
||||||
Postgres (once, guarded by a flag on the session); the finished session stays
|
(once, guarded by a flag on the Redis state); the finished state stays in
|
||||||
in Redis until its TTL expires so clients can still fetch the final board.
|
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, 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
|
|
||||||
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
|
## REST API
|
||||||
|
|
||||||
@@ -249,35 +139,28 @@ All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
|
|||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `GET` | `/api/game-types` | The games the platform can host (for the creation dropdown), each with its `options_schema` |
|
| `GET` | `/api/game-types` | The card games the platform can host (for the creation dropdown) |
|
||||||
| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "options": {"target_score": 11, "napola": true, "asso_pigliatutto": false}}`. Returns the lobby (`{id, join_code, players, seats_open, ...}`) |
|
| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "target_score": 11, "napola": true}`. Returns `{id, join_code}` |
|
||||||
| `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The last player triggers the start and gets the game view back |
|
| `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The fourth player triggers the deal |
|
||||||
| `GET` | `/api/games/preview?code=ABC123` | Pre-join summary of the lobby behind a code: game name/description, seated players, open seats and the engine-echoed options. `404` when the code is unknown |
|
|
||||||
| `GET` | `/api/games/{id}` | Personalized snapshot (only your own hand is visible) |
|
| `GET` | `/api/games/{id}` | Personalized snapshot (only your own hand is visible) |
|
||||||
| `GET` | `/api/me/matches` | Cursor-paginated match history with the engine's `result` and per-player Elo deltas (`?limit=&cursor=&game_type=`) |
|
| `GET` | `/api/me/matches` | Cursor-paginated match history with final scores (`?limit=&cursor=&game_type=`) |
|
||||||
| `GET` | `/api/me/ratings` | The caller's Elo rating per game type |
|
| `GET` | `/api/leaderboard` | Aggregated wins / matches / team points per player (`?game_type=`) |
|
||||||
| `GET` | `/api/leaderboard` | Elo rating, aggregated wins / matches / points per player, sorted by Elo (`?game_type=`) |
|
|
||||||
|
|
||||||
## WebSocket protocol
|
## WebSocket protocol
|
||||||
|
|
||||||
Connect to `/ws/games/{game_id}` with your session cookie. Only seated
|
Connect to `/ws/games/{game_id}` with your session cookie. Only seated
|
||||||
players are accepted; the upgrade is refused before the handshake completes
|
players are accepted; unauthenticated, unknown-game and non-player
|
||||||
with an HTTP status mirroring `GET /api/games/{id}`: `401` unauthenticated,
|
connections are closed with codes `4401`, `4404` and `4403` respectively.
|
||||||
`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`:
|
Server → client messages are JSON objects with a `type`:
|
||||||
|
|
||||||
- `{"type": "state", "game": {...}}` — personalized game view. Your own hand
|
- `{"type": "state", "game": {...}}` — personalized game view. Your own
|
||||||
is included; other players expose only their card counts. `your_turn: true`
|
hand is included; other players expose only their card counts.
|
||||||
is present when it is your move.
|
`your_turn: true` is present when it is your move.
|
||||||
- `{"type": "game_over", "scores": {"A": 11, "B": 7}, "winner": "A"}`
|
- `{"type": "game_over", "scores": {"A": 11, "B": 7}, "winner": "A"}`
|
||||||
- `{"type": "error", "code": "illegal_move", "message": "..."}`
|
- `{"type": "error", "code": "illegal_move", "message": "..."}`
|
||||||
|
|
||||||
Client → server messages are JSON objects with an `action`. The platform
|
Client → server messages:
|
||||||
handles `state` (resend the snapshot) itself; every other action is
|
|
||||||
dispatched to the session's game engine with the rest of the message as its
|
|
||||||
payload. For scopone scientifico:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
|
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
|
||||||
@@ -291,9 +174,9 @@ payload. For scopone scientifico:
|
|||||||
settebello).
|
settebello).
|
||||||
- `capture` lists the table cards to take. When a capture is legal it is
|
- `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.
|
mandatory to provide one; when no capture exists it must be omitted.
|
||||||
- `ack` acknowledges the hand-end scoring summary (see below). The next hand
|
- `ack` acknowledges the hand-end scoring summary (see below). The next
|
||||||
is dealt once all four players have acknowledged, or automatically after
|
hand is dealt once all four players have acknowledged, or automatically
|
||||||
`HAND_ACK_TIMEOUT_SECONDS`.
|
after `HAND_ACK_TIMEOUT_SECONDS`.
|
||||||
- `state` asks for a fresh snapshot.
|
- `state` asks for a fresh snapshot.
|
||||||
|
|
||||||
After every accepted move the new state is broadcast to all four players.
|
After every accepted move the new state is broadcast to all four players.
|
||||||
@@ -301,22 +184,23 @@ After every accepted move the new state is broadcast to all four players.
|
|||||||
### Turn timeout
|
### Turn timeout
|
||||||
|
|
||||||
The state carries a `turn_deadline` while a hand is being played. If the
|
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
|
player on turn does not move before it, the server plays a random legal
|
||||||
for them (picking one of the legal captures at random when a capture is
|
card for them (picking one of the legal captures at random when a capture
|
||||||
required), so a disconnected or idle player cannot stall the match. The
|
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
|
timeout is `TURN_TIMEOUT_SECONDS` (default 30); the auto-played move is
|
||||||
broadcast like any other. Deadlines fire from the shared `tavolo:deadlines`
|
broadcast like any other. Deadlines fire from the shared `tavolo:deadlines`
|
||||||
queue, not from timers tied to client connections, so the match keeps
|
queue (see above), not from timers tied to client connections, so the
|
||||||
progressing even with every player disconnected.
|
match keeps progressing even with every player disconnected.
|
||||||
|
|
||||||
### Hand-end summary
|
### Hand-end summary
|
||||||
|
|
||||||
When a hand finishes but the match continues, the game enters the
|
When a hand finishes but the match continues, the game enters the
|
||||||
`hand_end` phase instead of dealing immediately: the state carries
|
`hand_end` phase instead of dealing immediately: the state carries
|
||||||
`last_hand` (a full scoring breakdown with an `award` map naming the team
|
`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`.
|
that won each category), the `acknowledged` seats and a
|
||||||
The frontend renders this as a screen every player must dismiss. A play
|
`hand_end_deadline`. The frontend renders this as a screen every player
|
||||||
attempted in this phase is rejected with an `illegal_move` error.
|
must dismiss. A play attempted in this phase is rejected with an
|
||||||
|
`illegal_move` error.
|
||||||
|
|
||||||
## Rules implemented
|
## Rules implemented
|
||||||
|
|
||||||
@@ -328,121 +212,69 @@ attempted in this phase is rejected with an `illegal_move` error.
|
|||||||
- Hand points: `carte` (most captured cards), `denara` (most diamonds),
|
- Hand points: `carte` (most captured cards), `denara` (most diamonds),
|
||||||
`settebello` (7♦), `primiera` (best 7/6/5/4 per suit, all four suits
|
`settebello` (7♦), `primiera` (best 7/6/5/4 per suit, all four suits
|
||||||
required), plus one point per scopa. Ties award nothing.
|
required), plus one point per scopa. Ties award nothing.
|
||||||
- Optional *napola* rule (per-game `napola` creation option, default on):
|
- Optional *napola* rule (per-game `napola` flag on `POST /api/games`,
|
||||||
the longest run of consecutive denari starting from the ace scores one
|
default on): the longest run of consecutive denari starting from the
|
||||||
point per card once it reaches three cards (A-2-3 = 3, A-2-3-4 = 4, …).
|
ace scores one point per card once it reaches three cards (A-2-3 = 3,
|
||||||
A team that captures the whole denari suit (ace to king) wins the match
|
A-2-3-4 = 4, …). A team that captures the whole denari suit (ace to
|
||||||
instantly, regardless of the score.
|
king) wins the match instantly, regardless of the score.
|
||||||
- Optional *asso pigliatutto* rule (per-game `asso_pigliatutto` creation
|
|
||||||
option, default off): an ace played when no equal rank is on the table
|
|
||||||
sweeps every card on the table — or captures itself when the table is
|
|
||||||
empty. The capture never scores a scopa, but it counts as the last
|
|
||||||
capture, and the mandatory equal-rank capture still applies when another
|
|
||||||
ace is on the table.
|
|
||||||
- The match ends when a team reaches the target score (default 11,
|
- The match ends when a team reaches the target score (default 11,
|
||||||
configurable per game via the `target_score` creation option) with a
|
configurable per game) with a clear lead; a tie at or above the target is
|
||||||
clear lead; a tie at or above the target is broken by another hand.
|
broken by another hand.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Backend (from `server/`):
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo test --workspace # unit + integration suites
|
python3 -m venv .venv
|
||||||
cargo run -p tavolo-app # = tavolo serve (needs the env below)
|
.venv/bin/pip install -r requirements.txt
|
||||||
cargo run -p tavolo-app -- migrate
|
.venv/bin/pip install -e '.[dev]'
|
||||||
|
.venv/bin/python -m unittest discover -s tests -t .
|
||||||
|
.venv/bin/mypy src
|
||||||
```
|
```
|
||||||
|
|
||||||
For a local run without Docker:
|
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` /
|
||||||
```sh
|
`httpx-ws` ASGI transports for HTTP and WebSocket coverage.
|
||||||
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
|
### Migrations
|
||||||
|
|
||||||
The Postgres schema is owned by embedded sqlx migrations in
|
The Postgres schema is owned by aerich migrations in `migrations/`. The
|
||||||
`crates/tavolo-app/migrations/`, applied by `tavolo migrate` (the
|
`db-migrate` compose service runs `aerich upgrade` before the app starts.
|
||||||
`db-migrate` compose service and the Kubernetes init container). The
|
To add a migration after changing `src/tavolo/models.py`:
|
||||||
migration is idempotent (`CREATE TABLE IF NOT EXISTS`) and matches the
|
|
||||||
original aerich-created schema, so it can be applied to an existing database
|
```sh
|
||||||
without touching data; the `aerich` bookkeeping table is left alone. To
|
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo .venv/bin/aerich migrate
|
||||||
change the schema, add a new numbered `.sql` file next to the baseline and
|
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo .venv/bin/aerich upgrade
|
||||||
run `cargo test -p tavolo-app` (the migration is embedded at compile time).
|
```
|
||||||
|
|
||||||
|
(`aerich init-db` produces sqlite-flavored DDL when pointed at sqlite;
|
||||||
|
adjust `UUID`/`TIMESTAMPTZ`/`BOOL`/`JSONB` for Postgres like the existing
|
||||||
|
baseline.)
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
|
Everything lives under `server/`:
|
||||||
|
|
||||||
```
|
```
|
||||||
crates/tavolo-platform/ # game-independent platform (own crate + tests)
|
src/tavolo/
|
||||||
src/
|
├── app.py # composition root: session/OIDC/Tortoise/OpenAPI mixins
|
||||||
├── engine.rs # the platform<->game contract
|
├── config.py # env -> frozen Settings
|
||||||
├── registry.rs # game id -> engine lookup
|
├── auth.py # auth helpers (HTTP + WebSocket)
|
||||||
├── errors.rs # GameError + platform Error
|
├── http.py # JSON request/response helpers
|
||||||
├── store.rs # Redis / in-memory live-session store (+ deadline queue)
|
├── pagination.py # keyset (cursor) pagination
|
||||||
├── deadlines.rs # connection-independent timeout scheduler
|
├── openapi.py # shared OpenAPI parameter fragments
|
||||||
├── session.rs # HTTP sessions (in-memory / Redis) + middleware
|
├── tortoise_mixin.py # TortoiseORM lifecycle (HTTP + WebSocket)
|
||||||
├── oidc.rs # OIDC discovery, PKCE, JWKS validation, logout
|
├── aerich_config.py # aerich CLI configuration
|
||||||
├── auth.rs # session -> user helpers
|
├── models.py # Match, MatchPlayer (Postgres)
|
||||||
├── stats.rs # match persistence + history/leaderboard queries + Elo
|
├── stats.py # finished match -> Postgres persistence
|
||||||
├── models.rs # sqlx row models
|
├── store.py # Redis / in-memory live-game store (+ deadline queue)
|
||||||
├── elo.rs # chess-style Elo math (1500 start, K=32)
|
├── deadlines.py # connection-independent timeout scheduler
|
||||||
├── pagination.rs # keyset (cursor) pagination
|
├── ws.py # WebSocket live-play endpoint
|
||||||
├── http.rs # JSON request/response helpers
|
├── game/
|
||||||
├── platform.rs # the collaborators shared by all endpoints
|
│ ├── state.py # GameState / PlayerState / Card, JSON (de)serialization
|
||||||
├── routes/ # health / me / auth / games / stats / openapi
|
│ ├── engine.py # pure scopone scientifico rules
|
||||||
└── ws.rs # WebSocket live-play endpoint (generic envelope)
|
│ └── errors.py # typed rule violations
|
||||||
crates/tavolo-scopone/ # scopone scientifico game (own crate + tests)
|
└── routes/
|
||||||
src/{state,engine,plugin,errors}.rs
|
├── health.py # GET /api/health
|
||||||
crates/tavolo-app/ # deployable app: composition root (own crate)
|
├── games.py # lobby: create / join / snapshot
|
||||||
src/{main,app,config,cors,logging,static_files,telemetry}.rs
|
└── stats.py # match history + leaderboard
|
||||||
migrations/ # embedded sqlx migrations
|
|
||||||
dev/mockoauth/ # dev OIDC provider image (compose)
|
|
||||||
Dockerfile # web-builder + musl cargo + scratch 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`. |
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""Shared test fixtures.
|
||||||
|
|
||||||
|
Tests run against an in-memory sqlite database (overriding ``DATABASE_URL``)
|
||||||
|
so they need no running Postgres, and with ``REDIS_URL`` unset so sessions
|
||||||
|
and live games use their in-memory stores. The environment is set before
|
||||||
|
:mod:`tavolo.app` is imported by the test modules.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "sqlite://:memory:")
|
||||||
|
os.environ.setdefault("OIDC_ISSUER", "http://localhost:8180/tavolo")
|
||||||
|
os.environ.setdefault("OIDC_CLIENT_ID", "tavolo")
|
||||||
|
os.environ.setdefault("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback")
|
||||||
|
os.environ.pop("REDIS_URL", None)
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
[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",
|
|
||||||
]
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
-- 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");
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
//! Application composition root: registry, stores, platform, router.
|
|
||||||
//!
|
|
||||||
//! Mirrors `tavolo.app` from the Python implementation:
|
|
||||||
//!
|
|
||||||
//! * the [`ScoponeEngine`] is registered with the configured timeouts;
|
|
||||||
//! * sessions and live games use Redis when `REDIS_URL` is set, in-memory
|
|
||||||
//! stores otherwise;
|
|
||||||
//! * finished matches are persisted to Postgres through
|
|
||||||
//! [`PostgresStatsRepo`];
|
|
||||||
//! * the platform routes are merged with the SPA shell hosting and wrapped in
|
|
||||||
//! the session middleware and the optional CORS layer.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use axum::Router;
|
|
||||||
use tavolo_platform::deadlines::DeadlineScheduler;
|
|
||||||
use tavolo_platform::oidc::OidcProviders;
|
|
||||||
use tavolo_platform::platform::{Platform, SharedPlatform};
|
|
||||||
use tavolo_platform::registry::GameRegistry;
|
|
||||||
use tavolo_platform::routes;
|
|
||||||
use tavolo_platform::session::{
|
|
||||||
session_middleware, InMemorySessionStore, RedisSessionStore, SharedSessionStore,
|
|
||||||
};
|
|
||||||
use tavolo_platform::stats::{InMemoryStatsRepo, PostgresStatsRepo, SharedStatsRepo};
|
|
||||||
use tavolo_platform::store::{InMemoryGameStore, RedisGameStore, SharedGameStore};
|
|
||||||
use tavolo_scopone::ScoponeEngine;
|
|
||||||
use tower_http::services::ServeDir;
|
|
||||||
|
|
||||||
use crate::config::Settings;
|
|
||||||
use crate::cors::CorsConfig;
|
|
||||||
use crate::static_files::{mount_dirs, SpaFallback};
|
|
||||||
|
|
||||||
pub struct App {
|
|
||||||
pub router: Router,
|
|
||||||
pub platform: SharedPlatform,
|
|
||||||
pub session_store: SharedSessionStore,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the game registry (the first registered game is the default).
|
|
||||||
pub fn build_registry(settings: &Settings) -> Result<Arc<GameRegistry>, String> {
|
|
||||||
let mut registry = GameRegistry::new();
|
|
||||||
registry
|
|
||||||
.register(Arc::new(ScoponeEngine::new(
|
|
||||||
settings.turn_timeout_seconds,
|
|
||||||
settings.hand_ack_timeout_seconds,
|
|
||||||
)))
|
|
||||||
.map_err(|err| err.to_string())?;
|
|
||||||
Ok(Arc::new(registry))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the session and live-game stores from `REDIS_URL`.
|
|
||||||
pub async fn build_stores(
|
|
||||||
settings: &Settings,
|
|
||||||
registry: Arc<GameRegistry>,
|
|
||||||
) -> Result<(SharedSessionStore, SharedGameStore), String> {
|
|
||||||
if let Some(url) = &settings.redis_url {
|
|
||||||
let session_store: SharedSessionStore = Arc::new(
|
|
||||||
RedisSessionStore::new(url)
|
|
||||||
.await
|
|
||||||
.map_err(|err| format!("could not connect to Redis: {err}"))?,
|
|
||||||
);
|
|
||||||
let game_store: SharedGameStore = Arc::new(
|
|
||||||
RedisGameStore::new(url, registry, settings.game_ttl_seconds)
|
|
||||||
.await
|
|
||||||
.map_err(|err| format!("could not connect to Redis: {err}"))?,
|
|
||||||
);
|
|
||||||
Ok((session_store, game_store))
|
|
||||||
} else {
|
|
||||||
let session_store: SharedSessionStore = Arc::new(InMemorySessionStore::new());
|
|
||||||
let game_store: SharedGameStore = Arc::new(InMemoryGameStore::new(registry));
|
|
||||||
Ok((session_store, game_store))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the [`Platform`] bundle and start the deadline scheduler.
|
|
||||||
pub fn build_platform(
|
|
||||||
settings: &Settings,
|
|
||||||
registry: Arc<GameRegistry>,
|
|
||||||
game_store: SharedGameStore,
|
|
||||||
stats: SharedStatsRepo,
|
|
||||||
) -> Result<SharedPlatform, String> {
|
|
||||||
let oidc = Arc::new(
|
|
||||||
OidcProviders::new(settings.oidc_providers.clone()).map_err(|err| err.to_string())?,
|
|
||||||
);
|
|
||||||
let scheduler = DeadlineScheduler::new(
|
|
||||||
game_store.clone(),
|
|
||||||
registry.clone(),
|
|
||||||
stats.clone(),
|
|
||||||
settings.deadline_heartbeat_ms,
|
|
||||||
1000,
|
|
||||||
);
|
|
||||||
scheduler.start();
|
|
||||||
Ok(Arc::new(Platform {
|
|
||||||
registry,
|
|
||||||
game_store,
|
|
||||||
scheduler,
|
|
||||||
oidc,
|
|
||||||
stats,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the axum router: platform routes, static mounts, SPA fallback,
|
|
||||||
/// session middleware and optional CORS.
|
|
||||||
pub fn build_router(
|
|
||||||
platform: SharedPlatform,
|
|
||||||
session_store: SharedSessionStore,
|
|
||||||
settings: &Settings,
|
|
||||||
) -> Router {
|
|
||||||
let (root, assets) = mount_dirs(&settings.static_dir);
|
|
||||||
let provider_names: Vec<String> = settings
|
|
||||||
.oidc_providers
|
|
||||||
.iter()
|
|
||||||
.map(|provider| provider.provider.clone())
|
|
||||||
.collect();
|
|
||||||
let mut router = Router::new()
|
|
||||||
.merge(routes::router(&provider_names).with_state(platform))
|
|
||||||
.nest_service("/static", ServeDir::new(root))
|
|
||||||
.nest_service("/assets", ServeDir::new(assets))
|
|
||||||
.fallback_service(SpaFallback::new(&settings.static_dir));
|
|
||||||
// Added before the session layer, so request spans see the loaded
|
|
||||||
// session while the session middleware stays the outer layer.
|
|
||||||
let excluded = Arc::new(settings.otel_excluded_paths.clone());
|
|
||||||
router = router.layer(axum::middleware::from_fn(move |request, next| {
|
|
||||||
let excluded = Arc::clone(&excluded);
|
|
||||||
crate::telemetry::trace_middleware(excluded, request, next)
|
|
||||||
}));
|
|
||||||
router = router.layer(axum::middleware::from_fn_with_state(
|
|
||||||
session_store,
|
|
||||||
session_middleware,
|
|
||||||
));
|
|
||||||
if let Some(config) = CorsConfig::new(
|
|
||||||
settings.cors_allow_origins.as_deref().unwrap_or(&[]),
|
|
||||||
settings.cors_allow_methods.as_deref(),
|
|
||||||
settings.cors_allow_headers.as_deref(),
|
|
||||||
settings.cors_allow_credentials,
|
|
||||||
settings.cors_allow_origin_regex.as_deref(),
|
|
||||||
settings.cors_expose_headers.as_deref().unwrap_or(&[]),
|
|
||||||
settings.cors_max_age,
|
|
||||||
) {
|
|
||||||
router = router.layer(axum::middleware::from_fn_with_state(
|
|
||||||
Arc::new(config),
|
|
||||||
crate::cors::cors_middleware,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
router
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build everything in-memory: useful for tests and ephemeral dev without
|
|
||||||
/// Postgres or Redis.
|
|
||||||
pub fn in_memory_platform(
|
|
||||||
settings: &Settings,
|
|
||||||
) -> Result<(SharedPlatform, SharedSessionStore), String> {
|
|
||||||
let registry = build_registry(settings)?;
|
|
||||||
let session_store: SharedSessionStore = Arc::new(InMemorySessionStore::new());
|
|
||||||
let game_store: SharedGameStore = Arc::new(InMemoryGameStore::new(registry.clone()));
|
|
||||||
let stats: SharedStatsRepo = Arc::new(InMemoryStatsRepo::new());
|
|
||||||
let platform = build_platform(settings, registry, game_store, stats)?;
|
|
||||||
Ok((platform, session_store))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Connect to Postgres.
|
|
||||||
pub async fn connect_database(settings: &Settings) -> Result<sqlx::PgPool, String> {
|
|
||||||
if settings.database_url.starts_with("sqlite") {
|
|
||||||
return Err(
|
|
||||||
"the Rust server supports Postgres only; sqlite DATABASE_URLs are not supported"
|
|
||||||
.to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
sqlx::postgres::PgPoolOptions::new()
|
|
||||||
.max_connections(10)
|
|
||||||
.acquire_timeout(Duration::from_secs(10))
|
|
||||||
.connect(&settings.database_url)
|
|
||||||
.await
|
|
||||||
.map_err(|err| format!("could not connect to the database: {err}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the production application (requires Postgres; Redis optional).
|
|
||||||
pub async fn build(settings: &Settings) -> Result<App, String> {
|
|
||||||
let registry = build_registry(settings)?;
|
|
||||||
let (session_store, game_store) = build_stores(settings, registry.clone()).await?;
|
|
||||||
let pool = connect_database(settings).await?;
|
|
||||||
let stats: SharedStatsRepo = Arc::new(PostgresStatsRepo::new(pool));
|
|
||||||
let platform = build_platform(settings, registry, game_store, stats)?;
|
|
||||||
let router = build_router(platform.clone(), session_store.clone(), settings);
|
|
||||||
Ok(App {
|
|
||||||
router,
|
|
||||||
platform,
|
|
||||||
session_store,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
//! Environment-driven configuration for the tavolo application.
|
|
||||||
//!
|
|
||||||
//! Mirrors `tavolo.config` from the Python implementation: read environment
|
|
||||||
//! variables into a plain struct, with the same defaults and validation.
|
|
||||||
|
|
||||||
use std::collections::BTreeSet;
|
|
||||||
|
|
||||||
use regex::Regex;
|
|
||||||
|
|
||||||
use tavolo_platform::oidc::OidcProviderConfig;
|
|
||||||
|
|
||||||
const OIDC_VARIABLES: [&str; 7] = [
|
|
||||||
"PROVIDER",
|
|
||||||
"ISSUER",
|
|
||||||
"CLIENT_ID",
|
|
||||||
"CLIENT_SECRET",
|
|
||||||
"REDIRECT_URI",
|
|
||||||
"POST_LOGIN_REDIRECT",
|
|
||||||
"POST_LOGOUT_REDIRECT",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// The pre-0.0.6 unindexed names, detected only to point the migration out.
|
|
||||||
const LEGACY_OIDC_VARIABLES: [&str; 6] = [
|
|
||||||
"OIDC_ISSUER",
|
|
||||||
"OIDC_CLIENT_ID",
|
|
||||||
"OIDC_CLIENT_SECRET",
|
|
||||||
"OIDC_REDIRECT_URI",
|
|
||||||
"OIDC_POST_LOGIN_REDIRECT",
|
|
||||||
"OIDC_POST_LOGOUT_REDIRECT",
|
|
||||||
];
|
|
||||||
|
|
||||||
const DEFAULT_OIDC_ISSUER: &str = "http://localhost:8180/tavolo";
|
|
||||||
const DEFAULT_OIDC_CLIENT_ID: &str = "tavolo";
|
|
||||||
|
|
||||||
fn env(name: &str) -> Option<String> {
|
|
||||||
std::env::var(name).ok().filter(|value| !value.is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn env_or(name: &str, default: &str) -> String {
|
|
||||||
env(name).unwrap_or_else(|| default.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn env_list(name: &str) -> Option<Vec<String>> {
|
|
||||||
let value = std::env::var(name).ok()?;
|
|
||||||
if value.trim().is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(
|
|
||||||
value
|
|
||||||
.split(',')
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|part| !part.is_empty())
|
|
||||||
.map(str::to_string)
|
|
||||||
.collect(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn env_bool(name: &str, default: bool) -> bool {
|
|
||||||
match std::env::var(name) {
|
|
||||||
Ok(value) if !value.is_empty() => {
|
|
||||||
matches!(
|
|
||||||
value.trim().to_lowercase().as_str(),
|
|
||||||
"1" | "true" | "yes" | "on"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
_ => default,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_oidc_providers() -> Result<Vec<OidcProviderConfig>, String> {
|
|
||||||
let legacy: Vec<&str> = LEGACY_OIDC_VARIABLES
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.filter(|name| env(name).is_some())
|
|
||||||
.collect();
|
|
||||||
if !legacy.is_empty() {
|
|
||||||
let renamed: Vec<String> = legacy
|
|
||||||
.iter()
|
|
||||||
.map(|name| format!("{name} -> OIDC_0_{}", &name["OIDC_".len()..]))
|
|
||||||
.collect();
|
|
||||||
return Err(format!(
|
|
||||||
"the OIDC variables are now indexed per provider ({}; add OIDC_0_PROVIDER)",
|
|
||||||
renamed.join(", ")
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let pattern = Regex::new(r"^OIDC_(\d+)_([A-Z0-9_]+)$").expect("valid regex");
|
|
||||||
let mut indices: BTreeSet<usize> = BTreeSet::new();
|
|
||||||
for (name, _) in std::env::vars() {
|
|
||||||
let Some(captures) = pattern.captures(&name) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let variable = captures.get(2).expect("group 2").as_str();
|
|
||||||
if !OIDC_VARIABLES.contains(&variable) {
|
|
||||||
return Err(format!(
|
|
||||||
"unknown OIDC environment variable {name:?}; expected one of {}",
|
|
||||||
OIDC_VARIABLES
|
|
||||||
.iter()
|
|
||||||
.map(|variable| format!("OIDC_<n>_{variable}"))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(", ")
|
|
||||||
));
|
|
||||||
}
|
|
||||||
indices.insert(
|
|
||||||
captures
|
|
||||||
.get(1)
|
|
||||||
.expect("group 1")
|
|
||||||
.as_str()
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| format!("invalid OIDC provider index in {name:?}"))?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if indices.is_empty() {
|
|
||||||
return Ok(vec![OidcProviderConfig {
|
|
||||||
provider: "default".into(),
|
|
||||||
issuer: DEFAULT_OIDC_ISSUER.into(),
|
|
||||||
client_id: DEFAULT_OIDC_CLIENT_ID.into(),
|
|
||||||
client_secret: None,
|
|
||||||
redirect_uri: "http://localhost:8080/auth/default/callback".into(),
|
|
||||||
post_login_redirect: "/".into(),
|
|
||||||
post_logout_redirect: "/".into(),
|
|
||||||
}]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let max = *indices.iter().next_back().expect("non-empty");
|
|
||||||
let missing: Vec<usize> = (0..=max).filter(|index| !indices.contains(index)).collect();
|
|
||||||
if !missing.is_empty() {
|
|
||||||
return Err(format!(
|
|
||||||
"OIDC provider indices must be contiguous from 0; missing {}",
|
|
||||||
missing
|
|
||||||
.iter()
|
|
||||||
.map(|index| format!("OIDC_{index}_*"))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(", ")
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let provider_name = Regex::new(r"^[A-Za-z0-9_-]+$").expect("valid regex");
|
|
||||||
let mut seen = std::collections::HashSet::new();
|
|
||||||
let mut providers = Vec::new();
|
|
||||||
for index in indices {
|
|
||||||
let prefix = format!("OIDC_{index}_");
|
|
||||||
let required = |variable: &str| -> Result<String, String> {
|
|
||||||
env(&format!("{prefix}{variable}"))
|
|
||||||
.ok_or_else(|| format!("Missing required environment variable: {prefix}{variable}"))
|
|
||||||
};
|
|
||||||
let provider = required("PROVIDER")?;
|
|
||||||
if !provider_name.is_match(&provider) {
|
|
||||||
return Err(format!(
|
|
||||||
"{prefix}PROVIDER must be a non-empty string of letters, digits, underscores or hyphens (got {provider:?})"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if !seen.insert(provider.clone()) {
|
|
||||||
return Err(format!("duplicate OIDC provider name {provider:?}"));
|
|
||||||
}
|
|
||||||
providers.push(OidcProviderConfig {
|
|
||||||
issuer: required("ISSUER")?,
|
|
||||||
client_id: required("CLIENT_ID")?,
|
|
||||||
client_secret: env(&format!("{prefix}CLIENT_SECRET")),
|
|
||||||
redirect_uri: env_or(
|
|
||||||
&format!("{prefix}REDIRECT_URI"),
|
|
||||||
&format!("http://localhost:8080/auth/{provider}/callback"),
|
|
||||||
),
|
|
||||||
post_login_redirect: env_or(&format!("{prefix}POST_LOGIN_REDIRECT"), "/"),
|
|
||||||
post_logout_redirect: env_or(&format!("{prefix}POST_LOGOUT_REDIRECT"), "/"),
|
|
||||||
provider,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(providers)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Percent-encode a DSN component like Python's `quote(value, safe="")`,
|
|
||||||
/// which leaves the RFC 3986 unreserved characters alone.
|
|
||||||
fn quote(value: &str) -> String {
|
|
||||||
const QUOTE_SET: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
|
|
||||||
.remove(b'_')
|
|
||||||
.remove(b'-')
|
|
||||||
.remove(b'.')
|
|
||||||
.remove(b'~');
|
|
||||||
percent_encoding::utf8_percent_encode(value, QUOTE_SET).to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn database_url_from_parts(
|
|
||||||
engine: &str,
|
|
||||||
user: &str,
|
|
||||||
password: Option<&str>,
|
|
||||||
host: &str,
|
|
||||||
port: &str,
|
|
||||||
name: &str,
|
|
||||||
options: &str,
|
|
||||||
) -> String {
|
|
||||||
let mut netloc = quote(user);
|
|
||||||
if let Some(password) = password {
|
|
||||||
if !password.is_empty() {
|
|
||||||
netloc.push(':');
|
|
||||||
netloc.push_str("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<OidcProviderConfig>,
|
|
||||||
pub app_host: String,
|
|
||||||
pub app_port: u16,
|
|
||||||
pub redis_url: Option<String>,
|
|
||||||
pub game_ttl_seconds: i64,
|
|
||||||
pub static_dir: String,
|
|
||||||
pub hand_ack_timeout_seconds: i64,
|
|
||||||
pub turn_timeout_seconds: i64,
|
|
||||||
pub deadline_heartbeat_ms: u64,
|
|
||||||
/// Accepted for compatibility; Rust logging is configured via RUST_LOG.
|
|
||||||
pub logging_config: Option<String>,
|
|
||||||
pub cors_allow_origins: Option<Vec<String>>,
|
|
||||||
pub cors_allow_origin_regex: Option<String>,
|
|
||||||
pub cors_allow_methods: Option<Vec<String>>,
|
|
||||||
pub cors_allow_headers: Option<Vec<String>>,
|
|
||||||
pub cors_allow_credentials: bool,
|
|
||||||
pub cors_expose_headers: Option<Vec<String>>,
|
|
||||||
pub cors_max_age: i64,
|
|
||||||
pub otel_enabled: bool,
|
|
||||||
pub otel_service_name: String,
|
|
||||||
pub otel_exporter_endpoint: Option<String>,
|
|
||||||
pub otel_exporter_headers: Option<Vec<String>>,
|
|
||||||
pub otel_excluded_paths: Vec<String>,
|
|
||||||
pub otel_metric_export_interval_millis: u64,
|
|
||||||
pub otel_metrics_poll_interval_seconds: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Settings {
|
|
||||||
pub fn from_env() -> Result<Self, String> {
|
|
||||||
let database_url = env("DATABASE_URL").unwrap_or_else(|| {
|
|
||||||
database_url_from_parts(
|
|
||||||
&env_or("DATABASE_ENGINE", "postgres"),
|
|
||||||
&env_or("DATABASE_USER", "tavolo"),
|
|
||||||
Some(&env_or("DATABASE_PASSWORD", "password")),
|
|
||||||
&env_or("DATABASE_HOST", "localhost"),
|
|
||||||
&std::env::var("DATABASE_PORT").unwrap_or_default(),
|
|
||||||
&env_or("DATABASE_NAME", "tavolo"),
|
|
||||||
&std::env::var("DATABASE_OPTIONS").unwrap_or_default(),
|
|
||||||
)
|
|
||||||
});
|
|
||||||
let app_port = env_or("APP_PORT", "8080")
|
|
||||||
.parse::<u16>()
|
|
||||||
.map_err(|_| "APP_PORT must be a valid port number".to_string())?;
|
|
||||||
let otel_headers = env_list("OTEL_EXPORTER_OTLP_HEADERS");
|
|
||||||
Ok(Self {
|
|
||||||
database_url,
|
|
||||||
oidc_providers: parse_oidc_providers()?,
|
|
||||||
app_host: env_or("APP_HOST", "0.0.0.0"),
|
|
||||||
app_port,
|
|
||||||
redis_url: env("REDIS_URL"),
|
|
||||||
game_ttl_seconds: env_or("GAME_TTL_SECONDS", "86400")
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| "GAME_TTL_SECONDS must be an integer".to_string())?,
|
|
||||||
static_dir: env_or("STATIC_DIR", "web/dist"),
|
|
||||||
hand_ack_timeout_seconds: env_or("HAND_ACK_TIMEOUT_SECONDS", "30")
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| "HAND_ACK_TIMEOUT_SECONDS must be an integer".to_string())?,
|
|
||||||
turn_timeout_seconds: env_or("TURN_TIMEOUT_SECONDS", "30")
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| "TURN_TIMEOUT_SECONDS must be an integer".to_string())?,
|
|
||||||
deadline_heartbeat_ms: env_or("DEADLINE_HEARTBEAT_MS", "30000")
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| "DEADLINE_HEARTBEAT_MS must be an integer".to_string())?,
|
|
||||||
logging_config: env("LOGGING_CONFIG"),
|
|
||||||
cors_allow_origins: env_list("CORS_ALLOW_ORIGINS"),
|
|
||||||
cors_allow_origin_regex: env("CORS_ALLOW_ORIGIN_REGEX"),
|
|
||||||
cors_allow_methods: env_list("CORS_ALLOW_METHODS"),
|
|
||||||
cors_allow_headers: env_list("CORS_ALLOW_HEADERS"),
|
|
||||||
cors_allow_credentials: env_bool("CORS_ALLOW_CREDENTIALS", false),
|
|
||||||
cors_expose_headers: env_list("CORS_EXPOSE_HEADERS"),
|
|
||||||
cors_max_age: env_or("CORS_MAX_AGE", "600")
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| "CORS_MAX_AGE must be an integer".to_string())?,
|
|
||||||
otel_enabled: env_bool("OTEL_ENABLED", false),
|
|
||||||
otel_service_name: env_or("OTEL_SERVICE_NAME", "tavolo"),
|
|
||||||
otel_exporter_endpoint: env("OTEL_EXPORTER_OTLP_ENDPOINT"),
|
|
||||||
otel_exporter_headers: otel_headers,
|
|
||||||
otel_excluded_paths: env_list("OTEL_EXCLUDED_PATHS")
|
|
||||||
.unwrap_or_else(|| vec!["/api/health".to_string()]),
|
|
||||||
otel_metric_export_interval_millis: env_or(
|
|
||||||
"OTEL_METRIC_EXPORT_INTERVAL_MILLIS",
|
|
||||||
"60000",
|
|
||||||
)
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| "OTEL_METRIC_EXPORT_INTERVAL_MILLIS must be an integer".to_string())?,
|
|
||||||
otel_metrics_poll_interval_seconds: env_or("OTEL_METRICS_POLL_INTERVAL_SECONDS", "15")
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| "OTEL_METRICS_POLL_INTERVAL_SECONDS must be an integer".to_string())?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,354 +0,0 @@
|
|||||||
//! CORS middleware replicating kaya-cors' `CorsMixin` (modeled after
|
|
||||||
//! Starlette's `CORSMiddleware`).
|
|
||||||
//!
|
|
||||||
//! * requests without an `Origin` header pass through untouched;
|
|
||||||
//! * preflight requests (`OPTIONS` carrying `Origin` and
|
|
||||||
//! `Access-Control-Request-Method`) are answered directly: `200 OK` when
|
|
||||||
//! the origin, method and headers are allowed, `400` with a
|
|
||||||
//! `Disallowed CORS ...` body otherwise;
|
|
||||||
//! * simple cross-origin requests get `Access-Control-Allow-Origin` (and the
|
|
||||||
//! configured credentials/expose headers) merged into the response without
|
|
||||||
//! overwriting headers the handler already set.
|
|
||||||
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use axum::extract::{Request, State};
|
|
||||||
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode};
|
|
||||||
use axum::middleware::Next;
|
|
||||||
use axum::response::Response;
|
|
||||||
use regex::Regex;
|
|
||||||
|
|
||||||
const ALL_METHODS: [&str; 8] = [
|
|
||||||
"DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT", "QUERY",
|
|
||||||
];
|
|
||||||
const SAFELISTED_HEADERS: [&str; 4] = [
|
|
||||||
"Accept",
|
|
||||||
"Accept-Language",
|
|
||||||
"Content-Language",
|
|
||||||
"Content-Type",
|
|
||||||
];
|
|
||||||
|
|
||||||
pub struct CorsConfig {
|
|
||||||
allow_all_origins: bool,
|
|
||||||
allow_credentials: bool,
|
|
||||||
allow_origins: Vec<String>,
|
|
||||||
allow_origin_regex: Option<Regex>,
|
|
||||||
allow_methods: Vec<String>,
|
|
||||||
allow_all_headers: bool,
|
|
||||||
/// Lowercased set checked against requested headers.
|
|
||||||
allow_headers_lower: HashSet<String>,
|
|
||||||
preflight_explicit_allow_origin: bool,
|
|
||||||
simple_headers: Vec<(HeaderName, HeaderValue)>,
|
|
||||||
preflight_headers: Vec<(HeaderName, HeaderValue)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CorsConfig {
|
|
||||||
pub fn new(
|
|
||||||
allow_origins: &[String],
|
|
||||||
allow_methods: Option<&[String]>,
|
|
||||||
allow_headers: Option<&[String]>,
|
|
||||||
allow_credentials: bool,
|
|
||||||
allow_origin_regex: Option<&str>,
|
|
||||||
expose_headers: &[String],
|
|
||||||
max_age: i64,
|
|
||||||
) -> Option<Self> {
|
|
||||||
if allow_origins.is_empty() && allow_origin_regex.is_none() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let allow_all_origins = allow_origins.iter().any(|origin| origin == "*");
|
|
||||||
let allow_all_headers = allow_headers
|
|
||||||
.map(|headers| headers.iter().any(|header| header == "*"))
|
|
||||||
.unwrap_or(false);
|
|
||||||
let methods: Vec<String> = match allow_methods {
|
|
||||||
Some(methods) if methods.iter().any(|method| method == "*") => ALL_METHODS
|
|
||||||
.iter()
|
|
||||||
.map(|method| method.to_string())
|
|
||||||
.collect(),
|
|
||||||
Some(methods) => methods.to_vec(),
|
|
||||||
None => vec!["GET".to_string()],
|
|
||||||
};
|
|
||||||
|
|
||||||
let configured = allow_headers.unwrap_or_default();
|
|
||||||
let mut sorted_allow_headers: Vec<String> = SAFELISTED_HEADERS
|
|
||||||
.iter()
|
|
||||||
.map(|header| header.to_string())
|
|
||||||
.chain(configured.iter().cloned())
|
|
||||||
.collect::<HashSet<_>>()
|
|
||||||
.into_iter()
|
|
||||||
.collect();
|
|
||||||
sorted_allow_headers.sort();
|
|
||||||
let allow_headers_lower: HashSet<String> = sorted_allow_headers
|
|
||||||
.iter()
|
|
||||||
.map(|header| header.to_lowercase())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let preflight_explicit_allow_origin = !allow_all_origins || allow_credentials;
|
|
||||||
let simple_headers =
|
|
||||||
build_simple_headers(allow_all_origins, allow_credentials, expose_headers);
|
|
||||||
let preflight_headers = build_preflight_headers(
|
|
||||||
&methods,
|
|
||||||
&sorted_allow_headers,
|
|
||||||
allow_all_headers,
|
|
||||||
allow_credentials,
|
|
||||||
max_age,
|
|
||||||
preflight_explicit_allow_origin,
|
|
||||||
);
|
|
||||||
|
|
||||||
Some(Self {
|
|
||||||
allow_all_origins,
|
|
||||||
allow_credentials,
|
|
||||||
allow_origins: allow_origins.to_vec(),
|
|
||||||
allow_origin_regex: allow_origin_regex.and_then(|pattern| Regex::new(pattern).ok()),
|
|
||||||
allow_methods: methods,
|
|
||||||
allow_all_headers,
|
|
||||||
allow_headers_lower,
|
|
||||||
preflight_explicit_allow_origin,
|
|
||||||
simple_headers,
|
|
||||||
preflight_headers,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_allowed_origin(&self, origin: &str) -> bool {
|
|
||||||
if self.allow_all_origins {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if let Some(regex) = &self.allow_origin_regex {
|
|
||||||
if regex.is_match(origin) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.allow_origins.iter().any(|allowed| allowed == origin)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn simple_response_headers(&self, origin: &str) -> Vec<(HeaderName, HeaderValue)> {
|
|
||||||
let mut headers = self.simple_headers.clone();
|
|
||||||
if self.allow_all_origins && self.allow_credentials {
|
|
||||||
// Credentials require the specific origin instead of '*'.
|
|
||||||
upsert(
|
|
||||||
&mut headers,
|
|
||||||
header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
|
||||||
HeaderValue::from_str(origin).unwrap_or(HeaderValue::from_static("*")),
|
|
||||||
);
|
|
||||||
upsert(
|
|
||||||
&mut headers,
|
|
||||||
header::VARY,
|
|
||||||
HeaderValue::from_static("Origin"),
|
|
||||||
);
|
|
||||||
} else if !self.allow_all_origins && self.is_allowed_origin(origin) {
|
|
||||||
// Specific origins are mirrored back in the response.
|
|
||||||
upsert(
|
|
||||||
&mut headers,
|
|
||||||
header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
|
||||||
HeaderValue::from_str(origin).unwrap_or(HeaderValue::from_static("*")),
|
|
||||||
);
|
|
||||||
upsert(
|
|
||||||
&mut headers,
|
|
||||||
header::VARY,
|
|
||||||
HeaderValue::from_static("Origin"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
headers
|
|
||||||
}
|
|
||||||
|
|
||||||
fn preflight_response(&self, request_headers: &HeaderMap, origin: &str) -> Response {
|
|
||||||
let mut headers = self.preflight_headers.clone();
|
|
||||||
let mut failures: Vec<&'static str> = Vec::new();
|
|
||||||
|
|
||||||
if self.is_allowed_origin(origin) {
|
|
||||||
if self.preflight_explicit_allow_origin {
|
|
||||||
if let Ok(value) = HeaderValue::from_str(origin) {
|
|
||||||
headers.push((header::ACCESS_CONTROL_ALLOW_ORIGIN, value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
failures.push("origin");
|
|
||||||
}
|
|
||||||
|
|
||||||
let requested_method = request_headers
|
|
||||||
.get("access-control-request-method")
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.unwrap_or_default();
|
|
||||||
if !self
|
|
||||||
.allow_methods
|
|
||||||
.iter()
|
|
||||||
.any(|method| method == requested_method)
|
|
||||||
{
|
|
||||||
failures.push("method");
|
|
||||||
}
|
|
||||||
|
|
||||||
let requested_headers = request_headers
|
|
||||||
.get("access-control-request-headers")
|
|
||||||
.and_then(|value| value.to_str().ok());
|
|
||||||
if self.allow_all_headers && requested_headers.is_some() {
|
|
||||||
if let Ok(value) = HeaderValue::from_str(requested_headers.unwrap_or_default()) {
|
|
||||||
headers.push((header::ACCESS_CONTROL_ALLOW_HEADERS, value));
|
|
||||||
}
|
|
||||||
} else if let Some(requested_headers) = requested_headers {
|
|
||||||
for header in requested_headers.split(',') {
|
|
||||||
if !self
|
|
||||||
.allow_headers_lower
|
|
||||||
.contains(header.trim().to_lowercase().as_str())
|
|
||||||
{
|
|
||||||
failures.push("headers");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let (status, body) = if failures.is_empty() {
|
|
||||||
(StatusCode::OK, "OK".to_string())
|
|
||||||
} else {
|
|
||||||
(
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
format!("Disallowed CORS {}", failures.join(", ")),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut response = Response::new(axum::body::Body::from(body));
|
|
||||||
*response.status_mut() = status;
|
|
||||||
response.headers_mut().insert(
|
|
||||||
header::CONTENT_TYPE,
|
|
||||||
HeaderValue::from_static("text/plain; charset=utf-8"),
|
|
||||||
);
|
|
||||||
for (name, value) in headers {
|
|
||||||
if !response.headers().contains_key(&name) {
|
|
||||||
response.headers_mut().insert(name, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
response
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn upsert(headers: &mut Vec<(HeaderName, HeaderValue)>, name: HeaderName, value: HeaderValue) {
|
|
||||||
if let Some(existing) = headers.iter_mut().find(|(existing, _)| *existing == name) {
|
|
||||||
existing.1 = value;
|
|
||||||
} else {
|
|
||||||
headers.push((name, value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_simple_headers(
|
|
||||||
allow_all_origins: bool,
|
|
||||||
allow_credentials: bool,
|
|
||||||
expose_headers: &[String],
|
|
||||||
) -> Vec<(HeaderName, HeaderValue)> {
|
|
||||||
let mut headers = Vec::new();
|
|
||||||
if allow_all_origins {
|
|
||||||
headers.push((
|
|
||||||
header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
|
||||||
HeaderValue::from_static("*"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if allow_credentials {
|
|
||||||
headers.push((
|
|
||||||
header::ACCESS_CONTROL_ALLOW_CREDENTIALS,
|
|
||||||
HeaderValue::from_static("true"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if !expose_headers.is_empty() {
|
|
||||||
if let Ok(value) = HeaderValue::from_str(&expose_headers.join(", ")) {
|
|
||||||
headers.push((header::ACCESS_CONTROL_EXPOSE_HEADERS, value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
headers
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_preflight_headers(
|
|
||||||
methods: &[String],
|
|
||||||
sorted_allow_headers: &[String],
|
|
||||||
allow_all_headers: bool,
|
|
||||||
allow_credentials: bool,
|
|
||||||
max_age: i64,
|
|
||||||
explicit_allow_origin: bool,
|
|
||||||
) -> Vec<(HeaderName, HeaderValue)> {
|
|
||||||
let mut headers = Vec::new();
|
|
||||||
if explicit_allow_origin {
|
|
||||||
headers.push((header::VARY, HeaderValue::from_static("Origin")));
|
|
||||||
} else {
|
|
||||||
headers.push((
|
|
||||||
header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
|
||||||
HeaderValue::from_static("*"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if let Ok(value) = HeaderValue::from_str(&methods.join(", ")) {
|
|
||||||
headers.push((header::ACCESS_CONTROL_ALLOW_METHODS, value));
|
|
||||||
}
|
|
||||||
if let Ok(value) = HeaderValue::from_str(&max_age.to_string()) {
|
|
||||||
headers.push((header::ACCESS_CONTROL_MAX_AGE, value));
|
|
||||||
}
|
|
||||||
if !sorted_allow_headers.is_empty() && !allow_all_headers {
|
|
||||||
if let Ok(value) = HeaderValue::from_str(&sorted_allow_headers.join(", ")) {
|
|
||||||
headers.push((header::ACCESS_CONTROL_ALLOW_HEADERS, value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if allow_credentials {
|
|
||||||
headers.push((
|
|
||||||
header::ACCESS_CONTROL_ALLOW_CREDENTIALS,
|
|
||||||
HeaderValue::from_static("true"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
headers
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Merge CORS headers into a response: headers already set by the handler are
|
|
||||||
/// never overwritten; a `Vary` value is appended to an existing header when
|
|
||||||
/// not already present.
|
|
||||||
fn merge_headers(response: &mut Response, headers: Vec<(HeaderName, HeaderValue)>) {
|
|
||||||
for (name, value) in headers {
|
|
||||||
if name == header::VARY {
|
|
||||||
let existing = response
|
|
||||||
.headers()
|
|
||||||
.get(header::VARY)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string();
|
|
||||||
let present = existing
|
|
||||||
.split(',')
|
|
||||||
.any(|part| part.trim().eq_ignore_ascii_case("origin"));
|
|
||||||
if present {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if existing.is_empty() {
|
|
||||||
response.headers_mut().insert(header::VARY, value);
|
|
||||||
} else {
|
|
||||||
if let Ok(combined) = HeaderValue::from_str(&format!(
|
|
||||||
"{existing}, {}",
|
|
||||||
value.to_str().unwrap_or("Origin")
|
|
||||||
)) {
|
|
||||||
response.headers_mut().insert(header::VARY, combined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if !response.headers().contains_key(&name) {
|
|
||||||
response.headers_mut().insert(name, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn cors_middleware(
|
|
||||||
State(config): State<Arc<CorsConfig>>,
|
|
||||||
request: Request,
|
|
||||||
next: Next,
|
|
||||||
) -> Response {
|
|
||||||
let origin = request
|
|
||||||
.headers()
|
|
||||||
.get(header::ORIGIN)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.map(str::to_string);
|
|
||||||
let Some(origin) = origin else {
|
|
||||||
return next.run(request).await;
|
|
||||||
};
|
|
||||||
if request.method() == Method::OPTIONS
|
|
||||||
&& request
|
|
||||||
.headers()
|
|
||||||
.contains_key("access-control-request-method")
|
|
||||||
{
|
|
||||||
return config.preflight_response(request.headers(), &origin);
|
|
||||||
}
|
|
||||||
let headers = config.simple_response_headers(&origin);
|
|
||||||
let mut response = next.run(request).await;
|
|
||||||
merge_headers(&mut response, headers);
|
|
||||||
response
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
pub mod app;
|
|
||||||
pub mod config;
|
|
||||||
pub mod cors;
|
|
||||||
pub mod logging;
|
|
||||||
pub mod static_files;
|
|
||||||
pub mod telemetry;
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
//! 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();
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
//! The `tavolo` binary: `serve` (default), `migrate` and `backfill-elo`.
|
|
||||||
//!
|
|
||||||
//! `migrate` replaces the Python `aerich upgrade` migration service: it
|
|
||||||
//! applies the embedded sqlx migrations (idempotent against databases already
|
|
||||||
//! at the aerich schema head). `backfill-elo` replaces
|
|
||||||
//! `python -m tavolo.platform.backfill_elo`.
|
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
|
||||||
|
|
||||||
use clap::{Parser, Subcommand};
|
|
||||||
use tavolo_app::app;
|
|
||||||
use tavolo_app::config::Settings;
|
|
||||||
use tavolo_app::logging::configure_logging;
|
|
||||||
use tavolo_platform::stats::{PostgresStatsRepo, StatsRepo};
|
|
||||||
|
|
||||||
#[derive(Parser)]
|
|
||||||
#[command(
|
|
||||||
name = "tavolo",
|
|
||||||
version,
|
|
||||||
about = "Multiplayer card-game platform backend"
|
|
||||||
)]
|
|
||||||
struct Cli {
|
|
||||||
#[command(subcommand)]
|
|
||||||
command: Option<Command>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
|
||||||
enum Command {
|
|
||||||
/// Serve the HTTP + WebSocket application (default).
|
|
||||||
Serve,
|
|
||||||
/// Apply the database migrations and exit.
|
|
||||||
Migrate,
|
|
||||||
/// Recompute every Elo rating from the recorded match history.
|
|
||||||
BackfillElo {
|
|
||||||
/// Database URL; defaults to the configured DATABASE_* parts.
|
|
||||||
#[arg(long, env = "DATABASE_URL")]
|
|
||||||
database_url: Option<String>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let cli = Cli::parse();
|
|
||||||
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime");
|
|
||||||
let result = match cli.command.unwrap_or(Command::Serve) {
|
|
||||||
Command::Serve => runtime.block_on(serve()),
|
|
||||||
Command::Migrate => runtime.block_on(migrate()),
|
|
||||||
Command::BackfillElo { database_url } => {
|
|
||||||
runtime.block_on(backfill_elo(database_url.as_deref()))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if let Err(message) = result {
|
|
||||||
eprintln!("error: {message}");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn serve() -> Result<(), String> {
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use tavolo_platform::stats::{PostgresStatsRepo, SharedStatsRepo};
|
|
||||||
|
|
||||||
let settings = Settings::from_env()?;
|
|
||||||
let registry = app::build_registry(&settings)?;
|
|
||||||
let (session_store, game_store) = app::build_stores(&settings, registry.clone()).await?;
|
|
||||||
let telemetry =
|
|
||||||
tavolo_app::telemetry::init(&settings, session_store.clone(), game_store.clone())?;
|
|
||||||
configure_logging(&settings, telemetry.as_ref());
|
|
||||||
tracing::info!(
|
|
||||||
"registered games: {}",
|
|
||||||
registry
|
|
||||||
.all()
|
|
||||||
.iter()
|
|
||||||
.map(|engine| engine.id())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(", ")
|
|
||||||
);
|
|
||||||
if settings.redis_url.is_some() {
|
|
||||||
tracing::info!(
|
|
||||||
"using Redis stores (sessions + live games, game TTL {}s)",
|
|
||||||
settings.game_ttl_seconds
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
tracing::info!("REDIS_URL unset: using in-memory stores (sessions + live games)");
|
|
||||||
}
|
|
||||||
if telemetry.is_some() {
|
|
||||||
tracing::info!(
|
|
||||||
"OpenTelemetry enabled: service={} endpoint={} (traces, metrics, logs)",
|
|
||||||
settings.otel_service_name,
|
|
||||||
settings
|
|
||||||
.otel_exporter_endpoint
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("(OTLP default)"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let pool = app::connect_database(&settings).await?;
|
|
||||||
let stats: SharedStatsRepo = Arc::new(PostgresStatsRepo::new(pool));
|
|
||||||
let platform = app::build_platform(&settings, registry, game_store, stats)?;
|
|
||||||
let router = app::build_router(platform, session_store, &settings);
|
|
||||||
let address: SocketAddr = format!("{}:{}", settings.app_host, settings.app_port)
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| {
|
|
||||||
format!(
|
|
||||||
"invalid bind address {}:{}",
|
|
||||||
settings.app_host, settings.app_port
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let listener = tokio::net::TcpListener::bind(address)
|
|
||||||
.await
|
|
||||||
.map_err(|err| format!("could not bind {address}: {err}"))?;
|
|
||||||
tracing::info!("listening on http://{address}");
|
|
||||||
axum::serve(listener, router)
|
|
||||||
.with_graceful_shutdown(shutdown_signal())
|
|
||||||
.await
|
|
||||||
.map_err(|err| format!("server error: {err}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn shutdown_signal() {
|
|
||||||
let _ = tokio::signal::ctrl_c().await;
|
|
||||||
tracing::info!("shutting down");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn database_url(override_url: Option<&str>) -> Result<String, String> {
|
|
||||||
match override_url {
|
|
||||||
Some(url) => Ok(url.to_string()),
|
|
||||||
None => Ok(Settings::from_env()?.database_url),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn migrate() -> Result<(), String> {
|
|
||||||
let settings = Settings::from_env()?;
|
|
||||||
configure_logging(&settings, None);
|
|
||||||
let url = database_url(None)?;
|
|
||||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
|
||||||
.max_connections(1)
|
|
||||||
.connect(&url)
|
|
||||||
.await
|
|
||||||
.map_err(|err| format!("could not connect to the database: {err}"))?;
|
|
||||||
sqlx::migrate!("./migrations")
|
|
||||||
.run(&pool)
|
|
||||||
.await
|
|
||||||
.map_err(|err| format!("migration failed: {err}"))?;
|
|
||||||
println!("migrations applied");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn backfill_elo(override_url: Option<&str>) -> Result<(), String> {
|
|
||||||
let url = database_url(override_url)?;
|
|
||||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
|
||||||
.max_connections(1)
|
|
||||||
.connect(&url)
|
|
||||||
.await
|
|
||||||
.map_err(|err| format!("could not connect to the database: {err}"))?;
|
|
||||||
let repo = PostgresStatsRepo::new(pool);
|
|
||||||
let replayed = repo
|
|
||||||
.backfill_elo()
|
|
||||||
.await
|
|
||||||
.map_err(|err| format!("backfill failed: {err}"))?;
|
|
||||||
println!("Recomputed ratings from {replayed} matches.");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
//! SPA shell hosting and static asset mounts.
|
|
||||||
//!
|
|
||||||
//! Static assets (wasm, js, css, card images) are served by
|
|
||||||
//! [`tower_http::services::ServeDir`] under `/static` (mapped to the frontend
|
|
||||||
//! build root) and `/assets` (mapped to `<root>/assets`), mirroring the
|
|
||||||
//! Granian static mounts of the Python deployment. Every path no API or auth
|
|
||||||
//! route claimed falls back to `index.html`, so client-side routes
|
|
||||||
//! (`/game/<id>`) work on direct loads and refreshes.
|
|
||||||
|
|
||||||
use std::convert::Infallible;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::task::{Context, Poll};
|
|
||||||
|
|
||||||
use axum::body::Body;
|
|
||||||
use axum::extract::Request;
|
|
||||||
use axum::http::{header, StatusCode};
|
|
||||||
use axum::response::{IntoResponse, Response};
|
|
||||||
use tower::Service;
|
|
||||||
|
|
||||||
/// Fallback service serving the SPA shell, or 404 when the frontend build is
|
|
||||||
/// missing.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct SpaFallback {
|
|
||||||
index: Arc<PathBuf>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SpaFallback {
|
|
||||||
pub fn new(static_dir: &str) -> Self {
|
|
||||||
Self {
|
|
||||||
index: Arc::new(Path::new(static_dir).join("index.html")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Service<Request> for SpaFallback {
|
|
||||||
type Response = Response;
|
|
||||||
type Error = Infallible;
|
|
||||||
type Future = std::pin::Pin<
|
|
||||||
Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>,
|
|
||||||
>;
|
|
||||||
|
|
||||||
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
|
||||||
Poll::Ready(Ok(()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn call(&mut self, _request: Request) -> Self::Future {
|
|
||||||
let index = Arc::clone(&self.index);
|
|
||||||
Box::pin(async move {
|
|
||||||
match tokio::fs::read(index.as_ref()).await {
|
|
||||||
Ok(body) => Ok((
|
|
||||||
StatusCode::OK,
|
|
||||||
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
.into_response()),
|
|
||||||
Err(_) => Ok(StatusCode::NOT_FOUND.into_response()),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `/static` and `/assets` mount directories for a build root.
|
|
||||||
pub fn mount_dirs(static_dir: &str) -> (PathBuf, PathBuf) {
|
|
||||||
let root = PathBuf::from(static_dir);
|
|
||||||
let assets = root.join("assets");
|
|
||||||
(root, assets)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a 404 response body-less response (kept for symmetry with the SPA
|
|
||||||
/// fallback).
|
|
||||||
pub fn not_found() -> Response {
|
|
||||||
Body::empty().into_response()
|
|
||||||
}
|
|
||||||
@@ -1,389 +0,0 @@
|
|||||||
//! OpenTelemetry wiring: OTLP traces, metrics and logs.
|
|
||||||
//!
|
|
||||||
//! Mirrors `tavolo.telemetry` from the Python implementation within what the
|
|
||||||
//! Rust ecosystem offers:
|
|
||||||
//!
|
|
||||||
//! * **traces** — HTTP request spans (tagged with a hashed `session.id` and
|
|
||||||
//! the OIDC `user.id`) and one span per WebSocket connection, exported over
|
|
||||||
//! OTLP/HTTP;
|
|
||||||
//! * **metrics** — `tavolo.active_users` (sessions in the session store),
|
|
||||||
//! `tavolo.ongoing_games` (live matches not finished yet) and Rust process
|
|
||||||
//! metrics (`process.cpu.time`, `process.cpu.utilization`,
|
|
||||||
//! `process.memory.usage`, `process.memory.virtual`) in place of the
|
|
||||||
//! CPython-specific gauges;
|
|
||||||
//! * **logs** — tracing events forwarded to OTLP (OpenTelemetry's own records
|
|
||||||
//! are filtered out upstream).
|
|
||||||
//!
|
|
||||||
//! Database client instrumentation (asyncpg/redis spans in Python) has no
|
|
||||||
//! direct Rust equivalent and is omitted.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crate::config::Settings;
|
|
||||||
use tavolo_platform::session::SharedSessionStore;
|
|
||||||
use tavolo_platform::store::SharedGameStore;
|
|
||||||
|
|
||||||
#[cfg(feature = "otel")]
|
|
||||||
mod inner {
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::atomic::{AtomicI64, Ordering};
|
|
||||||
use std::sync::{Arc, Mutex, OnceLock};
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use opentelemetry_otlp::{
|
|
||||||
LogExporter, MetricExporter, SpanExporter, WithExportConfig, WithHttpConfig,
|
|
||||||
};
|
|
||||||
use opentelemetry_sdk::logs::SdkLoggerProvider;
|
|
||||||
use opentelemetry_sdk::metrics::{PeriodicReader, SdkMeterProvider};
|
|
||||||
use opentelemetry_sdk::trace::SdkTracerProvider;
|
|
||||||
use opentelemetry_sdk::Resource;
|
|
||||||
|
|
||||||
use super::{Settings, SharedGameStore, SharedSessionStore};
|
|
||||||
|
|
||||||
pub(super) static HTTP_DURATION: OnceLock<opentelemetry::metrics::Histogram<f64>> =
|
|
||||||
OnceLock::new();
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Default)]
|
|
||||||
pub(super) struct ProcessSnapshot {
|
|
||||||
pub cpu_seconds: f64,
|
|
||||||
pub cpu_utilization: f64,
|
|
||||||
pub memory_usage: u64,
|
|
||||||
pub memory_virtual: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) struct OtelInner {
|
|
||||||
pub tracer_provider: SdkTracerProvider,
|
|
||||||
pub meter_provider: SdkMeterProvider,
|
|
||||||
pub logger_provider: SdkLoggerProvider,
|
|
||||||
pub poll_task: tokio::task::JoinHandle<()>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for OtelInner {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.poll_task.abort();
|
|
||||||
let _ = self.logger_provider.shutdown();
|
|
||||||
let _ = self.meter_provider.shutdown();
|
|
||||||
let _ = self.tracer_provider.shutdown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn signal_endpoint(base: Option<&str>, signal: &str) -> Option<String> {
|
|
||||||
base.map(|base| format!("{base}/{signal}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_headers(raw: Option<&[String]>) -> HashMap<String, String> {
|
|
||||||
raw.unwrap_or(&[])
|
|
||||||
.iter()
|
|
||||||
.filter_map(|pair| pair.split_once('='))
|
|
||||||
.map(|(key, value)| (key.to_string(), value.trim().to_string()))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn refresh_process(snapshot: &Mutex<ProcessSnapshot>, pid: sysinfo::Pid) {
|
|
||||||
use sysinfo::ProcessesToUpdate;
|
|
||||||
let mut system = sysinfo::System::new();
|
|
||||||
system.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
|
|
||||||
if let Some(process) = system.process(pid) {
|
|
||||||
let mut snapshot = snapshot.lock().expect("process snapshot");
|
|
||||||
snapshot.cpu_seconds = process.accumulated_cpu_time() as f64 / 1000.0;
|
|
||||||
snapshot.cpu_utilization = process.cpu_usage() as f64;
|
|
||||||
snapshot.memory_usage = process.memory();
|
|
||||||
snapshot.memory_virtual = process.virtual_memory();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn init(
|
|
||||||
settings: &Settings,
|
|
||||||
session_store: SharedSessionStore,
|
|
||||||
game_store: SharedGameStore,
|
|
||||||
) -> Result<Option<super::Telemetry>, String> {
|
|
||||||
let endpoint = settings.otel_exporter_endpoint.clone();
|
|
||||||
let headers = parse_headers(settings.otel_exporter_headers.as_deref());
|
|
||||||
let resource = Resource::builder()
|
|
||||||
.with_service_name(settings.otel_service_name.clone())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let span_exporter = SpanExporter::builder()
|
|
||||||
.with_http()
|
|
||||||
.with_endpoint(signal_endpoint(endpoint.as_deref(), "v1/traces").unwrap_or_default())
|
|
||||||
.with_headers(headers.clone())
|
|
||||||
.build()
|
|
||||||
.map_err(|err| format!("could not build the OTLP span exporter: {err}"))?;
|
|
||||||
let tracer_provider = SdkTracerProvider::builder()
|
|
||||||
.with_batch_exporter(span_exporter)
|
|
||||||
.with_resource(resource.clone())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let metric_exporter = MetricExporter::builder()
|
|
||||||
.with_http()
|
|
||||||
.with_endpoint(signal_endpoint(endpoint.as_deref(), "v1/metrics").unwrap_or_default())
|
|
||||||
.with_headers(headers.clone())
|
|
||||||
.build()
|
|
||||||
.map_err(|err| format!("could not build the OTLP metric exporter: {err}"))?;
|
|
||||||
let reader = PeriodicReader::builder(metric_exporter)
|
|
||||||
.with_interval(Duration::from_millis(
|
|
||||||
settings.otel_metric_export_interval_millis.max(1),
|
|
||||||
))
|
|
||||||
.build();
|
|
||||||
let meter_provider = SdkMeterProvider::builder()
|
|
||||||
.with_reader(reader)
|
|
||||||
.with_resource(resource.clone())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let log_exporter = LogExporter::builder()
|
|
||||||
.with_http()
|
|
||||||
.with_endpoint(signal_endpoint(endpoint.as_deref(), "v1/logs").unwrap_or_default())
|
|
||||||
.with_headers(headers)
|
|
||||||
.build()
|
|
||||||
.map_err(|err| format!("could not build the OTLP log exporter: {err}"))?;
|
|
||||||
let logger_provider = SdkLoggerProvider::builder()
|
|
||||||
.with_batch_exporter(log_exporter)
|
|
||||||
.with_resource(resource)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
opentelemetry::global::set_meter_provider(meter_provider.clone());
|
|
||||||
let meter = opentelemetry::metrics::MeterProvider::meter(&meter_provider, "tavolo");
|
|
||||||
|
|
||||||
let active_users = Arc::new(AtomicI64::new(0));
|
|
||||||
let ongoing_games = Arc::new(AtomicI64::new(0));
|
|
||||||
let process = Arc::new(Mutex::new(ProcessSnapshot::default()));
|
|
||||||
let pid = sysinfo::get_current_pid()
|
|
||||||
.map_err(|err| format!("could not determine the current process id: {err}"))?;
|
|
||||||
refresh_process(&process, pid);
|
|
||||||
|
|
||||||
let _ = meter
|
|
||||||
.u64_observable_gauge("tavolo.active_users")
|
|
||||||
.with_description("Sessions currently held in the session store.")
|
|
||||||
.with_unit("{user}")
|
|
||||||
.with_callback({
|
|
||||||
let active_users = active_users.clone();
|
|
||||||
move |observer| {
|
|
||||||
observer.observe(active_users.load(Ordering::Relaxed).max(0) as u64, &[])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.build();
|
|
||||||
let _ = meter
|
|
||||||
.u64_observable_gauge("tavolo.ongoing_games")
|
|
||||||
.with_description("Live games that have not finished yet.")
|
|
||||||
.with_unit("{game}")
|
|
||||||
.with_callback({
|
|
||||||
let ongoing_games = ongoing_games.clone();
|
|
||||||
move |observer| {
|
|
||||||
observer.observe(ongoing_games.load(Ordering::Relaxed).max(0) as u64, &[])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.build();
|
|
||||||
let _ = meter
|
|
||||||
.f64_observable_gauge("process.cpu.utilization")
|
|
||||||
.with_description("Process CPU utilization since the last poll, in percent.")
|
|
||||||
.with_callback({
|
|
||||||
let process = process.clone();
|
|
||||||
move |observer| {
|
|
||||||
let snapshot = process.lock().expect("process snapshot");
|
|
||||||
observer.observe(snapshot.cpu_utilization / 100.0, &[])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.build();
|
|
||||||
let _ = meter
|
|
||||||
.u64_observable_gauge("process.memory.usage")
|
|
||||||
.with_description("Process resident memory, in bytes.")
|
|
||||||
.with_unit("By")
|
|
||||||
.with_callback({
|
|
||||||
let process = process.clone();
|
|
||||||
move |observer| {
|
|
||||||
let snapshot = process.lock().expect("process snapshot");
|
|
||||||
observer.observe(snapshot.memory_usage, &[])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.build();
|
|
||||||
let _ = meter
|
|
||||||
.u64_observable_gauge("process.memory.virtual")
|
|
||||||
.with_description("Process virtual memory, in bytes.")
|
|
||||||
.with_unit("By")
|
|
||||||
.with_callback({
|
|
||||||
let process = process.clone();
|
|
||||||
move |observer| {
|
|
||||||
let snapshot = process.lock().expect("process snapshot");
|
|
||||||
observer.observe(snapshot.memory_virtual, &[])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.build();
|
|
||||||
let _ = meter
|
|
||||||
.f64_observable_counter("process.cpu.time")
|
|
||||||
.with_description("Process CPU time, in seconds.")
|
|
||||||
.with_unit("s")
|
|
||||||
.with_callback({
|
|
||||||
let process = process.clone();
|
|
||||||
move |observer| {
|
|
||||||
let snapshot = process.lock().expect("process snapshot");
|
|
||||||
observer.observe(snapshot.cpu_seconds, &[]);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let _ = HTTP_DURATION.set(
|
|
||||||
meter
|
|
||||||
.f64_histogram("http.server.request.duration")
|
|
||||||
.with_description("HTTP server request duration.")
|
|
||||||
.with_unit("s")
|
|
||||||
.build(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let poll_interval = Duration::from_secs(settings.otel_metrics_poll_interval_seconds.max(1));
|
|
||||||
let poll_task = tokio::spawn({
|
|
||||||
let session_store = session_store.clone();
|
|
||||||
let game_store = game_store.clone();
|
|
||||||
let active_users = active_users.clone();
|
|
||||||
let ongoing_games = ongoing_games.clone();
|
|
||||||
let process = process.clone();
|
|
||||||
async move {
|
|
||||||
loop {
|
|
||||||
match session_store.count().await {
|
|
||||||
Ok(count) => active_users.store(count as i64, Ordering::Relaxed),
|
|
||||||
Err(err) => tracing::warn!("active_users refresh failed: {err}"),
|
|
||||||
}
|
|
||||||
match game_store.count_ongoing().await {
|
|
||||||
Ok(count) => ongoing_games.store(count as i64, Ordering::Relaxed),
|
|
||||||
Err(err) => tracing::warn!("ongoing_games refresh failed: {err}"),
|
|
||||||
}
|
|
||||||
let process = process.clone();
|
|
||||||
let _ =
|
|
||||||
tokio::task::spawn_blocking(move || refresh_process(&process, pid)).await;
|
|
||||||
tokio::time::sleep(poll_interval).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(Some(super::Telemetry {
|
|
||||||
inner: OtelInner {
|
|
||||||
tracer_provider,
|
|
||||||
meter_provider,
|
|
||||||
logger_provider,
|
|
||||||
poll_task,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The live telemetry pipeline; dropping it shuts the providers down.
|
|
||||||
pub struct Telemetry {
|
|
||||||
#[cfg(feature = "otel")]
|
|
||||||
inner: inner::OtelInner,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "otel")]
|
|
||||||
impl Telemetry {
|
|
||||||
pub fn tracer(&self) -> opentelemetry_sdk::trace::SdkTracer {
|
|
||||||
use opentelemetry::trace::TracerProvider as _;
|
|
||||||
self.inner.tracer_provider.tracer("tavolo")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn logger_provider(&self) -> &opentelemetry_sdk::logs::SdkLoggerProvider {
|
|
||||||
&self.inner.logger_provider
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Initialize the telemetry pipeline when `OTEL_ENABLED` is set.
|
|
||||||
pub fn init(
|
|
||||||
settings: &Settings,
|
|
||||||
session_store: SharedSessionStore,
|
|
||||||
game_store: SharedGameStore,
|
|
||||||
) -> Result<Option<Telemetry>, String> {
|
|
||||||
if !settings.otel_enabled {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
#[cfg(feature = "otel")]
|
|
||||||
{
|
|
||||||
inner::init(settings, session_store, game_store)
|
|
||||||
}
|
|
||||||
#[cfg(not(feature = "otel"))]
|
|
||||||
{
|
|
||||||
let _ = (session_store, game_store);
|
|
||||||
Err("OTEL_ENABLED is set but the binary was built without the 'otel' feature".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tag a span with the hashed session id and the authenticated user.
|
|
||||||
pub fn session_attributes(
|
|
||||||
session: &tavolo_platform::session::SessionData,
|
|
||||||
) -> (Option<String>, Option<String>) {
|
|
||||||
let session_id = session.id().map(hash_session_id);
|
|
||||||
let user = tavolo_platform::auth::session_user(session).map(|user| user.sub);
|
|
||||||
(session_id, user)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Truncated SHA-256 of the session id (the cookie is a credential, so it is
|
|
||||||
/// never exported verbatim).
|
|
||||||
pub fn hash_session_id(session_id: &str) -> String {
|
|
||||||
tavolo_platform::auth::session_tag(session_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An HTTP span per request, tagged with session/user; excludes the paths in
|
|
||||||
/// `OTEL_EXCLUDED_PATHS`. Also records the HTTP server duration histogram.
|
|
||||||
pub async fn trace_middleware(
|
|
||||||
excluded: Arc<Vec<String>>,
|
|
||||||
request: axum::extract::Request,
|
|
||||||
next: axum::middleware::Next,
|
|
||||||
) -> axum::response::Response {
|
|
||||||
use tracing::Instrument;
|
|
||||||
|
|
||||||
let path = request.uri().path().to_string();
|
|
||||||
if excluded.contains(&path) {
|
|
||||||
return next.run(request).await;
|
|
||||||
}
|
|
||||||
let method = request.method().clone();
|
|
||||||
let span = tracing::info_span!(
|
|
||||||
"http.server.request",
|
|
||||||
"http.request.method" = %method,
|
|
||||||
"url.path" = %path,
|
|
||||||
"http.response.status_code" = tracing::field::Empty,
|
|
||||||
"session.id" = tracing::field::Empty,
|
|
||||||
"user.id" = tracing::field::Empty,
|
|
||||||
);
|
|
||||||
let session = request
|
|
||||||
.extensions()
|
|
||||||
.get::<tavolo_platform::session::SessionHandle>()
|
|
||||||
.cloned();
|
|
||||||
async move {
|
|
||||||
let started = std::time::Instant::now();
|
|
||||||
let response = next.run(request).await;
|
|
||||||
let status = response.status().as_u16();
|
|
||||||
tracing::Span::current().record("http.response.status_code", status);
|
|
||||||
// Recorded after the handler ran so logins/mutations are visible;
|
|
||||||
// the session middleware mints the cookie (and its id on first use)
|
|
||||||
// after this layer returns.
|
|
||||||
if let Some(session) = session {
|
|
||||||
let guard = session.lock().await;
|
|
||||||
let (session_id, user) = session_attributes(&guard);
|
|
||||||
if let Some(session_id) = session_id {
|
|
||||||
tracing::Span::current().record("session.id", session_id);
|
|
||||||
}
|
|
||||||
if let Some(user) = user {
|
|
||||||
tracing::Span::current().record("user.id", user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
record_http_duration(started.elapsed(), &method, status);
|
|
||||||
response
|
|
||||||
}
|
|
||||||
.instrument(span)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
fn record_http_duration(duration: std::time::Duration, method: &axum::http::Method, status: u16) {
|
|
||||||
#[cfg(feature = "otel")]
|
|
||||||
{
|
|
||||||
if let Some(histogram) = inner::HTTP_DURATION.get() {
|
|
||||||
histogram.record(
|
|
||||||
duration.as_secs_f64(),
|
|
||||||
&[
|
|
||||||
opentelemetry::KeyValue::new("http.request.method", method.to_string()),
|
|
||||||
opentelemetry::KeyValue::new("http.response.status_code", status as i64),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[cfg(not(feature = "otel"))]
|
|
||||||
{
|
|
||||||
let _ = (duration, method, status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,319 +0,0 @@
|
|||||||
//! End-to-end OIDC login flow against a mock provider.
|
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use axum::extract::{Query, State};
|
|
||||||
use axum::routing::{get, post};
|
|
||||||
use axum::{Json, Router};
|
|
||||||
use chrono::Utc;
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use tower::ServiceExt;
|
|
||||||
|
|
||||||
use tavolo_app::app;
|
|
||||||
use tavolo_app::config::Settings;
|
|
||||||
use tavolo_platform::oidc::OidcProviderConfig;
|
|
||||||
|
|
||||||
const RSA_PEM: &str = r#"-----BEGIN PRIVATE KEY-----
|
|
||||||
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCMKcE4AgsStq5u
|
|
||||||
l3Rtg9p2XNN5l5N5z1orqDJmRHAJvSNqhr2BHxu5Q1m3o8prvE+lHRqe1lgJXKi5
|
|
||||||
8aITv6hDKkS9gmp+tvVQ/VJnnKSbgaSJSMEf5P5GoLR43SrsTEtbxlVO03kwCkiH
|
|
||||||
EoOihK0h4GgyIE/3UWkgsJ8eJKi+czBzBffX8+qfR29vJGEk1mhpHIBnHI+19pLV
|
|
||||||
q0GvMcqKA4hnTZwB43/8EI6lZb59r1ZJLvz87AKd/VnXW4zki16PHntkrrRfRl+U
|
|
||||||
Q5k+ErSYp0SXtyh9mONzxGDQ+ra7gIx8itTnSAbYTwi846UzSTltCCYqTwkvi4nm
|
|
||||||
aCmKn3mdAgMBAAECggEARUGKegC0dPo0JWwLuONKSmriiXWmhq04qlKxGIwfagh9
|
|
||||||
W/EY6Ri0a63GQnGZnvNMzVKcqThTBsk0i7BD/Asa+pkDnzo2IceIXozVL2IcicQc
|
|
||||||
x0LePkK5i69zjTcHMvwdzkqpM0pV2rFyFd/FkEOLgq4t/fquWI9u6RqsfMnzYSqx
|
|
||||||
XxS4JsC6dEgNK69lH2OTWvmpgpujrwWAiJVNlKY0Ehh0R8Wruy7iPjo6NLWH8sQq
|
|
||||||
XlCxIoeWCcPgCbtQRmLgNJPdFB8zSTYGFqGJ5lfYq/7qkcQsAEwIUJBF+EMU+yK5
|
|
||||||
y8jxiGF+8wDg4bYovNxs0uxU/HltHPGDfe1Yn2OyNwKBgQDA7GNsj4hxDME0rXnI
|
|
||||||
8SMnSjOVQRsmNTa6Je3XKXuTxzqXwYBndILmTYOmejYnQJ7oToAn1Jiyk9yo+Mzd
|
|
||||||
2sxKU9iHMVABuLiFmbUm269WB2Zh7fQ7ErhIVGTNsPsHv2iuSuOktGQw6Cd+9pBH
|
|
||||||
0H8HCf9dWOzQ01lqdKw60UfH2wKBgQC5/VnnCtQ4n+aAzSYLYgZf97XTTKN2+gGq
|
|
||||||
z2caPga/3yA+1dPx18JjVqlEuAxazXQPxgAL2ZnGHQfLR+M0lInHhljrALKHTzMl
|
|
||||||
MsmLTq+J8DChlGIPJIKc0Uk4vLSJLMkaHBS9gM9z49zdGx2ltI4gGUJIKk4z5gT0
|
|
||||||
iqRFXM1Z5wKBgAuOna4lMxFCqFhVhBQrjeWOpmRskAnL6klgeE0JaSPDLZlluCF6
|
|
||||||
c1UEk7xnWdMkJeDYwwifzb306aUdsngf4VkaiMAgjiBsRnGKcbWwN2K58sIvFRBs
|
|
||||||
4KlElP1VetRN/SFjzxtQyye64JLUhnQm41Uynh+jchxYbOTZtG+Zlek9AoGADPsP
|
|
||||||
sT5iPWl7jvXCd/zpcP065Xpt1jnwr9k12vEFS5LpDzmhRznFyWUc/zzRL+0y8n7h
|
|
||||||
NfKD9v3Ds35a7yqGyzQnNI+h5iuy6B+5TU26lqf63OwcQ+5KZ2N79sfE2eYUSww2
|
|
||||||
rls1H2OB4LSpxwzo9niM1biOKLUfF2otuOGXO3UCgYBIDAvpabTpw6z4IBQGwHKP
|
|
||||||
WOTFrtI/y0Zqf3iNusqb8RdCIokDHvT+fKdvI7VXzaASWt2GajMHArWB6uu/1Xeh
|
|
||||||
nF9SaK1F+mKyGv9a5m5We9VzRNXmaUaQBRLK3XjBr1Co4/UduYRDdqZo/E/5g8gr
|
|
||||||
BMZbIcEMr49N2ns4yHzJ3w==
|
|
||||||
-----END PRIVATE KEY-----
|
|
||||||
"#;
|
|
||||||
|
|
||||||
const JWK: &str = r#"{"kty":"RSA","use":"sig","alg":"RS256","kid":"test-key","n":"jCnBOAILEraubpd0bYPadlzTeZeTec9aK6gyZkRwCb0jaoa9gR8buUNZt6PKa7xPpR0antZYCVyoufGiE7-oQypEvYJqfrb1UP1SZ5ykm4GkiUjBH-T-RqC0eN0q7ExLW8ZVTtN5MApIhxKDooStIeBoMiBP91FpILCfHiSovnMwcwX31_Pqn0dvbyRhJNZoaRyAZxyPtfaS1atBrzHKigOIZ02cAeN__BCOpWW-fa9WSS78_OwCnf1Z11uM5Itejx57ZK60X0ZflEOZPhK0mKdEl7cofZjjc8Rg0Pq2u4CMfIrU50gG2E8IvOOlM0k5bQgmKk8JL4uJ5mgpip95nQ","e":"AQAB"}"#;
|
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
|
||||||
struct IdpState {
|
|
||||||
issuer: String,
|
|
||||||
nonce: Arc<Mutex<String>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Idp {
|
|
||||||
state: IdpState,
|
|
||||||
addr: SocketAddr,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn start_idp() -> Idp {
|
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
||||||
.await
|
|
||||||
.expect("bind idp");
|
|
||||||
let addr = listener.local_addr().expect("idp addr");
|
|
||||||
let base = format!("http://{addr}");
|
|
||||||
let state = IdpState {
|
|
||||||
issuer: base.clone(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let router = Router::new()
|
|
||||||
.route(
|
|
||||||
"/.well-known/openid-configuration",
|
|
||||||
get({
|
|
||||||
let base = base.clone();
|
|
||||||
move || async move {
|
|
||||||
Json(json!({
|
|
||||||
"issuer": base,
|
|
||||||
"authorization_endpoint": format!("{base}/authorize"),
|
|
||||||
"token_endpoint": format!("{base}/token"),
|
|
||||||
"userinfo_endpoint": format!("{base}/userinfo"),
|
|
||||||
"jwks_uri": format!("{base}/jwks"),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/authorize",
|
|
||||||
get(
|
|
||||||
|State(state): State<IdpState>,
|
|
||||||
Query(params): Query<std::collections::HashMap<String, String>>| async move {
|
|
||||||
if let Some(nonce) = params.get("nonce") {
|
|
||||||
*state.nonce.lock().await = nonce.clone();
|
|
||||||
}
|
|
||||||
let state_param = params.get("state").cloned().unwrap_or_default();
|
|
||||||
let redirect_uri = params
|
|
||||||
.get("redirect_uri")
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| "/".into());
|
|
||||||
let location = format!("{redirect_uri}?code=test-code&state={state_param}");
|
|
||||||
(
|
|
||||||
axum::http::StatusCode::FOUND,
|
|
||||||
[(axum::http::header::LOCATION, location)],
|
|
||||||
)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/token",
|
|
||||||
post(|State(state): State<IdpState>| async move {
|
|
||||||
let nonce = state.nonce.lock().await.clone();
|
|
||||||
let now = Utc::now().timestamp();
|
|
||||||
let claims = json!({
|
|
||||||
"iss": state.issuer,
|
|
||||||
"aud": "tavolo",
|
|
||||||
"sub": "alice",
|
|
||||||
"exp": now + 600,
|
|
||||||
"iat": now,
|
|
||||||
"nonce": nonce,
|
|
||||||
});
|
|
||||||
let token = jsonwebtoken::encode(
|
|
||||||
&jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256),
|
|
||||||
&claims,
|
|
||||||
&jsonwebtoken::EncodingKey::from_rsa_pem(RSA_PEM.as_bytes()).unwrap(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
Json(json!({
|
|
||||||
"access_token": "access-token",
|
|
||||||
"token_type": "Bearer",
|
|
||||||
"id_token": token,
|
|
||||||
}))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/userinfo",
|
|
||||||
get(|| async { Json(json!({"sub": "alice", "preferred_username": "Alice"})) }),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/jwks",
|
|
||||||
get(|| async { Json(json!({"keys": [serde_json::from_str::<Value>(JWK).unwrap()]})) }),
|
|
||||||
)
|
|
||||||
.with_state(state.clone());
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let _ = axum::serve(listener, router).await;
|
|
||||||
});
|
|
||||||
Idp { state, addr }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn test_settings(issuer: &str, redirect_uri: &str) -> Settings {
|
|
||||||
Settings {
|
|
||||||
database_url: "postgres://unused".into(),
|
|
||||||
oidc_providers: vec![OidcProviderConfig {
|
|
||||||
provider: "default".into(),
|
|
||||||
issuer: issuer.into(),
|
|
||||||
client_id: "tavolo".into(),
|
|
||||||
client_secret: Some("dev-secret".into()),
|
|
||||||
redirect_uri: redirect_uri.into(),
|
|
||||||
post_login_redirect: "/".into(),
|
|
||||||
post_logout_redirect: "/".into(),
|
|
||||||
}],
|
|
||||||
app_host: "127.0.0.1".into(),
|
|
||||||
app_port: 0,
|
|
||||||
redis_url: None,
|
|
||||||
game_ttl_seconds: 86400,
|
|
||||||
static_dir: "/nonexistent-web-dist".into(),
|
|
||||||
hand_ack_timeout_seconds: 30,
|
|
||||||
turn_timeout_seconds: 30,
|
|
||||||
deadline_heartbeat_ms: 50,
|
|
||||||
logging_config: None,
|
|
||||||
cors_allow_origins: None,
|
|
||||||
cors_allow_origin_regex: None,
|
|
||||||
cors_allow_methods: None,
|
|
||||||
cors_allow_headers: None,
|
|
||||||
cors_allow_credentials: false,
|
|
||||||
cors_expose_headers: None,
|
|
||||||
cors_max_age: 600,
|
|
||||||
otel_enabled: false,
|
|
||||||
otel_service_name: "tavolo".into(),
|
|
||||||
otel_exporter_endpoint: None,
|
|
||||||
otel_exporter_headers: None,
|
|
||||||
otel_excluded_paths: vec!["/api/health".into()],
|
|
||||||
otel_metric_export_interval_millis: 60000,
|
|
||||||
otel_metrics_poll_interval_seconds: 15,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cookie_header(response: &axum::response::Response) -> String {
|
|
||||||
let value = response
|
|
||||||
.headers()
|
|
||||||
.get(axum::http::header::SET_COOKIE)
|
|
||||||
.expect("set-cookie")
|
|
||||||
.to_str()
|
|
||||||
.expect("cookie header");
|
|
||||||
value.split(';').next().expect("cookie pair").to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn oidc_login_callback_and_logout_flow() {
|
|
||||||
// The mock IdP must know its own issuer before we can start it, so bind
|
|
||||||
// first and rebuild the settings from the recorded address.
|
|
||||||
let idp = start_idp().await;
|
|
||||||
let issuer = format!("http://{}", idp.addr);
|
|
||||||
let callback = "http://localhost:8080/auth/default/callback".to_string();
|
|
||||||
let settings = test_settings(&issuer, &callback);
|
|
||||||
|
|
||||||
let (platform, session_store) = app::in_memory_platform(&settings).expect("platform");
|
|
||||||
let router = app::build_router(platform, session_store.clone(), &settings);
|
|
||||||
|
|
||||||
// 1. Start the login: a 302 to the provider with state/nonce/PKCE and a
|
|
||||||
// fresh session cookie.
|
|
||||||
let response = router
|
|
||||||
.clone()
|
|
||||||
.oneshot(
|
|
||||||
axum::http::Request::builder()
|
|
||||||
.uri("/auth/default/login")
|
|
||||||
.body(axum::body::Body::empty())
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(response.status(), 302);
|
|
||||||
let location = response.headers()["location"].to_str().unwrap().to_string();
|
|
||||||
let login_cookie = cookie_header(&response);
|
|
||||||
let url = url::Url::parse(&location).expect("authorization url");
|
|
||||||
let params: std::collections::HashMap<String, String> = url
|
|
||||||
.query_pairs()
|
|
||||||
.map(|(key, value)| (key.into_owned(), value.into_owned()))
|
|
||||||
.collect();
|
|
||||||
assert_eq!(params["client_id"], "tavolo");
|
|
||||||
assert_eq!(params["code_challenge_method"], "S256");
|
|
||||||
assert_eq!(params["scope"], "openid email profile");
|
|
||||||
assert_eq!(
|
|
||||||
params["redirect_uri"],
|
|
||||||
"http://localhost:8080/auth/default/callback"
|
|
||||||
);
|
|
||||||
let state = params["state"].clone();
|
|
||||||
// Simulate the browser visiting the provider: the mock signs the nonce
|
|
||||||
// the client generated.
|
|
||||||
*idp.state.nonce.lock().await = params["nonce"].clone();
|
|
||||||
|
|
||||||
// 2. The provider redirects back with the code and state.
|
|
||||||
let response = router
|
|
||||||
.clone()
|
|
||||||
.oneshot(
|
|
||||||
axum::http::Request::builder()
|
|
||||||
.uri(format!(
|
|
||||||
"/auth/default/callback?code=test-code&state={state}"
|
|
||||||
))
|
|
||||||
.header("cookie", &login_cookie)
|
|
||||||
.body(axum::body::Body::empty())
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(response.status(), 302);
|
|
||||||
assert_eq!(response.headers()["location"], "/");
|
|
||||||
let session_cookie = cookie_header(&response);
|
|
||||||
assert_ne!(
|
|
||||||
session_cookie, login_cookie,
|
|
||||||
"session id must rotate on login"
|
|
||||||
);
|
|
||||||
|
|
||||||
// A wrong state is rejected without touching the provider.
|
|
||||||
let response = router
|
|
||||||
.clone()
|
|
||||||
.oneshot(
|
|
||||||
axum::http::Request::builder()
|
|
||||||
.uri("/auth/default/callback?code=test-code&state=wrong")
|
|
||||||
.header("cookie", &login_cookie)
|
|
||||||
.body(axum::body::Body::empty())
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(response.status(), 400);
|
|
||||||
|
|
||||||
// 3. The session is authenticated and /api/me reflects the userinfo.
|
|
||||||
let response = router
|
|
||||||
.clone()
|
|
||||||
.oneshot(
|
|
||||||
axum::http::Request::builder()
|
|
||||||
.uri("/api/me")
|
|
||||||
.header("cookie", &session_cookie)
|
|
||||||
.body(axum::body::Body::empty())
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(response.status(), 200);
|
|
||||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let me: Value = serde_json::from_slice(&body).unwrap();
|
|
||||||
assert_eq!(me, json!({"sub": "alice", "name": "Alice"}));
|
|
||||||
|
|
||||||
// 4. Logout clears the session and sends an expired cookie.
|
|
||||||
let response = router
|
|
||||||
.clone()
|
|
||||||
.oneshot(
|
|
||||||
axum::http::Request::builder()
|
|
||||||
.uri("/auth/default/logout")
|
|
||||||
.header("cookie", &session_cookie)
|
|
||||||
.body(axum::body::Body::empty())
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(response.status(), 302);
|
|
||||||
let clear_cookie = response
|
|
||||||
.headers()
|
|
||||||
.get(axum::http::header::SET_COOKIE)
|
|
||||||
.unwrap()
|
|
||||||
.to_str()
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
clear_cookie.contains("Max-Age=0"),
|
|
||||||
"cookie not cleared: {clear_cookie}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,380 +0,0 @@
|
|||||||
//! Configuration parsing and CORS behavior of the app composition root.
|
|
||||||
|
|
||||||
use std::sync::Mutex;
|
|
||||||
|
|
||||||
use axum::body::Body;
|
|
||||||
use axum::http::{Request, StatusCode};
|
|
||||||
use tower::ServiceExt;
|
|
||||||
|
|
||||||
use tavolo_app::app;
|
|
||||||
use tavolo_app::config::Settings;
|
|
||||||
use tavolo_platform::oidc::OidcProviderConfig;
|
|
||||||
|
|
||||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
|
||||||
|
|
||||||
const ENV_VARS: &[&str] = &[
|
|
||||||
"DATABASE_URL",
|
|
||||||
"DATABASE_ENGINE",
|
|
||||||
"DATABASE_HOST",
|
|
||||||
"DATABASE_PORT",
|
|
||||||
"DATABASE_NAME",
|
|
||||||
"DATABASE_USER",
|
|
||||||
"DATABASE_PASSWORD",
|
|
||||||
"DATABASE_OPTIONS",
|
|
||||||
"APP_HOST",
|
|
||||||
"APP_PORT",
|
|
||||||
"REDIS_URL",
|
|
||||||
"GAME_TTL_SECONDS",
|
|
||||||
"STATIC_DIR",
|
|
||||||
"HAND_ACK_TIMEOUT_SECONDS",
|
|
||||||
"TURN_TIMEOUT_SECONDS",
|
|
||||||
"DEADLINE_HEARTBEAT_MS",
|
|
||||||
"LOGGING_CONFIG",
|
|
||||||
"CORS_ALLOW_ORIGINS",
|
|
||||||
"CORS_ALLOW_ORIGIN_REGEX",
|
|
||||||
"CORS_ALLOW_METHODS",
|
|
||||||
"CORS_ALLOW_HEADERS",
|
|
||||||
"CORS_ALLOW_CREDENTIALS",
|
|
||||||
"CORS_EXPOSE_HEADERS",
|
|
||||||
"CORS_MAX_AGE",
|
|
||||||
"OTEL_ENABLED",
|
|
||||||
"OTEL_SERVICE_NAME",
|
|
||||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
|
||||||
"OTEL_EXPORTER_OTLP_HEADERS",
|
|
||||||
"OTEL_EXCLUDED_PATHS",
|
|
||||||
"OTEL_METRIC_EXPORT_INTERVAL_MILLIS",
|
|
||||||
"OTEL_METRICS_POLL_INTERVAL_SECONDS",
|
|
||||||
"OIDC_ISSUER",
|
|
||||||
"OIDC_CLIENT_ID",
|
|
||||||
"OIDC_CLIENT_SECRET",
|
|
||||||
"OIDC_REDIRECT_URI",
|
|
||||||
"OIDC_POST_LOGIN_REDIRECT",
|
|
||||||
"OIDC_POST_LOGOUT_REDIRECT",
|
|
||||||
];
|
|
||||||
|
|
||||||
fn clear_oidc_env() {
|
|
||||||
for (name, _) in std::env::vars() {
|
|
||||||
if name.starts_with("OIDC_") {
|
|
||||||
std::env::remove_var(&name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for name in ENV_VARS {
|
|
||||||
std::env::remove_var(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn with_env<T>(vars: &[(&str, &str)], f: impl FnOnce() -> T) -> T {
|
|
||||||
let _guard = ENV_LOCK.lock().expect("env lock");
|
|
||||||
clear_oidc_env();
|
|
||||||
for (name, value) in vars {
|
|
||||||
std::env::set_var(name, value);
|
|
||||||
}
|
|
||||||
let result = f();
|
|
||||||
clear_oidc_env();
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn defaults_match_the_python_settings() {
|
|
||||||
let settings = with_env(&[], Settings::from_env).expect("settings");
|
|
||||||
assert_eq!(settings.app_host, "0.0.0.0");
|
|
||||||
assert_eq!(settings.app_port, 8080);
|
|
||||||
assert_eq!(settings.game_ttl_seconds, 86400);
|
|
||||||
assert_eq!(settings.static_dir, "web/dist");
|
|
||||||
assert_eq!(settings.oidc_providers.len(), 1);
|
|
||||||
let provider = &settings.oidc_providers[0];
|
|
||||||
assert_eq!(provider.provider, "default");
|
|
||||||
assert_eq!(provider.issuer, "http://localhost:8180/tavolo");
|
|
||||||
assert_eq!(provider.client_id, "tavolo");
|
|
||||||
assert_eq!(
|
|
||||||
provider.redirect_uri,
|
|
||||||
"http://localhost:8080/auth/default/callback"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
settings.database_url,
|
|
||||||
"postgres://tavolo:password@localhost/tavolo"
|
|
||||||
);
|
|
||||||
assert!(!settings.otel_enabled);
|
|
||||||
assert_eq!(settings.otel_excluded_paths, vec!["/api/health"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn indexed_oidc_providers_are_parsed_in_order() {
|
|
||||||
let settings = with_env(
|
|
||||||
&[
|
|
||||||
("OIDC_0_PROVIDER", "google"),
|
|
||||||
("OIDC_0_ISSUER", "https://accounts.google.com"),
|
|
||||||
("OIDC_0_CLIENT_ID", "gid"),
|
|
||||||
("OIDC_0_CLIENT_SECRET", "gsecret"),
|
|
||||||
("OIDC_1_PROVIDER", "github"),
|
|
||||||
("OIDC_1_ISSUER", "https://github.com/login/oauth"),
|
|
||||||
("OIDC_1_CLIENT_ID", "ghid"),
|
|
||||||
],
|
|
||||||
Settings::from_env,
|
|
||||||
)
|
|
||||||
.expect("settings");
|
|
||||||
assert_eq!(settings.oidc_providers.len(), 2);
|
|
||||||
assert_eq!(settings.oidc_providers[0].provider, "google");
|
|
||||||
assert_eq!(
|
|
||||||
settings.oidc_providers[0].redirect_uri,
|
|
||||||
"http://localhost:8080/auth/google/callback"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
settings.oidc_providers[0].client_secret.as_deref(),
|
|
||||||
Some("gsecret")
|
|
||||||
);
|
|
||||||
assert_eq!(settings.oidc_providers[1].provider, "github");
|
|
||||||
assert!(settings.oidc_providers[1].client_secret.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn invalid_oidc_configurations_are_rejected() {
|
|
||||||
let error = with_env(
|
|
||||||
&[
|
|
||||||
("OIDC_0_PROVIDER", "a"),
|
|
||||||
("OIDC_0_ISSUER", "https://x"),
|
|
||||||
("OIDC_0_CLIENT_ID", "x"),
|
|
||||||
("OIDC_2_PROVIDER", "b"),
|
|
||||||
("OIDC_2_ISSUER", "https://y"),
|
|
||||||
("OIDC_2_CLIENT_ID", "y"),
|
|
||||||
],
|
|
||||||
Settings::from_env,
|
|
||||||
)
|
|
||||||
.expect_err("missing index must fail");
|
|
||||||
assert!(error.contains("contiguous"), "{error}");
|
|
||||||
|
|
||||||
let error = with_env(&[("OIDC_ISSUER", "https://legacy")], Settings::from_env)
|
|
||||||
.expect_err("legacy variables must fail");
|
|
||||||
assert!(error.contains("indexed per provider"), "{error}");
|
|
||||||
|
|
||||||
let error = with_env(
|
|
||||||
&[
|
|
||||||
("OIDC_0_PROVIDER", "bad name!"),
|
|
||||||
("OIDC_0_ISSUER", "https://x"),
|
|
||||||
("OIDC_0_CLIENT_ID", "x"),
|
|
||||||
],
|
|
||||||
Settings::from_env,
|
|
||||||
)
|
|
||||||
.expect_err("invalid provider names must fail");
|
|
||||||
assert!(error.contains("letters, digits"), "{error}");
|
|
||||||
|
|
||||||
let error = with_env(
|
|
||||||
&[
|
|
||||||
("OIDC_0_PROVIDER", "a"),
|
|
||||||
("OIDC_0_ISSUER", "https://x"),
|
|
||||||
("OIDC_0_CLIENT_ID", "x"),
|
|
||||||
("OIDC_0_WAT", "?"),
|
|
||||||
],
|
|
||||||
Settings::from_env,
|
|
||||||
)
|
|
||||||
.expect_err("unknown variables must fail");
|
|
||||||
assert!(
|
|
||||||
error.contains("unknown OIDC environment variable"),
|
|
||||||
"{error}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn database_dsn_quotes_credentials() {
|
|
||||||
let settings = with_env(
|
|
||||||
&[
|
|
||||||
("DATABASE_USER", "ta:v@lo"),
|
|
||||||
("DATABASE_PASSWORD", "p@ss word"),
|
|
||||||
("DATABASE_HOST", "db.example"),
|
|
||||||
("DATABASE_PORT", "5433"),
|
|
||||||
("DATABASE_NAME", "tavolo"),
|
|
||||||
("DATABASE_OPTIONS", "ssl=require"),
|
|
||||||
],
|
|
||||||
Settings::from_env,
|
|
||||||
)
|
|
||||||
.expect("settings");
|
|
||||||
assert_eq!(
|
|
||||||
settings.database_url,
|
|
||||||
"postgres://ta%3Av%40lo:p%40ss%20word@db.example:5433/tavolo?ssl=require"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn cors_is_disabled_without_configuration() {
|
|
||||||
let settings = base_settings();
|
|
||||||
let (platform, sessions) = app::in_memory_platform(&settings).expect("platform");
|
|
||||||
let router = app::build_router(platform, sessions, &settings);
|
|
||||||
let response = router
|
|
||||||
.clone()
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.uri("/api/health")
|
|
||||||
.header("origin", "https://cards.example")
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
assert!(response
|
|
||||||
.headers()
|
|
||||||
.get("access-control-allow-origin")
|
|
||||||
.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn cors_allows_configured_origins_and_regexes() {
|
|
||||||
let mut settings = base_settings();
|
|
||||||
settings.cors_allow_origins = Some(vec!["https://cards.example".into()]);
|
|
||||||
settings.cors_allow_origin_regex = Some(r"https://preview-[a-z0-9-]+\.example\.com".into());
|
|
||||||
settings.cors_allow_methods = Some(vec!["GET".into(), "POST".into()]);
|
|
||||||
settings.cors_max_age = 3600;
|
|
||||||
let (platform, sessions) = app::in_memory_platform(&settings).expect("platform");
|
|
||||||
let router = app::build_router(platform, sessions, &settings);
|
|
||||||
|
|
||||||
// Allowed origin: echoed. Disallowed origin: no header, request still runs.
|
|
||||||
let response = router
|
|
||||||
.clone()
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.uri("/api/health")
|
|
||||||
.header("origin", "https://cards.example")
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
response.headers()["access-control-allow-origin"],
|
|
||||||
"https://cards.example"
|
|
||||||
);
|
|
||||||
let response = router
|
|
||||||
.clone()
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.uri("/api/health")
|
|
||||||
.header("origin", "https://preview-abc.example.com")
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
response.headers()["access-control-allow-origin"],
|
|
||||||
"https://preview-abc.example.com"
|
|
||||||
);
|
|
||||||
let response = router
|
|
||||||
.clone()
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.uri("/api/health")
|
|
||||||
.header("origin", "https://mallory.example")
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
assert!(response
|
|
||||||
.headers()
|
|
||||||
.get("access-control-allow-origin")
|
|
||||||
.is_none());
|
|
||||||
|
|
||||||
// Preflight.
|
|
||||||
let response = router
|
|
||||||
.clone()
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.method("OPTIONS")
|
|
||||||
.uri("/api/health")
|
|
||||||
.header("origin", "https://cards.example")
|
|
||||||
.header("access-control-request-method", "POST")
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
let methods = response.headers()["access-control-allow-methods"]
|
|
||||||
.to_str()
|
|
||||||
.unwrap()
|
|
||||||
.to_string();
|
|
||||||
assert!(methods.contains("POST"), "{methods}");
|
|
||||||
assert_eq!(response.headers()["access-control-max-age"], "3600");
|
|
||||||
|
|
||||||
let response = router
|
|
||||||
.clone()
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.method("OPTIONS")
|
|
||||||
.uri("/api/health")
|
|
||||||
.header("origin", "https://mallory.example")
|
|
||||||
.header("access-control-request-method", "GET")
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn cors_credentials_echo_the_origin_for_wildcards() {
|
|
||||||
let mut settings = base_settings();
|
|
||||||
settings.cors_allow_origins = Some(vec!["*".into()]);
|
|
||||||
settings.cors_allow_credentials = true;
|
|
||||||
let (platform, sessions) = app::in_memory_platform(&settings).expect("platform");
|
|
||||||
let router = app::build_router(platform, sessions, &settings);
|
|
||||||
let response = router
|
|
||||||
.clone()
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.uri("/api/health")
|
|
||||||
.header("origin", "https://cards.example")
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
response.headers()["access-control-allow-origin"],
|
|
||||||
"https://cards.example"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
response.headers()["access-control-allow-credentials"],
|
|
||||||
"true"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn base_settings() -> Settings {
|
|
||||||
Settings {
|
|
||||||
database_url: "postgres://unused".into(),
|
|
||||||
oidc_providers: vec![OidcProviderConfig {
|
|
||||||
provider: "default".into(),
|
|
||||||
issuer: "http://localhost:8180/tavolo".into(),
|
|
||||||
client_id: "tavolo".into(),
|
|
||||||
client_secret: None,
|
|
||||||
redirect_uri: "http://localhost:8080/auth/default/callback".into(),
|
|
||||||
post_login_redirect: "/".into(),
|
|
||||||
post_logout_redirect: "/".into(),
|
|
||||||
}],
|
|
||||||
app_host: "127.0.0.1".into(),
|
|
||||||
app_port: 0,
|
|
||||||
redis_url: None,
|
|
||||||
game_ttl_seconds: 86400,
|
|
||||||
static_dir: "/nonexistent-web-dist".into(),
|
|
||||||
hand_ack_timeout_seconds: 30,
|
|
||||||
turn_timeout_seconds: 30,
|
|
||||||
deadline_heartbeat_ms: 50,
|
|
||||||
logging_config: None,
|
|
||||||
cors_allow_origins: None,
|
|
||||||
cors_allow_origin_regex: None,
|
|
||||||
cors_allow_methods: None,
|
|
||||||
cors_allow_headers: None,
|
|
||||||
cors_allow_credentials: false,
|
|
||||||
cors_expose_headers: None,
|
|
||||||
cors_max_age: 600,
|
|
||||||
otel_enabled: false,
|
|
||||||
otel_service_name: "tavolo".into(),
|
|
||||||
otel_exporter_endpoint: None,
|
|
||||||
otel_exporter_headers: None,
|
|
||||||
otel_excluded_paths: vec!["/api/health".into()],
|
|
||||||
otel_metric_export_interval_millis: 60000,
|
|
||||||
otel_metrics_poll_interval_seconds: 15,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
//! End-to-end scopone flow: lobby, four players, live play over the
|
|
||||||
//! WebSocket and the turn-timeout auto-play, plus SPA/static hosting.
|
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
|
||||||
|
|
||||||
use axum::body::Body;
|
|
||||||
use axum::http::{Request, StatusCode};
|
|
||||||
use futures_util::{SinkExt, StreamExt};
|
|
||||||
use serde_json::{json, Map, Value};
|
|
||||||
use tower::ServiceExt;
|
|
||||||
|
|
||||||
use tavolo_app::app;
|
|
||||||
use tavolo_app::config::Settings;
|
|
||||||
use tavolo_platform::oidc::OidcProviderConfig;
|
|
||||||
use tavolo_platform::session::SharedSessionStore;
|
|
||||||
|
|
||||||
const PLAYERS: [&str; 4] = ["alice", "bob", "carol", "dave"];
|
|
||||||
|
|
||||||
fn settings(static_dir: &str, turn_timeout: i64) -> Settings {
|
|
||||||
Settings {
|
|
||||||
database_url: "postgres://unused".into(),
|
|
||||||
oidc_providers: vec![OidcProviderConfig {
|
|
||||||
provider: "default".into(),
|
|
||||||
issuer: "http://localhost:8180/tavolo".into(),
|
|
||||||
client_id: "tavolo".into(),
|
|
||||||
client_secret: None,
|
|
||||||
redirect_uri: "http://localhost:8080/auth/default/callback".into(),
|
|
||||||
post_login_redirect: "/".into(),
|
|
||||||
post_logout_redirect: "/".into(),
|
|
||||||
}],
|
|
||||||
app_host: "127.0.0.1".into(),
|
|
||||||
app_port: 0,
|
|
||||||
redis_url: None,
|
|
||||||
game_ttl_seconds: 86400,
|
|
||||||
static_dir: static_dir.into(),
|
|
||||||
hand_ack_timeout_seconds: 30,
|
|
||||||
turn_timeout_seconds: turn_timeout,
|
|
||||||
deadline_heartbeat_ms: 50,
|
|
||||||
logging_config: None,
|
|
||||||
cors_allow_origins: None,
|
|
||||||
cors_allow_origin_regex: None,
|
|
||||||
cors_allow_methods: None,
|
|
||||||
cors_allow_headers: None,
|
|
||||||
cors_allow_credentials: false,
|
|
||||||
cors_expose_headers: None,
|
|
||||||
cors_max_age: 600,
|
|
||||||
otel_enabled: false,
|
|
||||||
otel_service_name: "tavolo".into(),
|
|
||||||
otel_exporter_endpoint: None,
|
|
||||||
otel_exporter_headers: None,
|
|
||||||
otel_excluded_paths: vec!["/api/health".into()],
|
|
||||||
otel_metric_export_interval_millis: 60000,
|
|
||||||
otel_metrics_poll_interval_seconds: 15,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn login(store: &SharedSessionStore, sub: &str, name: &str) -> String {
|
|
||||||
let id = format!("session-{sub}");
|
|
||||||
let mut data = Map::new();
|
|
||||||
data.insert(
|
|
||||||
"oidc:default:user".into(),
|
|
||||||
json!({"sub": sub, "preferred_username": name}),
|
|
||||||
);
|
|
||||||
store.save(&id, &data, Some(3600)).await.expect("session");
|
|
||||||
format!("session_id={id}")
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn body_json(response: axum::response::Response) -> Value {
|
|
||||||
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
||||||
.await
|
|
||||||
.expect("body");
|
|
||||||
serde_json::from_slice(&bytes).expect("json")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn post(uri: &str, cookie: &str, body: &Value) -> Request<Body> {
|
|
||||||
Request::builder()
|
|
||||||
.method("POST")
|
|
||||||
.uri(uri)
|
|
||||||
.header("cookie", cookie)
|
|
||||||
.header("content-type", "application/json")
|
|
||||||
.body(Body::from(serde_json::to_vec(body).unwrap()))
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get(uri: &str, cookie: Option<&str>) -> Request<Body> {
|
|
||||||
let mut builder = Request::builder().uri(uri);
|
|
||||||
if let Some(cookie) = cookie {
|
|
||||||
builder = builder.header("cookie", cookie);
|
|
||||||
}
|
|
||||||
builder.body(Body::empty()).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn start_server(router: axum::Router) -> SocketAddr {
|
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
||||||
.await
|
|
||||||
.expect("bind");
|
|
||||||
let addr = listener.local_addr().expect("addr");
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let _ = axum::serve(listener, router).await;
|
|
||||||
});
|
|
||||||
addr
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ws_request(addr: SocketAddr, game_id: &str, cookie: &str) -> Request<()> {
|
|
||||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
|
||||||
let mut request = format!("ws://{addr}/ws/games/{game_id}")
|
|
||||||
.into_client_request()
|
|
||||||
.expect("request");
|
|
||||||
request.headers_mut().insert(
|
|
||||||
"cookie",
|
|
||||||
axum::http::HeaderValue::from_str(cookie).expect("cookie"),
|
|
||||||
);
|
|
||||||
request
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn read_json(
|
|
||||||
socket: &mut tokio_tungstenite::WebSocketStream<
|
|
||||||
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
|
||||||
>,
|
|
||||||
) -> Value {
|
|
||||||
let message = socket.next().await.expect("message").expect("ok");
|
|
||||||
match message {
|
|
||||||
tokio_tungstenite::tungstenite::Message::Text(text) => {
|
|
||||||
serde_json::from_str(&text).expect("json")
|
|
||||||
}
|
|
||||||
other => panic!("expected text, got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn scopone_lobby_play_and_turn_timeout() {
|
|
||||||
let temp = std::env::temp_dir().join(format!("tavolo-test-{}", uuid::Uuid::new_v4()));
|
|
||||||
std::fs::create_dir_all(temp.join("assets")).expect("temp dir");
|
|
||||||
let test_settings = settings(temp.to_str().unwrap(), 1);
|
|
||||||
let (platform, sessions) = app::in_memory_platform(&test_settings).expect("platform");
|
|
||||||
let router = app::build_router(platform, sessions.clone(), &test_settings);
|
|
||||||
|
|
||||||
// Create the game as alice and join with the other three players.
|
|
||||||
let alice = login(&sessions, "alice", "Alice").await;
|
|
||||||
let created = router
|
|
||||||
.clone()
|
|
||||||
.oneshot(post(
|
|
||||||
"/api/games",
|
|
||||||
&alice,
|
|
||||||
&json!({"options": {"target_score": 11}}),
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(created.status(), StatusCode::CREATED);
|
|
||||||
let lobby = body_json(created).await;
|
|
||||||
let game_id = lobby["id"].as_str().unwrap().to_string();
|
|
||||||
let code = lobby["join_code"].as_str().unwrap().to_string();
|
|
||||||
assert_eq!(lobby["seats_open"], 3);
|
|
||||||
assert_eq!(lobby["players"][0]["team"], "A");
|
|
||||||
|
|
||||||
for (index, player) in PLAYERS.iter().enumerate().skip(1) {
|
|
||||||
let cookie = login(&sessions, player, &player.to_uppercase()).await;
|
|
||||||
let response = router
|
|
||||||
.clone()
|
|
||||||
.oneshot(post("/api/games/join", &cookie, &json!({"code": code})))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(response.status(), StatusCode::OK, "player {player}");
|
|
||||||
let body = body_json(response).await;
|
|
||||||
assert_eq!(body["players"].as_array().unwrap().len(), index + 1);
|
|
||||||
if index == 3 {
|
|
||||||
assert_eq!(body["phase"], "playing");
|
|
||||||
assert_eq!(body["hand_number"], 1);
|
|
||||||
assert_eq!(body["dealer"], 0);
|
|
||||||
assert_eq!(body["turn"], 1);
|
|
||||||
assert_eq!(body["players"][index]["hand"].as_array().unwrap().len(), 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bob is on turn (seat 1): his hand is visible to him and hidden from
|
|
||||||
// alice's personalized view.
|
|
||||||
let bob = login(&sessions, "bob", "Bob").await;
|
|
||||||
let bob_view = body_json(
|
|
||||||
router
|
|
||||||
.clone()
|
|
||||||
.oneshot(get(&format!("/api/games/{game_id}"), Some(&bob)))
|
|
||||||
.await
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(bob_view["turn"], 1);
|
|
||||||
assert_eq!(bob_view["your_turn"], true);
|
|
||||||
assert_eq!(bob_view["players"][1]["hand"].as_array().unwrap().len(), 10);
|
|
||||||
assert!(bob_view["players"][0].get("hand").is_none());
|
|
||||||
assert!(bob_view["legal_moves"].is_object());
|
|
||||||
|
|
||||||
let alice_view = body_json(
|
|
||||||
router
|
|
||||||
.clone()
|
|
||||||
.oneshot(get(&format!("/api/games/{game_id}"), Some(&alice)))
|
|
||||||
.await
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(
|
|
||||||
alice_view["players"][0]["hand"].as_array().unwrap().len(),
|
|
||||||
10
|
|
||||||
);
|
|
||||||
assert!(alice_view["players"][1].get("hand").is_none());
|
|
||||||
|
|
||||||
let addr = start_server(router.clone()).await;
|
|
||||||
let (mut ws, _) = tokio_tungstenite::connect_async(ws_request(addr, &game_id, &bob))
|
|
||||||
.await
|
|
||||||
.expect("bob connects");
|
|
||||||
let state = read_json(&mut ws).await;
|
|
||||||
assert_eq!(state["type"], "state");
|
|
||||||
// The table starts empty and the turn belongs to bob (seat 1).
|
|
||||||
assert_eq!(state["game"]["turn"], 1);
|
|
||||||
assert_eq!(state["game"]["table"].as_array().unwrap().len(), 0);
|
|
||||||
// Bob is on turn on the websocket view.
|
|
||||||
assert_eq!(state["game"]["your_turn"], true);
|
|
||||||
let hand = state["game"]["players"][1]["hand"]
|
|
||||||
.as_array()
|
|
||||||
.unwrap()
|
|
||||||
.clone();
|
|
||||||
let card = hand[0].as_str().unwrap().to_string();
|
|
||||||
let legal = state["game"]["legal_moves"]
|
|
||||||
.as_object()
|
|
||||||
.and_then(|moves| moves.get(&card))
|
|
||||||
.cloned();
|
|
||||||
let mut message = json!({"action": "play", "card": card});
|
|
||||||
if let Some(legal) = legal {
|
|
||||||
if let Some(option) = legal.as_array().and_then(|options| options.first()) {
|
|
||||||
message["capture"] = option.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ws.send(tokio_tungstenite::tungstenite::Message::Text(
|
|
||||||
message.to_string(),
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.expect("send play");
|
|
||||||
let update = read_json(&mut ws).await;
|
|
||||||
assert_eq!(update["type"], "state");
|
|
||||||
assert_eq!(update["game"]["turn"], 2);
|
|
||||||
assert_eq!(update["game"]["last_move"]["seat"], 1);
|
|
||||||
assert_eq!(update["game"]["last_move"]["card"], card);
|
|
||||||
|
|
||||||
// Carol (seat 2) is on turn now with a 1s timeout: the server auto-plays.
|
|
||||||
let before = update["game"]["players"][2]["cards_left"].as_u64().unwrap();
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(1800)).await;
|
|
||||||
let after = body_json(
|
|
||||||
router
|
|
||||||
.clone()
|
|
||||||
.oneshot(get(&format!("/api/games/{game_id}"), Some(&alice)))
|
|
||||||
.await
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(after["turn"], 3);
|
|
||||||
assert_eq!(
|
|
||||||
after["players"][2]["cards_left"].as_u64().unwrap(),
|
|
||||||
before - 1
|
|
||||||
);
|
|
||||||
assert_eq!(after["last_move"]["seat"], 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn spa_shell_and_static_mounts() {
|
|
||||||
let temp = std::env::temp_dir().join(format!("tavolo-static-{}", uuid::Uuid::new_v4()));
|
|
||||||
std::fs::create_dir_all(temp.join("assets")).expect("temp dir");
|
|
||||||
std::fs::write(temp.join("index.html"), "<html>app shell</html>").expect("index");
|
|
||||||
std::fs::write(temp.join("assets/card.png"), b"png-bytes").expect("asset");
|
|
||||||
let test_settings = settings(temp.to_str().unwrap(), 30);
|
|
||||||
let (platform, sessions) = app::in_memory_platform(&test_settings).expect("platform");
|
|
||||||
let router = app::build_router(platform, sessions, &test_settings);
|
|
||||||
|
|
||||||
let response = router.clone().oneshot(get("/", None)).await.unwrap();
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
assert_eq!(
|
|
||||||
response.headers()["content-type"],
|
|
||||||
"text/html; charset=utf-8"
|
|
||||||
);
|
|
||||||
|
|
||||||
let response = router
|
|
||||||
.clone()
|
|
||||||
.oneshot(get("/game/abc-123", None))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(response.status(), StatusCode::OK, "SPA fallback");
|
|
||||||
|
|
||||||
let response = router
|
|
||||||
.clone()
|
|
||||||
.oneshot(get("/static/index.html", None))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
|
|
||||||
let response = router
|
|
||||||
.clone()
|
|
||||||
.oneshot(get("/assets/card.png", None))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(&bytes[..], b"png-bytes");
|
|
||||||
|
|
||||||
// Unknown API paths also fall back to the SPA shell, like kaya's glob;
|
|
||||||
// an unknown OIDC provider path (no route registered) does too.
|
|
||||||
let response = router
|
|
||||||
.clone()
|
|
||||||
.oneshot(get("/api/does-not-exist", None))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
let response = router
|
|
||||||
.clone()
|
|
||||||
.oneshot(get("/auth/nope/login", None))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
|
|
||||||
std::fs::remove_dir_all(temp).ok();
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
[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 }
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
//! Authentication helpers on top of the session store.
|
|
||||||
//!
|
|
||||||
//! The platform has no application roles: every authenticated user may create
|
|
||||||
//! and join games. Authorization beyond login is game membership, checked
|
|
||||||
//! against the seats of the live session.
|
|
||||||
//!
|
|
||||||
//! kaya-oidc namespaces its session state per provider as
|
|
||||||
//! `oidc:{provider}:user`, so the lookup scans for any provider's user.
|
|
||||||
|
|
||||||
use serde_json::{Map, Value};
|
|
||||||
|
|
||||||
use crate::session::{SessionData, SessionHandle};
|
|
||||||
|
|
||||||
const OIDC_PREFIX: &str = "oidc:";
|
|
||||||
const USER_SUFFIX: &str = ":user";
|
|
||||||
|
|
||||||
/// Read-only view of the OIDC userinfo / ID token claims.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct User {
|
|
||||||
pub sub: String,
|
|
||||||
pub claims: Map<String, Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the authenticated user stored in a session, if any.
|
|
||||||
pub fn session_user(session: &SessionData) -> Option<User> {
|
|
||||||
for key in session.keys() {
|
|
||||||
if key.starts_with(OIDC_PREFIX) && key.ends_with(USER_SUFFIX) {
|
|
||||||
if let Some(Value::Object(claims)) = session.get(key) {
|
|
||||||
let sub = claims.get("sub").and_then(Value::as_str)?.to_string();
|
|
||||||
return Some(User {
|
|
||||||
sub,
|
|
||||||
claims: claims.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the authenticated user of a request, if any.
|
|
||||||
pub async fn current_user(session: &SessionHandle) -> Option<User> {
|
|
||||||
let session = session.lock().await;
|
|
||||||
session_user(&session)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Best-effort human-readable name for a user.
|
|
||||||
pub fn display_name(user: &User) -> String {
|
|
||||||
for key in ["name", "preferred_username", "email"] {
|
|
||||||
if let Some(Value::String(value)) = user.claims.get(key) {
|
|
||||||
if !value.is_empty() {
|
|
||||||
return value.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
user.sub.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Truncated SHA-256 of a session id; the cookie is a credential, so it is
|
|
||||||
/// never exported verbatim (used for span attributes).
|
|
||||||
pub fn session_tag(session_id: &str) -> String {
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
let digest = Sha256::digest(session_id.as_bytes());
|
|
||||||
let hex = format!("{digest:x}");
|
|
||||||
hex[..16].to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The names of every provider with a user in this session.
|
|
||||||
pub fn logged_in_providers(session: &SessionData) -> Vec<String> {
|
|
||||||
let mut providers = Vec::new();
|
|
||||||
for key in session.keys() {
|
|
||||||
if key.starts_with(OIDC_PREFIX) && key.ends_with(USER_SUFFIX) {
|
|
||||||
let provider = &key[OIDC_PREFIX.len()..key.len() - USER_SUFFIX.len()];
|
|
||||||
if !provider.is_empty() {
|
|
||||||
providers.push(provider.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
providers
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
//! ISO-8601 datetime helpers matching Python's `datetime.isoformat()` /
|
|
||||||
//! `datetime.fromisoformat()` behavior for UTC timestamps.
|
|
||||||
|
|
||||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
|
||||||
|
|
||||||
/// Format a UTC timestamp the way Python's `datetime.isoformat()` does for an
|
|
||||||
/// aware UTC datetime: microseconds are included only when non-zero, and the
|
|
||||||
/// UTC offset is rendered as `+00:00` (never `Z`).
|
|
||||||
pub fn to_iso(dt: DateTime<Utc>) -> String {
|
|
||||||
if dt.timestamp_subsec_micros() == 0 {
|
|
||||||
dt.format("%Y-%m-%dT%H:%M:%S+00:00").to_string()
|
|
||||||
} else {
|
|
||||||
dt.format("%Y-%m-%dT%H:%M:%S%.6f+00:00").to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse an ISO-8601 timestamp, tolerating both `Z` and explicit offsets.
|
|
||||||
///
|
|
||||||
/// Timestamps without an offset are interpreted as UTC (Python would treat
|
|
||||||
/// them as local time, but every timestamp this platform produces carries an
|
|
||||||
/// offset).
|
|
||||||
pub fn from_iso(value: &str) -> Option<DateTime<Utc>> {
|
|
||||||
if let Ok(parsed) = DateTime::parse_from_rfc3339(value) {
|
|
||||||
return Some(parsed.with_timezone(&Utc));
|
|
||||||
}
|
|
||||||
if let Ok(naive) = NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S%.f") {
|
|
||||||
return Some(DateTime::from_naive_utc_and_offset(naive, Utc));
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse a JSON value carrying an ISO timestamp.
|
|
||||||
pub fn from_json(value: &serde_json::Value) -> Option<DateTime<Utc>> {
|
|
||||||
value.as_str().and_then(from_iso)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serde `serialize_with` helper emitting Python-compatible ISO-8601.
|
|
||||||
pub fn serialize_iso<S>(dt: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
|
|
||||||
where
|
|
||||||
S: serde::Serializer,
|
|
||||||
{
|
|
||||||
serializer.serialize_str(&to_iso(*dt))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The current UTC instant, with microsecond precision.
|
|
||||||
pub fn now() -> DateTime<Utc> {
|
|
||||||
Utc::now()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use chrono::TimeZone;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn round_trips_with_and_without_microseconds() {
|
|
||||||
let base = Utc.with_ymd_and_hms(2026, 9, 19, 6, 59, 17).unwrap();
|
|
||||||
assert_eq!(to_iso(base), "2026-09-19T06:59:17+00:00");
|
|
||||||
let dt = base + chrono::Duration::microseconds(123456);
|
|
||||||
assert_eq!(to_iso(dt), "2026-09-19T06:59:17.123456+00:00");
|
|
||||||
assert_eq!(from_iso("2026-09-19T06:59:17+00:00"), Some(base));
|
|
||||||
assert_eq!(from_iso("2026-09-19T06:59:17.123456Z"), Some(dt));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,250 +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
|
|
||||||
//! [`GameEngine::next_deadline`](crate::engine::GameEngine::next_deadline)
|
|
||||||
//! computes the deadline the new state implies, and the scheduler enqueues
|
|
||||||
//! it in the store's shared deadline queue (a Redis sorted set in
|
|
||||||
//! production); enqueueing is idempotent;
|
|
||||||
//! * enqueueing a deadline announces it on a shared pub/sub channel so every
|
|
||||||
//! worker's consumer wakes immediately; the consumer also wakes on a slow
|
|
||||||
//! heartbeat, the backstop recovering lost notifications. Due entries are
|
|
||||||
//! handed to the engine's
|
|
||||||
//! [`GameEngine::fire_deadline`](crate::engine::GameEngine::fire_deadline)
|
|
||||||
//! under the per-game lock; the engine revalidates the token against the
|
|
||||||
//! live state, so duplicate or stale deliveries are harmless.
|
|
||||||
//!
|
|
||||||
//! Delivery is at-least-once: an entry is removed from the queue only after
|
|
||||||
//! it has been processed. Entries whose game has expired are dropped the
|
|
||||||
//! first time they fire.
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use serde_json::json;
|
|
||||||
use tokio::sync::Notify;
|
|
||||||
|
|
||||||
use crate::engine::GameSession;
|
|
||||||
use crate::errors::{Error, Result};
|
|
||||||
use crate::registry::GameRegistry;
|
|
||||||
use crate::stats::SharedStatsRepo;
|
|
||||||
use crate::store::SharedGameStore;
|
|
||||||
|
|
||||||
pub struct DeadlineScheduler {
|
|
||||||
store: SharedGameStore,
|
|
||||||
registry: Arc<GameRegistry>,
|
|
||||||
stats: SharedStatsRepo,
|
|
||||||
heartbeat: Duration,
|
|
||||||
retry_delay: Duration,
|
|
||||||
wake: Notify,
|
|
||||||
started: AtomicBool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DeadlineScheduler {
|
|
||||||
pub fn new(
|
|
||||||
store: SharedGameStore,
|
|
||||||
registry: Arc<GameRegistry>,
|
|
||||||
stats: SharedStatsRepo,
|
|
||||||
heartbeat_ms: u64,
|
|
||||||
retry_ms: u64,
|
|
||||||
) -> Arc<Self> {
|
|
||||||
Arc::new(Self {
|
|
||||||
store,
|
|
||||||
registry,
|
|
||||||
stats,
|
|
||||||
heartbeat: Duration::from_millis(heartbeat_ms),
|
|
||||||
retry_delay: Duration::from_millis(retry_ms),
|
|
||||||
wake: Notify::new(),
|
|
||||||
started: AtomicBool::new(false),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start the consumer and listener tasks (idempotent).
|
|
||||||
pub fn start(self: &Arc<Self>) {
|
|
||||||
if self.started.swap(true, Ordering::SeqCst) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
tokio::spawn(run_consumer(Arc::clone(self)));
|
|
||||||
tokio::spawn(run_listener(Arc::clone(self)));
|
|
||||||
tracing::debug!("deadline consumer started");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enqueue the deadline the current state implies, if any.
|
|
||||||
///
|
|
||||||
/// Called after every mutation that can set a deadline and as a backstop
|
|
||||||
/// when a client connects. Enqueueing is idempotent: an identical entry
|
|
||||||
/// is already queued with the same due time, so re-adding changes
|
|
||||||
/// nothing.
|
|
||||||
pub async fn sync_deadline(self: &Arc<Self>, session: &GameSession) -> Result<()> {
|
|
||||||
let engine = self.registry.require(&session.game_type)?.clone();
|
|
||||||
let Some(deadline) = engine.next_deadline(session)? else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
self.start();
|
|
||||||
let entry = json!({
|
|
||||||
"game_id": session.id,
|
|
||||||
"kind": deadline.kind,
|
|
||||||
"token": deadline.token,
|
|
||||||
});
|
|
||||||
let member = serde_json::to_string(&entry)?;
|
|
||||||
let due_at = deadline.due_at.timestamp() as f64
|
|
||||||
+ deadline.due_at.timestamp_subsec_micros() as f64 / 1e6;
|
|
||||||
self.store.add_deadline(&member, due_at).await?;
|
|
||||||
if let Err(err) = self.store.notify_deadline().await {
|
|
||||||
tracing::warn!(
|
|
||||||
"deadline enqueue notification failed; relying on the poll backstop: {err}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
self.wake.notify_waiters();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persist a successful mutation, notify subscribers and enqueue the next
|
|
||||||
/// deadline. Callers must hold the per-game lock. Handles the terminal
|
|
||||||
/// transition: the match result is written to Postgres once (guarded by
|
|
||||||
/// `stats_saved`).
|
|
||||||
pub async fn finalize_mutation(self: &Arc<Self>, session: &mut GameSession) -> Result<()> {
|
|
||||||
let engine = self.registry.require(&session.game_type)?.clone();
|
|
||||||
if engine.is_finished(session) {
|
|
||||||
if session.finished_at.is_none() {
|
|
||||||
session.finished_at = Some(crate::datetime::now());
|
|
||||||
}
|
|
||||||
if !session.stats_saved {
|
|
||||||
self.stats.save_match(session, engine.as_ref()).await?;
|
|
||||||
session.stats_saved = true;
|
|
||||||
}
|
|
||||||
tracing::info!("game {} ({}) finished", session.id, session.game_type);
|
|
||||||
}
|
|
||||||
self.store.save(session).await?;
|
|
||||||
self.store.publish(&session.id).await?;
|
|
||||||
self.sync_deadline(session).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fire a single due deadline entry.
|
|
||||||
///
|
|
||||||
/// The engine revalidates the entry against the live state under the
|
|
||||||
/// per-game lock; stale or foreign entries are discarded without effect.
|
|
||||||
/// The entry is removed from the queue once handled; if handling fails
|
|
||||||
/// unexpectedly (e.g. the lock cannot be acquired), the entry is left in
|
|
||||||
/// the queue so another consumer retries it.
|
|
||||||
pub async fn process_due(self: &Arc<Self>, member: &str) -> Result<()> {
|
|
||||||
let Ok(entry) = serde_json::from_str::<serde_json::Value>(member) else {
|
|
||||||
tracing::warn!("deadline consumer: dropping malformed entry {member:?}");
|
|
||||||
self.store.remove_deadline(member).await?;
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
let game_id = entry.get("game_id").and_then(serde_json::Value::as_str);
|
|
||||||
let kind = entry.get("kind").and_then(serde_json::Value::as_str);
|
|
||||||
let token = entry
|
|
||||||
.get("token")
|
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
.unwrap_or("");
|
|
||||||
let (Some(game_id), Some(kind)) = (game_id, kind) else {
|
|
||||||
self.store.remove_deadline(member).await?;
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
|
|
||||||
let lock = self.store.acquire_lock(game_id).await?;
|
|
||||||
let outcome = async {
|
|
||||||
let Some(mut session) = self.store.load(game_id).await? else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
let engine = self.registry.require(&session.game_type)?.clone();
|
|
||||||
match engine.fire_deadline(&mut session, kind, token) {
|
|
||||||
Ok(()) => self.finalize_mutation(&mut session).await,
|
|
||||||
Err(err) if err.as_game().is_some() => {
|
|
||||||
// Stale or inapplicable entry: nothing to do.
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(err) => Err(err),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.await;
|
|
||||||
lock.release().await?;
|
|
||||||
outcome?;
|
|
||||||
self.store.remove_deadline(member).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_consumer(scheduler: Arc<DeadlineScheduler>) {
|
|
||||||
loop {
|
|
||||||
// Clear before polling so an enqueue racing the poll re-wakes us.
|
|
||||||
let mut delay = scheduler.heartbeat;
|
|
||||||
let mut failed = false;
|
|
||||||
match scheduler.store.due_deadlines(now_secs(), 32).await {
|
|
||||||
Ok(members) => {
|
|
||||||
for member in members {
|
|
||||||
match scheduler.process_due(&member).await {
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(err) => {
|
|
||||||
tracing::error!(
|
|
||||||
"deadline consumer: failed to process {member:?}: {err}"
|
|
||||||
);
|
|
||||||
failed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
tracing::error!("deadline consumer: poll failed; retrying: {err}");
|
|
||||||
failed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
match scheduler.store.next_deadline().await {
|
|
||||||
Ok(Some(next_due)) => {
|
|
||||||
let until = (next_due - now_secs()).max(0.0);
|
|
||||||
delay = delay.min(Duration::from_secs_f64(until));
|
|
||||||
}
|
|
||||||
Ok(None) => {}
|
|
||||||
Err(err) => {
|
|
||||||
tracing::error!("deadline consumer: next_deadline failed: {err}");
|
|
||||||
failed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if failed {
|
|
||||||
// A failed entry is still due: back off briefly instead of
|
|
||||||
// polling it again immediately.
|
|
||||||
delay = scheduler.retry_delay;
|
|
||||||
}
|
|
||||||
tokio::select! {
|
|
||||||
_ = scheduler.wake.notified() => {}
|
|
||||||
_ = tokio::time::sleep(delay) => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_listener(scheduler: Arc<DeadlineScheduler>) {
|
|
||||||
loop {
|
|
||||||
match scheduler.store.subscribe_deadlines().await {
|
|
||||||
Ok(mut events) => {
|
|
||||||
use futures_util::StreamExt;
|
|
||||||
while let Some(()) = events.next().await {
|
|
||||||
scheduler.wake.notify_waiters();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
tracing::error!("deadline listener failed; reconnecting: {err}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tokio::time::sleep(scheduler.retry_delay).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn now_secs() -> f64 {
|
|
||||||
std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_secs_f64())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convenience constructor returning an error when the registry is empty.
|
|
||||||
pub fn scheduler_error() -> Error {
|
|
||||||
Error::Internal("deadline scheduler misconfigured".into())
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
//! Chess-style Elo ratings, generalized to two-team matches.
|
|
||||||
//!
|
|
||||||
//! Every player starts at [`INITIAL_RATING`]. A team's rating is the mean of
|
|
||||||
//! its members' current ratings, so the usual chess formula applies unchanged
|
|
||||||
//! between the two teams:
|
|
||||||
//!
|
|
||||||
//! * expected score `E = 1 / (1 + 10 ** ((R_opponent - R_team) / 400))`
|
|
||||||
//! * actual score `S` is 1 for a win and 0 for a loss (matches never draw)
|
|
||||||
//! * every member of a team gains/loses the same `round(K * (S - E))`
|
|
||||||
//!
|
|
||||||
//! Deltas are rounded to integers and ratings are stored as integers, so the
|
|
||||||
//! system is exactly zero-sum. Rounding uses Python's banker's rounding
|
|
||||||
//! (`round()` half-to-even), which Rust exposes as `f64::round_ties_even`.
|
|
||||||
|
|
||||||
pub const INITIAL_RATING: i64 = 1500;
|
|
||||||
pub const K_FACTOR: f64 = 32.0;
|
|
||||||
|
|
||||||
/// Expected score (0..1) of a side rated `rating` against `opponent_rating`.
|
|
||||||
pub fn expected_score(rating: f64, opponent_rating: f64) -> f64 {
|
|
||||||
1.0 / (1.0 + 10.0_f64.powf((opponent_rating - rating) / 400.0))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A team's rating is the mean of its members' ratings.
|
|
||||||
pub fn team_rating(ratings: &[f64]) -> Result<f64, &'static str> {
|
|
||||||
if ratings.is_empty() {
|
|
||||||
return Err("a team needs at least one rating");
|
|
||||||
}
|
|
||||||
Ok(ratings.iter().sum::<f64>() / ratings.len() as f64)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rating change applied to each member of team A.
|
|
||||||
///
|
|
||||||
/// `winner_team` is 0 when team A won, 1 when team B won. Team B members
|
|
||||||
/// change by the negation of the returned value (zero-sum).
|
|
||||||
pub fn match_delta(team_a_ratings: &[f64], team_b_ratings: &[f64], winner_team: usize) -> i64 {
|
|
||||||
let rating_a = team_rating(team_a_ratings).unwrap_or_default();
|
|
||||||
let rating_b = team_rating(team_b_ratings).unwrap_or_default();
|
|
||||||
let expected = expected_score(rating_a, rating_b);
|
|
||||||
let score = if winner_team == 0 { 1.0 } else { 0.0 };
|
|
||||||
(K_FACTOR * (score - expected)).round_ties_even() as i64
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn equal_ratings_move_16_points() {
|
|
||||||
assert_eq!(match_delta(&[1500.0], &[1500.0], 0), 16);
|
|
||||||
assert_eq!(match_delta(&[1500.0], &[1500.0], 1), -16);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn team_rating_is_the_mean() {
|
|
||||||
assert_eq!(team_rating(&[1400.0, 1600.0]).unwrap(), 1500.0);
|
|
||||||
assert!(team_rating(&[]).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn banker_rounding_matches_python() {
|
|
||||||
// Python: round(32 * (1 - 0.5)) == 16; ties-to-even for exact halves.
|
|
||||||
assert_eq!((16.5_f64).round_ties_even(), 16.0);
|
|
||||||
assert_eq!((17.5_f64).round_ties_even(), 18.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,335 +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 [`GameEngine`] and plugs into
|
|
||||||
//! the platform through [`crate::registry::GameRegistry`]; the platform
|
|
||||||
//! never imports a concrete game module.
|
|
||||||
//!
|
|
||||||
//! The central type is [`GameSession`]: the platform-owned envelope carrying
|
|
||||||
//! the lifecycle metadata (id, join code, seats, timestamps) plus an opaque
|
|
||||||
//! `state` blob that only the engine interprets. The engine serializes and
|
|
||||||
//! deserializes that blob so the store stays game-agnostic.
|
|
||||||
|
|
||||||
use std::any::Any;
|
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::{Map, Value};
|
|
||||||
|
|
||||||
use crate::errors::{GameError, Result};
|
|
||||||
|
|
||||||
/// One occupied chair in a game, owned by the platform.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct Seat {
|
|
||||||
pub user_sub: String,
|
|
||||||
pub display_name: String,
|
|
||||||
/// Game-defined team label ("A"/"B", ...); `None` for games without
|
|
||||||
/// fixed teams.
|
|
||||||
pub team: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Seat {
|
|
||||||
pub fn new(user_sub: impl Into<String>, display_name: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
user_sub: user_sub.into(),
|
|
||||||
display_name: display_name.into(),
|
|
||||||
team: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn to_json(&self) -> Value {
|
|
||||||
serde_json::json!({
|
|
||||||
"user_sub": self.user_sub,
|
|
||||||
"display_name": self.display_name,
|
|
||||||
"team": self.team,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_json(data: &Value) -> Result<Self> {
|
|
||||||
Ok(Self {
|
|
||||||
user_sub: data
|
|
||||||
.get("user_sub")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string(),
|
|
||||||
display_name: data
|
|
||||||
.get("display_name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string(),
|
|
||||||
team: data.get("team").and_then(Value::as_str).map(str::to_string),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The platform-owned envelope of a live game.
|
|
||||||
///
|
|
||||||
/// `state` is opaque to the platform: only the engine registered for
|
|
||||||
/// `game_type` reads or writes it.
|
|
||||||
pub struct GameSession {
|
|
||||||
pub id: String,
|
|
||||||
pub game_type: String,
|
|
||||||
pub join_code: String,
|
|
||||||
pub creator_sub: String,
|
|
||||||
pub players: Vec<Seat>,
|
|
||||||
pub created_at: Option<DateTime<Utc>>,
|
|
||||||
pub finished_at: Option<DateTime<Utc>>,
|
|
||||||
/// Set once the finished match has been copied to Postgres.
|
|
||||||
pub stats_saved: bool,
|
|
||||||
pub state: Option<Box<dyn Any + Send + Sync>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GameSession {
|
|
||||||
/// A fresh lobby session created by [`GameEngine::create`].
|
|
||||||
pub fn new(
|
|
||||||
id: impl Into<String>,
|
|
||||||
game_type: impl Into<String>,
|
|
||||||
join_code: impl Into<String>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
id: id.into(),
|
|
||||||
game_type: game_type.into(),
|
|
||||||
join_code: join_code.into(),
|
|
||||||
creator_sub: String::new(),
|
|
||||||
players: Vec::new(),
|
|
||||||
created_at: None,
|
|
||||||
finished_at: None,
|
|
||||||
stats_saved: false,
|
|
||||||
state: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether `sub` occupies a seat in this game.
|
|
||||||
pub fn seated(&self, sub: &str) -> bool {
|
|
||||||
self.players.iter().any(|seat| seat.user_sub == sub)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The seat occupied by `sub`, if any.
|
|
||||||
pub fn seat_of(&self, sub: &str) -> Option<&Seat> {
|
|
||||||
self.players.iter().find(|seat| seat.user_sub == sub)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The seat index occupied by `sub`, if any.
|
|
||||||
pub fn seat_index_of(&self, sub: &str) -> Option<usize> {
|
|
||||||
self.players.iter().position(|seat| seat.user_sub == sub)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A timeout the engine wants the platform to fire.
|
|
||||||
///
|
|
||||||
/// `kind` is an engine-defined string (e.g. `"turn"`); `token` is an opaque
|
|
||||||
/// revalidation token: when the deadline fires, the engine must recompute it
|
|
||||||
/// from the live session and refuse to act (returning an error) if it no
|
|
||||||
/// longer matches, which makes duplicate or overtaken deliveries harmless.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub struct Deadline {
|
|
||||||
pub kind: String,
|
|
||||||
pub due_at: DateTime<Utc>,
|
|
||||||
pub token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The outcome of a finished match for one participant.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct PlayerResult {
|
|
||||||
pub user_sub: String,
|
|
||||||
pub seat: i64,
|
|
||||||
pub won: bool,
|
|
||||||
pub team: Option<String>,
|
|
||||||
/// Points scored, aggregated by the leaderboard.
|
|
||||||
pub score: f64,
|
|
||||||
/// Game-specific extras persisted alongside the participation.
|
|
||||||
pub details: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The game-independent outcome of a finished match.
|
|
||||||
///
|
|
||||||
/// `teams` groups the participants' subs into the sides the Elo rating treats
|
|
||||||
/// as opponents (exactly two for now); `winner_team` is the index of the
|
|
||||||
/// winning side. `summary` is persisted verbatim as the match's JSON
|
|
||||||
/// `result` column.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct MatchResult {
|
|
||||||
pub teams: Vec<Vec<String>>,
|
|
||||||
pub winner_team: usize,
|
|
||||||
pub players: Vec<PlayerResult>,
|
|
||||||
pub summary: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MatchResult {
|
|
||||||
pub fn new(
|
|
||||||
teams: Vec<Vec<String>>,
|
|
||||||
winner_team: usize,
|
|
||||||
players: Vec<PlayerResult>,
|
|
||||||
summary: Value,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
teams,
|
|
||||||
winner_team,
|
|
||||||
players,
|
|
||||||
summary,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The interface every playable game implements.
|
|
||||||
///
|
|
||||||
/// Implementations must be deterministic and I/O-free apart from logging:
|
|
||||||
/// all persistence, locking and transport concerns belong to the platform.
|
|
||||||
/// Rule violations are reported by returning [`GameError`] values.
|
|
||||||
pub trait GameEngine: Send + Sync {
|
|
||||||
/// Unique id carried by sessions and persisted matches.
|
|
||||||
fn id(&self) -> &'static str;
|
|
||||||
/// Human-readable name, for the lobby UI.
|
|
||||||
fn name(&self) -> &'static str;
|
|
||||||
fn description(&self) -> &'static str;
|
|
||||||
fn min_players(&self) -> usize;
|
|
||||||
fn max_players(&self) -> usize;
|
|
||||||
/// JSON-schema fragment describing the game-specific creation options
|
|
||||||
/// accepted by [`GameEngine::create`] (exposed via `GET /api/game-types`).
|
|
||||||
fn options_schema(&self) -> Value;
|
|
||||||
|
|
||||||
/// Initialize `session.state` for a fresh lobby game.
|
|
||||||
///
|
|
||||||
/// The platform has already filled the session envelope and seated the
|
|
||||||
/// creator (`session.players[0]`, without a team label yet). Assign the
|
|
||||||
/// creator's team here if the game has fixed teams, then validate
|
|
||||||
/// `options` and initialize `session.state`.
|
|
||||||
fn create(&self, session: &mut GameSession, options: &Map<String, Value>) -> Result<()>;
|
|
||||||
|
|
||||||
/// Seat a player, starting the match when the lobby fills up.
|
|
||||||
///
|
|
||||||
/// Must append a [`Seat`] to `session.players` and mirror whatever
|
|
||||||
/// per-player state the game keeps in `session.state`.
|
|
||||||
fn join(&self, session: &mut GameSession, user_sub: &str, display_name: &str) -> Result<()>;
|
|
||||||
|
|
||||||
/// Apply one player action received over the websocket.
|
|
||||||
///
|
|
||||||
/// `payload` is the client message minus its `action` field. Unknown
|
|
||||||
/// actions and rule violations return [`GameError`].
|
|
||||||
fn handle_action(
|
|
||||||
&self,
|
|
||||||
session: &mut GameSession,
|
|
||||||
user_sub: &str,
|
|
||||||
action: &str,
|
|
||||||
payload: &Map<String, Value>,
|
|
||||||
) -> Result<()>;
|
|
||||||
|
|
||||||
/// The personalized game view for one player.
|
|
||||||
///
|
|
||||||
/// Must hide information the player is not entitled to (e.g. other
|
|
||||||
/// players' hands). The platform merges in the envelope fields
|
|
||||||
/// (`id`, `join_code`, `game_type`) itself.
|
|
||||||
fn view_for(&self, session: &GameSession, user_sub: &str) -> Value;
|
|
||||||
|
|
||||||
/// Game-specific fields of the lobby payload (options echo, phase).
|
|
||||||
fn lobby_view(&self, session: &GameSession) -> Value;
|
|
||||||
|
|
||||||
/// Whether the game is still waiting for players.
|
|
||||||
fn in_lobby(&self, session: &GameSession) -> bool;
|
|
||||||
|
|
||||||
/// Whether the match is over and a result can be extracted.
|
|
||||||
fn is_finished(&self, session: &GameSession) -> bool;
|
|
||||||
|
|
||||||
/// The outcome of the match. Only called when finished.
|
|
||||||
fn result(&self, session: &GameSession) -> Result<MatchResult>;
|
|
||||||
|
|
||||||
/// Serialize the opaque game state to plain JSON.
|
|
||||||
fn state_to_json(&self, state: &(dyn Any + Send + Sync)) -> Result<Value>;
|
|
||||||
|
|
||||||
/// Rebuild the opaque game state from its JSON form.
|
|
||||||
fn state_from_json(&self, data: &Value) -> Result<Box<dyn Any + Send + Sync>>;
|
|
||||||
|
|
||||||
/// The deadline the current state implies, if any.
|
|
||||||
///
|
|
||||||
/// Called after every mutation; the platform enqueues the returned
|
|
||||||
/// deadline (enqueueing is idempotent) and never removes previously
|
|
||||||
/// enqueued ones — stale entries are discarded at fire time by
|
|
||||||
/// [`GameEngine::fire_deadline`] revalidation.
|
|
||||||
fn next_deadline(&self, session: &GameSession) -> Result<Option<Deadline>>;
|
|
||||||
|
|
||||||
/// Apply the timeout action for a due deadline.
|
|
||||||
///
|
|
||||||
/// Must recompute the current deadline and return a [`GameError`] without
|
|
||||||
/// acting when `kind`/`token` no longer match the live state.
|
|
||||||
fn fire_deadline(&self, session: &mut GameSession, kind: &str, token: &str) -> Result<()>;
|
|
||||||
|
|
||||||
/// Extra fields merged into the `game_over` websocket message.
|
|
||||||
fn game_over_view(&self, _session: &GameSession, _user_sub: &str) -> Value {
|
|
||||||
serde_json::json!({})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the JSON envelope for a session: platform fields plus the engine's
|
|
||||||
/// state blob (mirrors `tavolo.platform.store.session_to_json`).
|
|
||||||
pub fn session_to_json(session: &GameSession, engine: &dyn GameEngine) -> Result<Value> {
|
|
||||||
let state = match session.state.as_ref() {
|
|
||||||
Some(state) => engine.state_to_json(state.as_ref())?,
|
|
||||||
None => Value::Object(Map::new()),
|
|
||||||
};
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"id": session.id,
|
|
||||||
"game_type": session.game_type,
|
|
||||||
"join_code": session.join_code,
|
|
||||||
"creator_sub": session.creator_sub,
|
|
||||||
"players": session.players.iter().map(Seat::to_json).collect::<Vec<_>>(),
|
|
||||||
"created_at": session.created_at.map(crate::datetime::to_iso),
|
|
||||||
"finished_at": session.finished_at.map(crate::datetime::to_iso),
|
|
||||||
"stats_saved": session.stats_saved,
|
|
||||||
"state": state,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rebuild a session from its JSON envelope, delegating the state blob to
|
|
||||||
/// the engine registered for its `game_type` (mirrors
|
|
||||||
/// `tavolo.platform.store.session_from_json`).
|
|
||||||
pub fn session_from_json(data: &Value, engine: &dyn GameEngine) -> Result<GameSession> {
|
|
||||||
let game_type = data
|
|
||||||
.get("game_type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.ok_or_else(|| GameError::illegal_move("session is missing game_type"))?
|
|
||||||
.to_string();
|
|
||||||
if game_type != engine.id() {
|
|
||||||
return Err(
|
|
||||||
GameError::unknown_game_type(format!("unknown game_type: {game_type:?}")).into(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let players = data
|
|
||||||
.get("players")
|
|
||||||
.and_then(Value::as_array)
|
|
||||||
.map(|list| list.iter().map(Seat::from_json).collect::<Result<Vec<_>>>())
|
|
||||||
.transpose()?
|
|
||||||
.unwrap_or_default();
|
|
||||||
let state = match data.get("state") {
|
|
||||||
Some(Value::Object(map)) if !map.is_empty() => {
|
|
||||||
Some(engine.state_from_json(data.get("state").unwrap())?)
|
|
||||||
}
|
|
||||||
_ => Some(engine.state_from_json(&Value::Object(Map::new()))?),
|
|
||||||
};
|
|
||||||
Ok(GameSession {
|
|
||||||
id: data
|
|
||||||
.get("id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string(),
|
|
||||||
game_type,
|
|
||||||
join_code: data
|
|
||||||
.get("join_code")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string(),
|
|
||||||
creator_sub: data
|
|
||||||
.get("creator_sub")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string(),
|
|
||||||
players,
|
|
||||||
created_at: data.get("created_at").and_then(crate::datetime::from_json),
|
|
||||||
finished_at: data.get("finished_at").and_then(crate::datetime::from_json),
|
|
||||||
stats_saved: data
|
|
||||||
.get("stats_saved")
|
|
||||||
.and_then(Value::as_bool)
|
|
||||||
.unwrap_or(false),
|
|
||||||
state,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
//! Typed errors shared between the platform and game implementations.
|
|
||||||
//!
|
|
||||||
//! Game engines return [`GameError`] on rule/validation failures; the
|
|
||||||
//! platform translates them into 4xx HTTP responses or `error` WebSocket
|
|
||||||
//! messages, using [`GameError::code`] as the machine-readable error code.
|
|
||||||
//! Engines themselves stay transport-agnostic.
|
|
||||||
//!
|
|
||||||
//! Mirrors `tavolo.platform.errors` from the Python implementation.
|
|
||||||
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
/// Base error for every rule/validation failure a game can raise.
|
|
||||||
#[derive(Debug, Clone, Error)]
|
|
||||||
#[error("{message}")]
|
|
||||||
pub struct GameError {
|
|
||||||
/// Machine-readable code carried by WebSocket `error` messages.
|
|
||||||
pub code: &'static str,
|
|
||||||
/// Human-readable failure reason.
|
|
||||||
pub message: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GameError {
|
|
||||||
pub fn new(code: &'static str, message: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
code,
|
|
||||||
message: message.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The requested action violates the rules of the game.
|
|
||||||
pub fn illegal_move(message: impl Into<String>) -> Self {
|
|
||||||
Self::new("illegal_move", message)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A player attempted to act out of turn.
|
|
||||||
pub fn not_your_turn(message: impl Into<String>) -> Self {
|
|
||||||
Self::new("illegal_move", message)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An action was attempted before the game left the lobby.
|
|
||||||
pub fn game_not_started(message: impl Into<String>) -> Self {
|
|
||||||
Self::new("illegal_move", message)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An action was attempted after the match ended.
|
|
||||||
pub fn game_finished(message: impl Into<String>) -> Self {
|
|
||||||
Self::new("illegal_move", message)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A game already has its full complement of players.
|
|
||||||
pub fn lobby_full(message: impl Into<String>) -> Self {
|
|
||||||
Self::new("illegal_move", message)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A player tried to join a game they are already seated in.
|
|
||||||
pub fn already_joined(message: impl Into<String>) -> Self {
|
|
||||||
Self::new("illegal_move", message)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// No live game exists for the given id or join code.
|
|
||||||
pub fn game_not_found(message: impl Into<String>) -> Self {
|
|
||||||
Self::new("illegal_move", message)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A request named a `game_type` no registered engine provides.
|
|
||||||
pub fn unknown_game_type(message: impl Into<String>) -> Self {
|
|
||||||
Self::new("unknown_game_type", message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Errors raised by the platform outside game rules (storage, transport,
|
|
||||||
/// serialization).
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum Error {
|
|
||||||
#[error(transparent)]
|
|
||||||
Game(#[from] GameError),
|
|
||||||
#[error("store error: {0}")]
|
|
||||||
Store(String),
|
|
||||||
#[error(transparent)]
|
|
||||||
Json(#[from] serde_json::Error),
|
|
||||||
#[error(transparent)]
|
|
||||||
Redis(#[from] redis::RedisError),
|
|
||||||
#[error(transparent)]
|
|
||||||
Sql(#[from] sqlx::Error),
|
|
||||||
#[error(transparent)]
|
|
||||||
Io(#[from] std::io::Error),
|
|
||||||
#[error("{0}")]
|
|
||||||
Internal(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Error {
|
|
||||||
/// The game error behind this error, when there is one.
|
|
||||||
pub fn as_game(&self) -> Option<&GameError> {
|
|
||||||
match self {
|
|
||||||
Error::Game(err) => Some(err),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type Result<T, E = Error> = std::result::Result<T, E>;
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
//! JSON request/response helpers for the HTTP handlers.
|
|
||||||
//!
|
|
||||||
//! Mirrors `tavolo.platform.http` from the Python implementation: the error
|
|
||||||
//! messages produced here are part of the public API contract.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use axum::http::{header, StatusCode};
|
|
||||||
use axum::response::{IntoResponse, Response};
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
|
|
||||||
/// Raised when the request body is not valid JSON or not a JSON object.
|
|
||||||
#[derive(Debug, Clone, thiserror::Error)]
|
|
||||||
#[error("{0}")]
|
|
||||||
pub struct JsonRequestError(pub String);
|
|
||||||
|
|
||||||
/// Drain and parse a request body as a JSON object.
|
|
||||||
pub fn read_json(body: &[u8]) -> Result<Value, JsonRequestError> {
|
|
||||||
if body.is_empty() {
|
|
||||||
return Err(JsonRequestError("empty body".into()));
|
|
||||||
}
|
|
||||||
let parsed: Value =
|
|
||||||
serde_json::from_slice(body).map_err(|_| JsonRequestError("invalid JSON".into()))?;
|
|
||||||
if !parsed.is_object() {
|
|
||||||
return Err(JsonRequestError("JSON body must be an object".into()));
|
|
||||||
}
|
|
||||||
Ok(parsed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Like [`read_json`] but treats an empty body as `{}`.
|
|
||||||
pub fn read_json_optional(body: &[u8]) -> Result<Value, JsonRequestError> {
|
|
||||||
match read_json(body) {
|
|
||||||
Ok(value) => Ok(value),
|
|
||||||
Err(JsonRequestError(message)) if message == "empty body" => Ok(json!({})),
|
|
||||||
Err(err) => Err(err),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse a raw query string into a mapping of param name to all values.
|
|
||||||
pub fn extract_query_params(query: Option<&str>) -> HashMap<String, Vec<String>> {
|
|
||||||
let mut params: HashMap<String, Vec<String>> = HashMap::new();
|
|
||||||
if let Some(query) = query {
|
|
||||||
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
|
|
||||||
params
|
|
||||||
.entry(key.into_owned())
|
|
||||||
.or_default()
|
|
||||||
.push(value.into_owned());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
params
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send `payload` as a JSON response.
|
|
||||||
pub fn send_json(status: StatusCode, payload: &Value) -> Response {
|
|
||||||
let body = serde_json::to_vec(payload).unwrap_or_else(|_| b"null".to_vec());
|
|
||||||
(status, [(header::CONTENT_TYPE, "application/json")], body).into_response()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a JSON error envelope.
|
|
||||||
pub fn send_error(status: StatusCode, message: impl Into<String>) -> Response {
|
|
||||||
let message = message.into();
|
|
||||||
send_json(status, &json!({ "error": message }))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a plain body with an explicit content type.
|
|
||||||
pub fn send_text(status: StatusCode, body: impl Into<String>, content_type: &str) -> Response {
|
|
||||||
(
|
|
||||||
status,
|
|
||||||
[(header::CONTENT_TYPE, content_type.to_string())],
|
|
||||||
body.into(),
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send an empty response with a `Location` header.
|
|
||||||
pub fn redirect(location: impl Into<String>) -> Response {
|
|
||||||
(StatusCode::FOUND, [(header::LOCATION, location.into())]).into_response()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The 401 JSON envelope used by the authentication gate.
|
|
||||||
pub fn unauthorized() -> Response {
|
|
||||||
send_json(
|
|
||||||
StatusCode::UNAUTHORIZED,
|
|
||||||
&json!({ "error": "unauthenticated" }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render a JSON value the way Python's `repr()` would, so error messages
|
|
||||||
/// like `unknown game_type: 'foo'` match the Python server.
|
|
||||||
pub fn py_repr(value: &Value) -> String {
|
|
||||||
match value {
|
|
||||||
Value::Null => "None".to_string(),
|
|
||||||
Value::Bool(true) => "True".to_string(),
|
|
||||||
Value::Bool(false) => "False".to_string(),
|
|
||||||
Value::Number(number) => {
|
|
||||||
if let Some(int) = number.as_i64() {
|
|
||||||
int.to_string()
|
|
||||||
} else if let Some(float) = number.as_f64() {
|
|
||||||
if float.fract() == 0.0 && float.is_finite() {
|
|
||||||
format!("{float:.1}")
|
|
||||||
} else {
|
|
||||||
float.to_string()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
number.to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Value::String(text) => py_repr_str(text),
|
|
||||||
Value::Array(items) => {
|
|
||||||
let rendered: Vec<String> = items.iter().map(py_repr).collect();
|
|
||||||
format!("[{}]", rendered.join(", "))
|
|
||||||
}
|
|
||||||
Value::Object(map) => {
|
|
||||||
let rendered: Vec<String> = map
|
|
||||||
.iter()
|
|
||||||
.map(|(key, value)| format!("{}: {}", py_repr_str(key), py_repr(value)))
|
|
||||||
.collect();
|
|
||||||
format!("{{{}}}", rendered.join(", "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn py_repr_str(text: &str) -> String {
|
|
||||||
let mut out = String::with_capacity(text.len() + 2);
|
|
||||||
out.push('\'');
|
|
||||||
for ch in text.chars() {
|
|
||||||
match ch {
|
|
||||||
'\\' => out.push_str("\\\\"),
|
|
||||||
'\'' => out.push_str("\\'"),
|
|
||||||
'\n' => out.push_str("\\n"),
|
|
||||||
'\r' => out.push_str("\\r"),
|
|
||||||
'\t' => out.push_str("\\t"),
|
|
||||||
_ => out.push(ch),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.push('\'');
|
|
||||||
out
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
#![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;
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
//! Database row models: match statistics persisted in Postgres.
|
|
||||||
//!
|
|
||||||
//! The schema is unchanged from the Python implementation
|
|
||||||
//! (`server/migrations/`):
|
|
||||||
//!
|
|
||||||
//! * [`MatchRow`] — one row per finished match; game-specific data lives in
|
|
||||||
//! the JSON `result` column produced by the engine.
|
|
||||||
//! * [`MatchPlayerRow`] — one row per participant.
|
|
||||||
//! * [`PlayerRatingRow`] — current Elo rating per `(user_sub, game_type)`.
|
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, sqlx::FromRow)]
|
|
||||||
pub struct MatchRow {
|
|
||||||
pub id: uuid::Uuid,
|
|
||||||
pub game_type: String,
|
|
||||||
pub started_at: DateTime<Utc>,
|
|
||||||
pub finished_at: DateTime<Utc>,
|
|
||||||
pub result: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, sqlx::FromRow)]
|
|
||||||
pub struct MatchPlayerRow {
|
|
||||||
pub id: uuid::Uuid,
|
|
||||||
pub match_id: uuid::Uuid,
|
|
||||||
pub user_sub: String,
|
|
||||||
pub display_name: String,
|
|
||||||
pub seat: i16,
|
|
||||||
pub team: Option<String>,
|
|
||||||
pub won: bool,
|
|
||||||
pub score: f64,
|
|
||||||
pub elo_delta: Option<i16>,
|
|
||||||
pub details: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, sqlx::FromRow)]
|
|
||||||
pub struct PlayerRatingRow {
|
|
||||||
pub user_sub: String,
|
|
||||||
pub game_type: String,
|
|
||||||
pub rating: i32,
|
|
||||||
pub matches_played: i32,
|
|
||||||
}
|
|
||||||
@@ -1,551 +0,0 @@
|
|||||||
//! OpenID Connect login flows (discovery, PKCE, ID-token validation).
|
|
||||||
//!
|
|
||||||
//! Mirrors `kaya.oidc`'s `OIDCProviders` / `OIDCMixin`: each configured
|
|
||||||
//! provider gets `/auth/<provider>/login`, `/auth/<provider>/callback` and
|
|
||||||
//! `/auth/<provider>/logout` routes, namespaces its session state as
|
|
||||||
//! `oidc:<provider>:...`, and shares one session store. The configured
|
|
||||||
//! strategy is single-identity: a successful login evicts every other
|
|
||||||
//! provider's identity.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
|
||||||
use base64::Engine;
|
|
||||||
use rand::RngCore;
|
|
||||||
use regex::Regex;
|
|
||||||
use serde_json::{json, Map, Value};
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
|
|
||||||
use crate::errors::{Error, Result};
|
|
||||||
use crate::session::SessionHandle;
|
|
||||||
|
|
||||||
pub const BASE_PATH: &str = "/auth";
|
|
||||||
const SCOPES: &str = "openid email profile";
|
|
||||||
const IDENTITY_FIELDS: [&str; 4] = ["user", "id_token", "access_token", "refresh_token"];
|
|
||||||
|
|
||||||
/// Configuration for one OpenID Connect provider.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct OidcProviderConfig {
|
|
||||||
pub provider: String,
|
|
||||||
pub issuer: String,
|
|
||||||
pub client_id: String,
|
|
||||||
pub client_secret: Option<String>,
|
|
||||||
pub redirect_uri: String,
|
|
||||||
pub post_login_redirect: String,
|
|
||||||
pub post_logout_redirect: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A flow error surfaced to the browser as a plain-text 400 (like kaya's
|
|
||||||
/// callback error responses).
|
|
||||||
#[derive(Debug, Clone, thiserror::Error)]
|
|
||||||
pub enum OidcFlowError {
|
|
||||||
#[error("{0}")]
|
|
||||||
BadRequest(String),
|
|
||||||
#[error("{0}")]
|
|
||||||
Internal(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OidcFlowError {
|
|
||||||
pub fn status(&self) -> axum::http::StatusCode {
|
|
||||||
match self {
|
|
||||||
OidcFlowError::BadRequest(_) => axum::http::StatusCode::BAD_REQUEST,
|
|
||||||
OidcFlowError::Internal(_) => axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn random_token(bytes: usize) -> String {
|
|
||||||
let mut buf = vec![0u8; bytes];
|
|
||||||
rand::thread_rng().fill_bytes(&mut buf);
|
|
||||||
URL_SAFE_NO_PAD.encode(buf)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct OidcClient {
|
|
||||||
config: OidcProviderConfig,
|
|
||||||
http: reqwest::Client,
|
|
||||||
metadata: Mutex<Option<Value>>,
|
|
||||||
jwks: Mutex<Option<Value>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OidcClient {
|
|
||||||
fn new(config: OidcProviderConfig) -> Self {
|
|
||||||
Self {
|
|
||||||
config,
|
|
||||||
http: reqwest::Client::new(),
|
|
||||||
metadata: Mutex::new(None),
|
|
||||||
jwks: Mutex::new(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn metadata(&self) -> Result<Value, OidcFlowError> {
|
|
||||||
let mut cache = self.metadata.lock().await;
|
|
||||||
if let Some(metadata) = cache.as_ref() {
|
|
||||||
return Ok(metadata.clone());
|
|
||||||
}
|
|
||||||
let url = format!(
|
|
||||||
"{}/.well-known/openid-configuration",
|
|
||||||
self.config.issuer.trim_end_matches('/')
|
|
||||||
);
|
|
||||||
let response = self
|
|
||||||
.http
|
|
||||||
.get(&url)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|err| OidcFlowError::Internal(format!("OIDC discovery failed: {err}")))?;
|
|
||||||
let response = response
|
|
||||||
.error_for_status()
|
|
||||||
.map_err(|err| OidcFlowError::Internal(format!("OIDC discovery failed: {err}")))?;
|
|
||||||
let metadata: Value = response
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|err| OidcFlowError::Internal(format!("OIDC discovery failed: {err}")))?;
|
|
||||||
*cache = Some(metadata.clone());
|
|
||||||
Ok(metadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn jwks(&self) -> Result<Value, OidcFlowError> {
|
|
||||||
{
|
|
||||||
let cache = self.jwks.lock().await;
|
|
||||||
if let Some(jwks) = cache.as_ref() {
|
|
||||||
return Ok(jwks.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let metadata = self.metadata().await?;
|
|
||||||
let jwks_uri = metadata
|
|
||||||
.get("jwks_uri")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.ok_or_else(|| {
|
|
||||||
OidcFlowError::Internal("OIDC provider does not advertise a jwks_uri".into())
|
|
||||||
})?;
|
|
||||||
let response = self
|
|
||||||
.http
|
|
||||||
.get(jwks_uri)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|err| OidcFlowError::Internal(format!("JWKS fetch failed: {err}")))?;
|
|
||||||
let response = response
|
|
||||||
.error_for_status()
|
|
||||||
.map_err(|err| OidcFlowError::Internal(format!("JWKS fetch failed: {err}")))?;
|
|
||||||
let jwks: Value = response
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|err| OidcFlowError::Internal(format!("JWKS fetch failed: {err}")))?;
|
|
||||||
*self.jwks.lock().await = Some(jwks.clone());
|
|
||||||
Ok(jwks)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return (authorization_url, state, nonce, code_verifier).
|
|
||||||
async fn build_authorization_url(
|
|
||||||
&self,
|
|
||||||
) -> Result<(String, String, String, String), OidcFlowError> {
|
|
||||||
let metadata = self.metadata().await?;
|
|
||||||
let endpoint = metadata
|
|
||||||
.get("authorization_endpoint")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.ok_or_else(|| {
|
|
||||||
OidcFlowError::Internal(
|
|
||||||
"OIDC provider does not advertise an authorization_endpoint".into(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let state = random_token(32);
|
|
||||||
let nonce = random_token(32);
|
|
||||||
let code_verifier = random_token(64);
|
|
||||||
let code_challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes()));
|
|
||||||
|
|
||||||
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
|
|
||||||
serializer.append_pair("client_id", &self.config.client_id);
|
|
||||||
serializer.append_pair("response_type", "code");
|
|
||||||
serializer.append_pair("scope", SCOPES);
|
|
||||||
serializer.append_pair("redirect_uri", &self.config.redirect_uri);
|
|
||||||
serializer.append_pair("state", &state);
|
|
||||||
serializer.append_pair("nonce", &nonce);
|
|
||||||
serializer.append_pair("code_challenge", &code_challenge);
|
|
||||||
serializer.append_pair("code_challenge_method", "S256");
|
|
||||||
let query = serializer.finish();
|
|
||||||
Ok((format!("{endpoint}?{query}"), state, nonce, code_verifier))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_token(
|
|
||||||
&self,
|
|
||||||
code: &str,
|
|
||||||
code_verifier: &str,
|
|
||||||
) -> Result<Map<String, Value>, OidcFlowError> {
|
|
||||||
let metadata = self.metadata().await?;
|
|
||||||
let endpoint = metadata
|
|
||||||
.get("token_endpoint")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.ok_or_else(|| {
|
|
||||||
OidcFlowError::Internal("OIDC provider does not advertise a token_endpoint".into())
|
|
||||||
})?;
|
|
||||||
let form = [
|
|
||||||
("grant_type", "authorization_code"),
|
|
||||||
("code", code),
|
|
||||||
("redirect_uri", self.config.redirect_uri.as_str()),
|
|
||||||
("client_id", self.config.client_id.as_str()),
|
|
||||||
("code_verifier", code_verifier),
|
|
||||||
];
|
|
||||||
let mut request = self.http.post(endpoint).form(&form);
|
|
||||||
if let Some(secret) = &self.config.client_secret {
|
|
||||||
request = request.basic_auth(&self.config.client_id, Some(secret));
|
|
||||||
}
|
|
||||||
let response = request
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|err| OidcFlowError::Internal(format!("token request failed: {err}")))?;
|
|
||||||
let response = response
|
|
||||||
.error_for_status()
|
|
||||||
.map_err(|err| OidcFlowError::Internal(format!("token request failed: {err}")))?;
|
|
||||||
response
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|err| OidcFlowError::Internal(format!("token response is not JSON: {err}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn validate_id_token(
|
|
||||||
&self,
|
|
||||||
id_token: &str,
|
|
||||||
nonce: &str,
|
|
||||||
) -> Result<Map<String, Value>, OidcFlowError> {
|
|
||||||
let payload = self.decode_id_token(id_token).await?;
|
|
||||||
if payload.get("nonce").and_then(Value::as_str) != Some(nonce) {
|
|
||||||
return Err(OidcFlowError::BadRequest(
|
|
||||||
"Authentication failed: ID token nonce mismatch".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn decode_id_token(&self, id_token: &str) -> Result<Map<String, Value>, OidcFlowError> {
|
|
||||||
let header = jsonwebtoken::decode_header(id_token)
|
|
||||||
.map_err(|err| OidcFlowError::BadRequest(format!("Authentication failed: {err}")))?;
|
|
||||||
let alg_name = format!("{:?}", header.alg);
|
|
||||||
if alg_name == "none" {
|
|
||||||
return Err(OidcFlowError::BadRequest(
|
|
||||||
"Authentication failed: Unsigned ID tokens are not allowed".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let allowed = [
|
|
||||||
"RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512",
|
|
||||||
"EdDSA",
|
|
||||||
];
|
|
||||||
if !allowed.contains(&alg_name.as_str()) {
|
|
||||||
return Err(OidcFlowError::BadRequest(format!(
|
|
||||||
"Authentication failed: ID token algorithm {alg_name} is not allowed"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let jwks = self.jwks().await?;
|
|
||||||
let keys = jwks.get("keys").and_then(Value::as_array).ok_or_else(|| {
|
|
||||||
OidcFlowError::BadRequest("Authentication failed: JWKS contains no keys".into())
|
|
||||||
})?;
|
|
||||||
let selected = if let Some(kid) = header.kid.as_deref() {
|
|
||||||
keys.iter()
|
|
||||||
.find(|key| key.get("kid").and_then(Value::as_str) == Some(kid))
|
|
||||||
.ok_or_else(|| {
|
|
||||||
OidcFlowError::BadRequest(format!(
|
|
||||||
"Authentication failed: No signing key found for kid {kid}"
|
|
||||||
))
|
|
||||||
})?
|
|
||||||
} else if keys.len() == 1 {
|
|
||||||
&keys[0]
|
|
||||||
} else {
|
|
||||||
return Err(OidcFlowError::BadRequest(
|
|
||||||
"Authentication failed: ID token has no kid and JWKS contains multiple keys".into(),
|
|
||||||
));
|
|
||||||
};
|
|
||||||
let jwk: jsonwebtoken::jwk::Jwk = serde_json::from_value(selected.clone())
|
|
||||||
.map_err(|err| OidcFlowError::BadRequest(format!("Authentication failed: {err}")))?;
|
|
||||||
let key = jsonwebtoken::DecodingKey::from_jwk(&jwk)
|
|
||||||
.map_err(|err| OidcFlowError::BadRequest(format!("Authentication failed: {err}")))?;
|
|
||||||
|
|
||||||
let mut validation = jsonwebtoken::Validation::new(header.alg);
|
|
||||||
validation.set_audience(&[self.config.client_id.as_str()]);
|
|
||||||
validation.validate_nbf = true;
|
|
||||||
let data = jsonwebtoken::decode::<Map<String, Value>>(id_token, &key, &validation)
|
|
||||||
.map_err(|err| OidcFlowError::BadRequest(format!("Authentication failed: {err}")))?;
|
|
||||||
let claims = data.claims;
|
|
||||||
|
|
||||||
let metadata = self.metadata().await?;
|
|
||||||
let expected = metadata
|
|
||||||
.get("issuer")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.ok_or_else(|| {
|
|
||||||
OidcFlowError::Internal("OIDC provider does not advertise an issuer".into())
|
|
||||||
})?;
|
|
||||||
let expected = if expected.contains("{tenantid}") || expected.contains("{tenantId}") {
|
|
||||||
let tenant = claims.get("tid").and_then(Value::as_str).ok_or_else(|| {
|
|
||||||
OidcFlowError::BadRequest("Authentication failed: Invalid issuer".into())
|
|
||||||
})?;
|
|
||||||
let re = Regex::new(r"(?i)\{tenantid\}").expect("valid tenant regex");
|
|
||||||
re.replace(expected, tenant).to_string()
|
|
||||||
} else {
|
|
||||||
expected.to_string()
|
|
||||||
};
|
|
||||||
if claims.get("iss").and_then(Value::as_str) != Some(expected.as_str()) {
|
|
||||||
return Err(OidcFlowError::BadRequest(
|
|
||||||
"Authentication failed: Invalid issuer".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(claims)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_userinfo(
|
|
||||||
&self,
|
|
||||||
access_token: &str,
|
|
||||||
) -> Result<Map<String, Value>, OidcFlowError> {
|
|
||||||
let metadata = self.metadata().await?;
|
|
||||||
let endpoint = metadata
|
|
||||||
.get("userinfo_endpoint")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.ok_or_else(|| {
|
|
||||||
OidcFlowError::Internal(
|
|
||||||
"OIDC provider does not advertise a userinfo_endpoint".into(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let response = self
|
|
||||||
.http
|
|
||||||
.get(endpoint)
|
|
||||||
.bearer_auth(access_token)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|err| OidcFlowError::Internal(format!("userinfo request failed: {err}")))?;
|
|
||||||
let response = response
|
|
||||||
.error_for_status()
|
|
||||||
.map_err(|err| OidcFlowError::Internal(format!("userinfo request failed: {err}")))?;
|
|
||||||
response
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|err| OidcFlowError::Internal(format!("userinfo response is not JSON: {err}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn build_logout_url(
|
|
||||||
&self,
|
|
||||||
id_token: Option<&str>,
|
|
||||||
) -> Result<Option<String>, OidcFlowError> {
|
|
||||||
let metadata = self.metadata().await?;
|
|
||||||
let Some(endpoint) = metadata.get("end_session_endpoint").and_then(Value::as_str) else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
|
|
||||||
serializer.append_pair(
|
|
||||||
"post_logout_redirect_uri",
|
|
||||||
&self.config.post_logout_redirect,
|
|
||||||
);
|
|
||||||
if let Some(id_token) = id_token {
|
|
||||||
serializer.append_pair("id_token_hint", id_token);
|
|
||||||
}
|
|
||||||
Ok(Some(format!("{endpoint}?{}", serializer.finish())))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct OidcProvider {
|
|
||||||
config: OidcProviderConfig,
|
|
||||||
client: OidcClient,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OidcProvider {
|
|
||||||
fn key(&self, name: &str) -> String {
|
|
||||||
format!("oidc:{}:{}", self.config.provider, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn name(&self) -> &str {
|
|
||||||
&self.config.provider
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start the login flow: build the authorization URL and stash state,
|
|
||||||
/// nonce and PKCE verifier in the session; returns the redirect target.
|
|
||||||
pub async fn begin_login(&self, session: &SessionHandle) -> Result<String, OidcFlowError> {
|
|
||||||
let (url, state, nonce, code_verifier) = self.client.build_authorization_url().await?;
|
|
||||||
let mut session = session.lock().await;
|
|
||||||
session.insert(self.key("state"), json!(state));
|
|
||||||
session.insert(self.key("nonce"), json!(nonce));
|
|
||||||
session.insert(self.key("code_verifier"), json!(code_verifier));
|
|
||||||
Ok(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle the provider callback; returns the post-login redirect target.
|
|
||||||
pub async fn handle_callback(
|
|
||||||
&self,
|
|
||||||
session: &SessionHandle,
|
|
||||||
query: &HashMap<String, Vec<String>>,
|
|
||||||
) -> Result<String, OidcFlowError> {
|
|
||||||
let first = |name: &str| query.get(name).and_then(|values| values.first()).cloned();
|
|
||||||
if let Some(error) = first("error") {
|
|
||||||
let mut message = format!("OIDC error: {error}");
|
|
||||||
if let Some(description) = first("error_description") {
|
|
||||||
message.push_str(&format!(": {description}"));
|
|
||||||
}
|
|
||||||
return Err(OidcFlowError::BadRequest(message));
|
|
||||||
}
|
|
||||||
let (Some(code), Some(state)) = (first("code"), first("state")) else {
|
|
||||||
return Err(OidcFlowError::BadRequest(
|
|
||||||
"Missing code or state".to_string(),
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
let (expected_state, nonce, code_verifier) = {
|
|
||||||
let session = session.lock().await;
|
|
||||||
(
|
|
||||||
session
|
|
||||||
.get(&self.key("state"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::to_string),
|
|
||||||
session
|
|
||||||
.get(&self.key("nonce"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string(),
|
|
||||||
session
|
|
||||||
.get(&self.key("code_verifier"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if Some(state.as_str()) != expected_state.as_deref() {
|
|
||||||
return Err(OidcFlowError::BadRequest("Invalid state".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let token_response = self.client.fetch_token(&code, &code_verifier).await?;
|
|
||||||
let Some(id_token) = token_response.get("id_token").and_then(Value::as_str) else {
|
|
||||||
return Err(OidcFlowError::BadRequest(
|
|
||||||
"No id_token in token response".to_string(),
|
|
||||||
));
|
|
||||||
};
|
|
||||||
let id_token = id_token.to_string();
|
|
||||||
let claims = self.client.validate_id_token(&id_token, &nonce).await?;
|
|
||||||
|
|
||||||
let mut user = claims.clone();
|
|
||||||
let access_token = token_response
|
|
||||||
.get("access_token")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::to_string);
|
|
||||||
if let Some(access_token) = access_token.as_deref() {
|
|
||||||
let userinfo = self.client.fetch_userinfo(access_token).await?;
|
|
||||||
for (key, value) in userinfo {
|
|
||||||
user.insert(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut session = session.lock().await;
|
|
||||||
session.remove(&self.key("state"));
|
|
||||||
session.remove(&self.key("code_verifier"));
|
|
||||||
session.insert(self.key("user"), Value::Object(user));
|
|
||||||
session.insert(self.key("id_token"), json!(id_token));
|
|
||||||
if let Some(access_token) = access_token {
|
|
||||||
session.insert(self.key("access_token"), json!(access_token));
|
|
||||||
}
|
|
||||||
if let Some(refresh_token) = token_response.get("refresh_token") {
|
|
||||||
session.insert(self.key("refresh_token"), refresh_token.clone());
|
|
||||||
}
|
|
||||||
self.strip_other_identities(&mut session);
|
|
||||||
session.regenerate_id();
|
|
||||||
Ok(self.config.post_login_redirect.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drop this provider's session state; returns the post-logout redirect.
|
|
||||||
pub async fn logout(&self, session: &SessionHandle) -> Result<String, OidcFlowError> {
|
|
||||||
let mut session = session.lock().await;
|
|
||||||
let id_token = session
|
|
||||||
.get(&self.key("id_token"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::to_string);
|
|
||||||
let prefix = format!("oidc:{}:", self.config.provider);
|
|
||||||
let keys: Vec<String> = session
|
|
||||||
.keys()
|
|
||||||
.filter(|key| key.starts_with(&prefix))
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
for key in keys {
|
|
||||||
session.remove(&key);
|
|
||||||
}
|
|
||||||
let any_user = session
|
|
||||||
.keys()
|
|
||||||
.any(|key| key.starts_with("oidc:") && key.ends_with(":user"));
|
|
||||||
if !any_user {
|
|
||||||
session.invalidate();
|
|
||||||
}
|
|
||||||
drop(session);
|
|
||||||
match self.client.build_logout_url(id_token.as_deref()).await {
|
|
||||||
Ok(Some(url)) => Ok(url),
|
|
||||||
_ => Ok(self.config.post_logout_redirect.clone()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn strip_other_identities(&self, session: &mut crate::session::SessionData) {
|
|
||||||
let own_prefix = format!("oidc:{}:", self.config.provider);
|
|
||||||
let keys: Vec<String> = session
|
|
||||||
.keys()
|
|
||||||
.filter(|key| {
|
|
||||||
key.starts_with("oidc:")
|
|
||||||
&& !key.starts_with(&own_prefix)
|
|
||||||
&& IDENTITY_FIELDS
|
|
||||||
.iter()
|
|
||||||
.any(|field| key.ends_with(&format!(":{field}")))
|
|
||||||
})
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
for key in keys {
|
|
||||||
session.remove(&key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The set of configured providers, in configuration order.
|
|
||||||
pub struct OidcProviders {
|
|
||||||
providers: Vec<Arc<OidcProvider>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OidcProviders {
|
|
||||||
pub fn new(configs: Vec<OidcProviderConfig>) -> Result<Self> {
|
|
||||||
let pattern = Regex::new(r"^[A-Za-z0-9_-]+$").expect("valid provider regex");
|
|
||||||
let mut seen = std::collections::HashSet::new();
|
|
||||||
let mut providers = Vec::new();
|
|
||||||
for config in configs {
|
|
||||||
if !pattern.is_match(&config.provider) {
|
|
||||||
return Err(Error::Internal(format!(
|
|
||||||
"provider must be a non-empty string of letters, digits, underscores or hyphens (got {:?})",
|
|
||||||
config.provider
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
if !seen.insert(config.provider.clone()) {
|
|
||||||
return Err(Error::Internal(format!(
|
|
||||||
"duplicate OIDC provider name {:?}",
|
|
||||||
config.provider
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let client = OidcClient::new(config.clone());
|
|
||||||
providers.push(Arc::new(OidcProvider { config, client }));
|
|
||||||
}
|
|
||||||
if providers.is_empty() {
|
|
||||||
return Err(Error::Internal(
|
|
||||||
"At least one OIDC provider is required".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(Self { providers })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn base_path(&self) -> &'static str {
|
|
||||||
BASE_PATH
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn providers(&self) -> impl Iterator<Item = &Arc<OidcProvider>> {
|
|
||||||
self.providers.iter()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn first(&self) -> &Arc<OidcProvider> {
|
|
||||||
&self.providers[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get(&self, name: &str) -> Option<&Arc<OidcProvider>> {
|
|
||||||
self.providers
|
|
||||||
.iter()
|
|
||||||
.find(|provider| provider.name() == name)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn login_url(&self, name: &str) -> String {
|
|
||||||
format!("{BASE_PATH}/{name}/login")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,122 +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
|
|
||||||
//! cursor is a base64url-encoded JSON object mapping the sort-field names to
|
|
||||||
//! the values of the last item; it is opaque to callers.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
|
||||||
use base64::Engine;
|
|
||||||
use serde_json::{Map, Value};
|
|
||||||
|
|
||||||
pub const DEFAULT_LIMIT: usize = 20;
|
|
||||||
pub const MAX_LIMIT: usize = 100;
|
|
||||||
pub const MIN_LIMIT: usize = 1;
|
|
||||||
|
|
||||||
pub const CURSOR_PARAM: &str = "cursor";
|
|
||||||
pub const LIMIT_PARAM: &str = "limit";
|
|
||||||
|
|
||||||
/// Raised when the `cursor`/`limit` query params cannot be decoded; the
|
|
||||||
/// message is sent verbatim in the 400 response.
|
|
||||||
#[derive(Debug, Clone, thiserror::Error)]
|
|
||||||
pub enum CursorDecodeError {
|
|
||||||
#[error("invalid cursor")]
|
|
||||||
InvalidCursor,
|
|
||||||
#[error("invalid limit")]
|
|
||||||
InvalidLimit,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Cursor {
|
|
||||||
pub limit: usize,
|
|
||||||
pub after: Option<Map<String, Value>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encode sort-key values as an opaque cursor.
|
|
||||||
pub fn encode_cursor(values: &Map<String, Value>) -> String {
|
|
||||||
let raw = serde_json::to_vec(values).unwrap_or_default();
|
|
||||||
URL_SAFE_NO_PAD.encode(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decode an opaque cursor; tolerates missing base64 padding.
|
|
||||||
pub fn decode_cursor(value: Option<&str>) -> Result<Option<Map<String, Value>>, CursorDecodeError> {
|
|
||||||
let Some(value) = value else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
if value.is_empty() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
// Tolerate both padded and unpadded base64url input.
|
|
||||||
let unpadded = value.trim_end_matches('=');
|
|
||||||
let raw = URL_SAFE_NO_PAD
|
|
||||||
.decode(unpadded)
|
|
||||||
.map_err(|_| CursorDecodeError::InvalidCursor)?;
|
|
||||||
let parsed: Value =
|
|
||||||
serde_json::from_slice(&raw).map_err(|_| CursorDecodeError::InvalidCursor)?;
|
|
||||||
match parsed {
|
|
||||||
Value::Object(map) => Ok(Some(map)),
|
|
||||||
_ => Err(CursorDecodeError::InvalidCursor),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse the `limit` and `cursor` query parameters.
|
|
||||||
pub fn parse_cursor_params(
|
|
||||||
params: &HashMap<String, Vec<String>>,
|
|
||||||
) -> Result<Cursor, CursorDecodeError> {
|
|
||||||
let limit = match params.get(LIMIT_PARAM).and_then(|values| values.first()) {
|
|
||||||
Some(value) => value
|
|
||||||
.parse::<i64>()
|
|
||||||
.map_err(|_| CursorDecodeError::InvalidLimit)?,
|
|
||||||
None => DEFAULT_LIMIT as i64,
|
|
||||||
};
|
|
||||||
let limit = limit.clamp(MIN_LIMIT as i64, MAX_LIMIT as i64) as usize;
|
|
||||||
let after = decode_cursor(
|
|
||||||
params
|
|
||||||
.get(CURSOR_PARAM)
|
|
||||||
.and_then(|values| values.first())
|
|
||||||
.map(String::as_str),
|
|
||||||
)?;
|
|
||||||
Ok(Cursor { limit, after })
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cursor_round_trip() {
|
|
||||||
let mut values = Map::new();
|
|
||||||
values.insert("finished_at".into(), json!("2026-09-19T06:59:17+00:00"));
|
|
||||||
values.insert("id".into(), json!("7c9e6679-7425-40de-944b-e07fc1f90ae7"));
|
|
||||||
let encoded = encode_cursor(&values);
|
|
||||||
assert!(!encoded.contains('='));
|
|
||||||
let decoded = decode_cursor(Some(&encoded)).unwrap().unwrap();
|
|
||||||
assert_eq!(decoded, values);
|
|
||||||
// Padded input is tolerated too.
|
|
||||||
let padded = format!("{encoded}==");
|
|
||||||
let decoded = decode_cursor(Some(&padded)).unwrap().unwrap();
|
|
||||||
assert_eq!(decoded, values);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_defaults_and_clamps() {
|
|
||||||
let params = HashMap::new();
|
|
||||||
let cursor = parse_cursor_params(¶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)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
//! The collaborators every platform endpoint needs, bundled so route and
|
|
||||||
//! websocket modules never reach for global state (mirrors
|
|
||||||
//! `tavolo.platform.mixin.Platform`).
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crate::deadlines::DeadlineScheduler;
|
|
||||||
use crate::oidc::OidcProviders;
|
|
||||||
use crate::registry::GameRegistry;
|
|
||||||
use crate::stats::SharedStatsRepo;
|
|
||||||
use crate::store::SharedGameStore;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct Platform {
|
|
||||||
pub registry: Arc<GameRegistry>,
|
|
||||||
pub game_store: SharedGameStore,
|
|
||||||
pub scheduler: Arc<DeadlineScheduler>,
|
|
||||||
pub oidc: Arc<OidcProviders>,
|
|
||||||
pub stats: SharedStatsRepo,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type SharedPlatform = Arc<Platform>;
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
//! Registry of the games the platform can host.
|
|
||||||
//!
|
|
||||||
//! The registry maps a `game_type` id to its [`GameEngine`] implementation
|
|
||||||
//! and is the single source of truth for which games exist: the lobby lists
|
|
||||||
//! it, the store uses it to deserialize opaque game state, and the stats
|
|
||||||
//! endpoints use it to validate `game_type` filters. Games register
|
|
||||||
//! themselves at composition time; the platform itself ships no game.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crate::engine::GameEngine;
|
|
||||||
use crate::errors::{GameError, Result};
|
|
||||||
|
|
||||||
/// An ordered collection of game engines, keyed by their id.
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct GameRegistry {
|
|
||||||
engines: Vec<Arc<dyn GameEngine>>,
|
|
||||||
by_id: HashMap<String, usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GameRegistry {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add `engine`; the first registered becomes the default.
|
|
||||||
pub fn register(&mut self, engine: Arc<dyn GameEngine>) -> Result<()> {
|
|
||||||
if self.by_id.contains_key(engine.id()) {
|
|
||||||
return Err(GameError::illegal_move(format!(
|
|
||||||
"duplicate game engine id: {:?}",
|
|
||||||
engine.id()
|
|
||||||
))
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
self.by_id
|
|
||||||
.insert(engine.id().to_string(), self.engines.len());
|
|
||||||
self.engines.push(engine);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the engine for `game_type`, or `None`.
|
|
||||||
pub fn get(&self, game_type: &str) -> Option<&Arc<dyn GameEngine>> {
|
|
||||||
self.by_id.get(game_type).map(|index| &self.engines[*index])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the engine for `game_type` or raise an error.
|
|
||||||
pub fn require(&self, game_type: &str) -> Result<&Arc<dyn GameEngine>> {
|
|
||||||
self.get(game_type).ok_or_else(|| {
|
|
||||||
GameError::unknown_game_type(format!("unknown game_type: {game_type:?}")).into()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The first registered engine, used when no type is requested.
|
|
||||||
pub fn default_engine(&self) -> Result<&Arc<dyn GameEngine>> {
|
|
||||||
self.engines
|
|
||||||
.first()
|
|
||||||
.ok_or_else(|| crate::errors::Error::Internal("no game engines registered".into()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn all(&self) -> &[Arc<dyn GameEngine>] {
|
|
||||||
&self.engines
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
//! Authentication routes for the single-page app.
|
|
||||||
//!
|
|
||||||
//! Each configured provider gets `/auth/<provider>/login`, `.../callback`
|
|
||||||
//! and `.../logout` routes (registered per provider, so an unknown provider
|
|
||||||
//! path falls through to the SPA fallback like in the Python server). This
|
|
||||||
//! module adds the provider-agnostic pieces:
|
|
||||||
//!
|
|
||||||
//! * `GET /api/auth/providers` lists the configured providers with their
|
|
||||||
//! login URLs, so the SPA can offer a choice without hardcoding names;
|
|
||||||
//! * `GET /auth/login` redirects to the first configured provider;
|
|
||||||
//! * `GET /auth/logout` redirects to the provider this session is logged in
|
|
||||||
//! with (or the SPA root when not logged in).
|
|
||||||
|
|
||||||
use axum::extract::{Extension, RawQuery, State};
|
|
||||||
use axum::http::StatusCode;
|
|
||||||
use axum::response::Response;
|
|
||||||
use axum::routing::get;
|
|
||||||
use axum::Router;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
use crate::auth::logged_in_providers;
|
|
||||||
use crate::http::{extract_query_params, redirect, send_json, send_text};
|
|
||||||
use crate::oidc::OidcFlowError;
|
|
||||||
use crate::platform::SharedPlatform;
|
|
||||||
use crate::session::SessionHandle;
|
|
||||||
|
|
||||||
pub fn routes(providers: &[String]) -> Router<SharedPlatform> {
|
|
||||||
let mut router = Router::new()
|
|
||||||
.route("/api/auth/providers", get(providers_handler))
|
|
||||||
.route("/auth/login", get(generic_login))
|
|
||||||
.route("/auth/logout", get(generic_logout));
|
|
||||||
for provider in providers {
|
|
||||||
let name = provider.clone();
|
|
||||||
router = router.route(
|
|
||||||
&format!("/auth/{name}/login"),
|
|
||||||
get(
|
|
||||||
move |state: State<SharedPlatform>, session: Extension<SessionHandle>| {
|
|
||||||
let name = name.clone();
|
|
||||||
async move { provider_login(state, session, name).await }
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
let name = provider.clone();
|
|
||||||
router = router.route(
|
|
||||||
&format!("/auth/{name}/callback"),
|
|
||||||
get(
|
|
||||||
move |state: State<SharedPlatform>,
|
|
||||||
query: RawQuery,
|
|
||||||
session: Extension<SessionHandle>| {
|
|
||||||
let name = name.clone();
|
|
||||||
async move { provider_callback(state, query, session, name).await }
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
let name = provider.clone();
|
|
||||||
router = router.route(
|
|
||||||
&format!("/auth/{name}/logout"),
|
|
||||||
get(
|
|
||||||
move |state: State<SharedPlatform>, session: Extension<SessionHandle>| {
|
|
||||||
let name = name.clone();
|
|
||||||
async move { provider_logout(state, session, name).await }
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
router
|
|
||||||
}
|
|
||||||
|
|
||||||
fn flow_error_response(error: OidcFlowError) -> Response {
|
|
||||||
match error {
|
|
||||||
OidcFlowError::BadRequest(message) => send_text(
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
message,
|
|
||||||
"text/plain; charset=utf-8",
|
|
||||||
),
|
|
||||||
OidcFlowError::Internal(message) => send_text(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
message,
|
|
||||||
"text/plain; charset=utf-8",
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Names and login URLs of the configured providers, in configuration order.
|
|
||||||
async fn providers_handler(State(platform): State<SharedPlatform>) -> Response {
|
|
||||||
let results: Vec<serde_json::Value> = platform
|
|
||||||
.oidc
|
|
||||||
.providers()
|
|
||||||
.map(|provider| {
|
|
||||||
json!({
|
|
||||||
"name": provider.name(),
|
|
||||||
"login_url": platform.oidc.login_url(provider.name()),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
send_json(StatusCode::OK, &json!({ "providers": results }))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Redirect to the first configured provider's login.
|
|
||||||
async fn generic_login(State(platform): State<SharedPlatform>) -> Response {
|
|
||||||
redirect(platform.oidc.login_url(platform.oidc.first().name()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Redirect to the logged-in provider's logout (or the SPA root).
|
|
||||||
async fn generic_logout(
|
|
||||||
State(platform): State<SharedPlatform>,
|
|
||||||
Extension(session): Extension<SessionHandle>,
|
|
||||||
) -> Response {
|
|
||||||
let logged_in = {
|
|
||||||
let session = session.lock().await;
|
|
||||||
logged_in_providers(&session)
|
|
||||||
};
|
|
||||||
match logged_in.first() {
|
|
||||||
Some(provider) if platform.oidc.get(provider).is_some() => {
|
|
||||||
redirect(format!("/auth/{provider}/logout"))
|
|
||||||
}
|
|
||||||
_ => redirect("/"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn provider_login(
|
|
||||||
State(platform): State<SharedPlatform>,
|
|
||||||
Extension(session): Extension<SessionHandle>,
|
|
||||||
provider: String,
|
|
||||||
) -> Response {
|
|
||||||
let Some(provider) = platform.oidc.get(&provider) else {
|
|
||||||
return send_text(
|
|
||||||
StatusCode::NOT_FOUND,
|
|
||||||
"not found",
|
|
||||||
"text/plain; charset=utf-8",
|
|
||||||
);
|
|
||||||
};
|
|
||||||
match provider.begin_login(&session).await {
|
|
||||||
Ok(location) => redirect(location),
|
|
||||||
Err(error) => flow_error_response(error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn provider_callback(
|
|
||||||
State(platform): State<SharedPlatform>,
|
|
||||||
RawQuery(query): RawQuery,
|
|
||||||
Extension(session): Extension<SessionHandle>,
|
|
||||||
provider: String,
|
|
||||||
) -> Response {
|
|
||||||
let Some(provider) = platform.oidc.get(&provider) else {
|
|
||||||
return send_text(
|
|
||||||
StatusCode::NOT_FOUND,
|
|
||||||
"not found",
|
|
||||||
"text/plain; charset=utf-8",
|
|
||||||
);
|
|
||||||
};
|
|
||||||
let params = extract_query_params(query.as_deref());
|
|
||||||
match provider.handle_callback(&session, ¶ms).await {
|
|
||||||
Ok(location) => redirect(location),
|
|
||||||
Err(error) => flow_error_response(error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn provider_logout(
|
|
||||||
State(platform): State<SharedPlatform>,
|
|
||||||
Extension(session): Extension<SessionHandle>,
|
|
||||||
provider: String,
|
|
||||||
) -> Response {
|
|
||||||
let Some(provider) = platform.oidc.get(&provider) else {
|
|
||||||
return send_text(
|
|
||||||
StatusCode::NOT_FOUND,
|
|
||||||
"not found",
|
|
||||||
"text/plain; charset=utf-8",
|
|
||||||
);
|
|
||||||
};
|
|
||||||
match provider.logout(&session).await {
|
|
||||||
Ok(location) => redirect(location),
|
|
||||||
Err(error) => flow_error_response(error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,421 +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; these
|
|
||||||
//! endpoints cover creation, joining and snapshotting state.
|
|
||||||
//!
|
|
||||||
//! Everything game-specific — which options a game accepts, when the lobby
|
|
||||||
//! is full, what the personalized view looks like — is delegated to the
|
|
||||||
//! engine registered for the session's `game_type`.
|
|
||||||
|
|
||||||
use axum::body::Bytes;
|
|
||||||
use axum::extract::{Path, RawQuery, State};
|
|
||||||
use axum::http::StatusCode;
|
|
||||||
use axum::response::Response;
|
|
||||||
use axum::routing::{get, post};
|
|
||||||
use axum::Extension;
|
|
||||||
use axum::Router;
|
|
||||||
use rand::seq::SliceRandom;
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
|
|
||||||
use crate::auth::{current_user, display_name};
|
|
||||||
use crate::datetime::now;
|
|
||||||
use crate::engine::GameSession;
|
|
||||||
use crate::http::{
|
|
||||||
extract_query_params, py_repr, read_json, read_json_optional, send_error, send_json,
|
|
||||||
unauthorized,
|
|
||||||
};
|
|
||||||
use crate::platform::{Platform, SharedPlatform};
|
|
||||||
use crate::session::SessionHandle;
|
|
||||||
|
|
||||||
/// Join codes avoid characters that are easy to confuse when read aloud.
|
|
||||||
const CODE_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
||||||
const CODE_LENGTH: usize = 6;
|
|
||||||
const MAX_CODE_ATTEMPTS: usize = 20;
|
|
||||||
|
|
||||||
pub fn routes() -> Router<SharedPlatform> {
|
|
||||||
Router::new()
|
|
||||||
.route("/api/game-types", get(list_game_types))
|
|
||||||
.route("/api/games", post(create_game))
|
|
||||||
.route("/api/games/join", post(join_game))
|
|
||||||
.route("/api/games/preview", get(preview_game))
|
|
||||||
.route("/api/games/{game_id}", get(get_game))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn random_code() -> String {
|
|
||||||
let mut rng = rand::thread_rng();
|
|
||||||
(0..CODE_LENGTH)
|
|
||||||
.map(|_| *CODE_ALPHABET.choose(&mut rng).expect("non-empty alphabet") as char)
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn unique_code(platform: &Platform) -> Result<String, Response> {
|
|
||||||
for _ in 0..MAX_CODE_ATTEMPTS {
|
|
||||||
let code = random_code();
|
|
||||||
match platform.game_store.find_by_code(&code).await {
|
|
||||||
Ok(None) => return Ok(code),
|
|
||||||
Ok(Some(_)) => continue,
|
|
||||||
Err(err) => {
|
|
||||||
tracing::error!("join code lookup failed: {err}");
|
|
||||||
return Err(send_error(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"could not allocate a join code",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(send_error(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"could not allocate a unique join code",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn merge(base: &mut Value, extra: Value) {
|
|
||||||
if let (Value::Object(base), Value::Object(extra)) = (base, extra) {
|
|
||||||
for (key, value) in extra {
|
|
||||||
base.insert(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn lobby_payload(platform: &Platform, game: &GameSession) -> Result<Value, Response> {
|
|
||||||
let engine = platform
|
|
||||||
.registry
|
|
||||||
.require(&game.game_type)
|
|
||||||
.map_err(|_| send_error(StatusCode::INTERNAL_SERVER_ERROR, "unknown game type"))?;
|
|
||||||
let players: Vec<Value> = game
|
|
||||||
.players
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(index, seat)| {
|
|
||||||
json!({
|
|
||||||
"sub": seat.user_sub,
|
|
||||||
"name": seat.display_name,
|
|
||||||
"seat": index,
|
|
||||||
"team": seat.team,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let mut payload = json!({
|
|
||||||
"id": game.id,
|
|
||||||
"join_code": game.join_code,
|
|
||||||
"game_type": game.game_type,
|
|
||||||
"players": players,
|
|
||||||
"seats_open": engine.max_players().saturating_sub(game.players.len()),
|
|
||||||
});
|
|
||||||
merge(&mut payload, engine.lobby_view(game));
|
|
||||||
Ok(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view_payload(platform: &Platform, game: &GameSession, sub: &str) -> Result<Value, Response> {
|
|
||||||
let engine = platform
|
|
||||||
.registry
|
|
||||||
.require(&game.game_type)
|
|
||||||
.map_err(|_| send_error(StatusCode::INTERNAL_SERVER_ERROR, "unknown game type"))?;
|
|
||||||
let mut payload = json!({
|
|
||||||
"id": game.id,
|
|
||||||
"join_code": game.join_code,
|
|
||||||
"game_type": game.game_type,
|
|
||||||
});
|
|
||||||
merge(&mut payload, engine.view_for(game, sub));
|
|
||||||
Ok(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn preview_payload(platform: &Platform, game: &GameSession) -> Result<Value, Response> {
|
|
||||||
let engine = platform
|
|
||||||
.registry
|
|
||||||
.require(&game.game_type)
|
|
||||||
.map_err(|_| send_error(StatusCode::INTERNAL_SERVER_ERROR, "unknown game type"))?;
|
|
||||||
let mut payload = lobby_payload(platform, game)?;
|
|
||||||
merge(
|
|
||||||
&mut payload,
|
|
||||||
json!({
|
|
||||||
"game_name": engine.name(),
|
|
||||||
"game_description": engine.description(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
Ok(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every game the platform can host, for the match-creation dropdown.
|
|
||||||
async fn list_game_types(State(platform): State<SharedPlatform>) -> Response {
|
|
||||||
let results: Vec<Value> = platform
|
|
||||||
.registry
|
|
||||||
.all()
|
|
||||||
.iter()
|
|
||||||
.map(|engine| {
|
|
||||||
json!({
|
|
||||||
"id": engine.id(),
|
|
||||||
"name": engine.name(),
|
|
||||||
"description": engine.description(),
|
|
||||||
"min_players": engine.min_players(),
|
|
||||||
"max_players": engine.max_players(),
|
|
||||||
"options_schema": engine.options_schema(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
send_json(StatusCode::OK, &json!({ "results": results }))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a lobby game and seat the caller in seat 0.
|
|
||||||
async fn create_game(
|
|
||||||
State(platform): State<SharedPlatform>,
|
|
||||||
Extension(session): Extension<SessionHandle>,
|
|
||||||
body: Bytes,
|
|
||||||
) -> Response {
|
|
||||||
let body = match read_json_optional(&body) {
|
|
||||||
Ok(body) => body,
|
|
||||||
Err(err) => return send_error(StatusCode::BAD_REQUEST, err.to_string()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let engine = match body.get("game_type") {
|
|
||||||
None | Some(Value::Null) => match platform.registry.default_engine() {
|
|
||||||
Ok(engine) => engine.clone(),
|
|
||||||
Err(_) => {
|
|
||||||
return send_error(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"no game engines registered",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Some(Value::String(name)) => match platform.registry.require(name) {
|
|
||||||
Ok(engine) => engine.clone(),
|
|
||||||
Err(_) => {
|
|
||||||
return send_error(
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
format!(
|
|
||||||
"unknown game_type: {}",
|
|
||||||
py_repr(&Value::String(name.clone()))
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Some(other) => {
|
|
||||||
return send_error(
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
format!("unknown game_type: {}", py_repr(other)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let options = body.get("options").cloned().unwrap_or_else(|| json!({}));
|
|
||||||
let Value::Object(options) = options else {
|
|
||||||
return send_error(StatusCode::BAD_REQUEST, "options must be an object");
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(user) = current_user(&session).await else {
|
|
||||||
return unauthorized();
|
|
||||||
};
|
|
||||||
|
|
||||||
let join_code = match unique_code(&platform).await {
|
|
||||||
Ok(code) => code,
|
|
||||||
Err(response) => return response,
|
|
||||||
};
|
|
||||||
let mut game = GameSession::new(uuid::Uuid::new_v4().to_string(), engine.id(), join_code);
|
|
||||||
game.creator_sub = user.sub.clone();
|
|
||||||
game.players = vec![crate::engine::Seat::new(&user.sub, display_name(&user))];
|
|
||||||
game.created_at = Some(now());
|
|
||||||
|
|
||||||
if let Err(err) = engine.create(&mut game, &options) {
|
|
||||||
match err.as_game() {
|
|
||||||
Some(game_error) => {
|
|
||||||
return send_error(StatusCode::BAD_REQUEST, game_error.message.clone())
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
tracing::error!("game create failed: {err}");
|
|
||||||
return send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not create game");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Err(err) = platform.game_store.save(&game).await {
|
|
||||||
tracing::error!("game save failed: {err}");
|
|
||||||
return send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not create game");
|
|
||||||
}
|
|
||||||
tracing::info!(
|
|
||||||
"game {} created by {} ({}, options {})",
|
|
||||||
game.id,
|
|
||||||
user.sub,
|
|
||||||
engine.id(),
|
|
||||||
serde_json::to_string(&options).unwrap_or_default()
|
|
||||||
);
|
|
||||||
match lobby_payload(&platform, &game) {
|
|
||||||
Ok(payload) => send_json(StatusCode::CREATED, &payload),
|
|
||||||
Err(response) => response,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Join with `{"code": "ABC123"}`; the last player triggers the start.
|
|
||||||
async fn join_game(
|
|
||||||
State(platform): State<SharedPlatform>,
|
|
||||||
Extension(session): Extension<SessionHandle>,
|
|
||||||
body: Bytes,
|
|
||||||
) -> Response {
|
|
||||||
let body = match read_json(&body) {
|
|
||||||
Ok(body) => body,
|
|
||||||
Err(err) => return send_error(StatusCode::BAD_REQUEST, err.to_string()),
|
|
||||||
};
|
|
||||||
let Some(code) = body
|
|
||||||
.get("code")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|code| !code.is_empty())
|
|
||||||
else {
|
|
||||||
return send_error(StatusCode::BAD_REQUEST, "code is required");
|
|
||||||
};
|
|
||||||
let Some(user) = current_user(&session).await else {
|
|
||||||
return unauthorized();
|
|
||||||
};
|
|
||||||
|
|
||||||
let existing = match platform.game_store.find_by_code(code).await {
|
|
||||||
Ok(Some(existing)) => existing,
|
|
||||||
Ok(None) => {
|
|
||||||
tracing::debug!("join rejected for {}: unknown code {code:?}", user.sub);
|
|
||||||
return send_error(StatusCode::NOT_FOUND, "unknown join code");
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
tracing::error!("join lookup failed: {err}");
|
|
||||||
return send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not join game");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let game_id = existing.id.clone();
|
|
||||||
let lock = match platform.game_store.acquire_lock(&game_id).await {
|
|
||||||
Ok(lock) => lock,
|
|
||||||
Err(err) => {
|
|
||||||
tracing::error!("join lock failed: {err}");
|
|
||||||
return send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not join game");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let result = async {
|
|
||||||
let Some(mut game) = (match platform.game_store.load(&game_id).await {
|
|
||||||
Ok(game) => game,
|
|
||||||
Err(err) => {
|
|
||||||
tracing::error!("join load failed: {err}");
|
|
||||||
return Err(send_error(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"could not join game",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}) else {
|
|
||||||
return Err(send_error(StatusCode::NOT_FOUND, "unknown join code"));
|
|
||||||
};
|
|
||||||
let engine = match platform.registry.require(&game.game_type) {
|
|
||||||
Ok(engine) => engine.clone(),
|
|
||||||
Err(err) => {
|
|
||||||
tracing::error!("join engine lookup failed: {err}");
|
|
||||||
return Err(send_error(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"could not join game",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if let Err(err) = engine.join(&mut game, &user.sub, &display_name(&user)) {
|
|
||||||
match err.as_game() {
|
|
||||||
Some(game_error) => {
|
|
||||||
tracing::debug!(
|
|
||||||
"join rejected for {} in game {game_id}: {}",
|
|
||||||
user.sub,
|
|
||||||
game_error.message
|
|
||||||
);
|
|
||||||
return Err(send_error(StatusCode::CONFLICT, game_error.message.clone()));
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
tracing::error!("join failed: {err}");
|
|
||||||
return Err(send_error(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"could not join game",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Err(err) = platform.game_store.save(&game).await {
|
|
||||||
tracing::error!("join save failed: {err}");
|
|
||||||
return Err(send_error(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"could not join game",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let _ = platform.game_store.publish(&game.id).await;
|
|
||||||
if let Err(err) = platform.scheduler.sync_deadline(&game).await {
|
|
||||||
tracing::error!("join sync_deadline failed: {err}");
|
|
||||||
}
|
|
||||||
let seat = game.seat_index_of(&user.sub).unwrap_or(0);
|
|
||||||
if engine.in_lobby(&game) {
|
|
||||||
tracing::info!(
|
|
||||||
"{} joined game {} (seat {}, {}/{} players)",
|
|
||||||
user.sub,
|
|
||||||
game.id,
|
|
||||||
seat,
|
|
||||||
game.players.len(),
|
|
||||||
engine.max_players()
|
|
||||||
);
|
|
||||||
return lobby_payload(&platform, &game);
|
|
||||||
}
|
|
||||||
tracing::info!(
|
|
||||||
"{} joined game {} (seat {}); match started",
|
|
||||||
user.sub,
|
|
||||||
game.id,
|
|
||||||
seat
|
|
||||||
);
|
|
||||||
view_payload(&platform, &game, &user.sub)
|
|
||||||
}
|
|
||||||
.await;
|
|
||||||
let _ = lock.release().await;
|
|
||||||
match result {
|
|
||||||
Ok(payload) => send_json(StatusCode::OK, &payload),
|
|
||||||
Err(response) => response,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pre-join summary of the lobby behind a code.
|
|
||||||
async fn preview_game(
|
|
||||||
State(platform): State<SharedPlatform>,
|
|
||||||
RawQuery(query): RawQuery,
|
|
||||||
) -> Response {
|
|
||||||
let params = extract_query_params(query.as_deref());
|
|
||||||
let code = params
|
|
||||||
.get("code")
|
|
||||||
.and_then(|values| values.first())
|
|
||||||
.map(|code| code.trim().to_string())
|
|
||||||
.unwrap_or_default();
|
|
||||||
if code.is_empty() {
|
|
||||||
return send_error(StatusCode::BAD_REQUEST, "code is required");
|
|
||||||
}
|
|
||||||
match platform.game_store.find_by_code(&code).await {
|
|
||||||
Ok(Some(game)) => match preview_payload(&platform, &game) {
|
|
||||||
Ok(payload) => send_json(StatusCode::OK, &payload),
|
|
||||||
Err(response) => response,
|
|
||||||
},
|
|
||||||
Ok(None) => send_error(StatusCode::NOT_FOUND, "unknown join code"),
|
|
||||||
Err(err) => {
|
|
||||||
tracing::error!("preview lookup failed: {err}");
|
|
||||||
send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not preview game")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Personalized snapshot; only seated players may read a game.
|
|
||||||
async fn get_game(
|
|
||||||
State(platform): State<SharedPlatform>,
|
|
||||||
Extension(session): Extension<SessionHandle>,
|
|
||||||
Path(game_id): Path<String>,
|
|
||||||
) -> Response {
|
|
||||||
let game = match platform.game_store.load(&game_id).await {
|
|
||||||
Ok(Some(game)) => game,
|
|
||||||
Ok(None) => return send_error(StatusCode::NOT_FOUND, "game not found"),
|
|
||||||
Err(err) => {
|
|
||||||
tracing::error!("game load failed: {err}");
|
|
||||||
return send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not load game");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let Some(user) = current_user(&session).await else {
|
|
||||||
return unauthorized();
|
|
||||||
};
|
|
||||||
if !game.seated(&user.sub) {
|
|
||||||
return send_error(StatusCode::FORBIDDEN, "forbidden");
|
|
||||||
}
|
|
||||||
match view_payload(&platform, &game, &user.sub) {
|
|
||||||
Ok(payload) => send_json(StatusCode::OK, &payload),
|
|
||||||
Err(response) => response,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
//! Liveness probe.
|
|
||||||
|
|
||||||
use axum::extract::State;
|
|
||||||
use axum::http::StatusCode;
|
|
||||||
use axum::response::Response;
|
|
||||||
use axum::routing::get;
|
|
||||||
use axum::Router;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
use crate::http::send_json;
|
|
||||||
use crate::platform::SharedPlatform;
|
|
||||||
|
|
||||||
pub fn routes() -> Router<SharedPlatform> {
|
|
||||||
Router::new().route("/api/health", get(health))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Health check: the service is up.
|
|
||||||
async fn health(State(_platform): State<SharedPlatform>) -> Response {
|
|
||||||
send_json(StatusCode::OK, &json!({ "status": "ok" }))
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
//! Whoami endpoint: lets the single-page app detect the login state.
|
|
||||||
|
|
||||||
use axum::extract::State;
|
|
||||||
use axum::http::StatusCode;
|
|
||||||
use axum::response::Response;
|
|
||||||
use axum::routing::get;
|
|
||||||
use axum::Extension;
|
|
||||||
use axum::Router;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
use crate::auth::{current_user, display_name};
|
|
||||||
use crate::http::{send_json, unauthorized};
|
|
||||||
use crate::platform::SharedPlatform;
|
|
||||||
use crate::session::SessionHandle;
|
|
||||||
|
|
||||||
pub fn routes() -> Router<SharedPlatform> {
|
|
||||||
Router::new().route("/api/me", get(me))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Current user; 401 when not logged in.
|
|
||||||
async fn me(
|
|
||||||
State(_platform): State<SharedPlatform>,
|
|
||||||
Extension(session): Extension<SessionHandle>,
|
|
||||||
) -> Response {
|
|
||||||
let Some(user) = current_user(&session).await else {
|
|
||||||
return unauthorized();
|
|
||||||
};
|
|
||||||
send_json(
|
|
||||||
StatusCode::OK,
|
|
||||||
&json!({ "sub": user.sub, "name": display_name(&user) }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
//! The platform's HTTP surface: lobby, stats, auth and health routes.
|
|
||||||
//!
|
|
||||||
//! Handlers live in submodules mirroring the Python implementation; this
|
|
||||||
//! module assembles them into one axum [`Router`].
|
|
||||||
|
|
||||||
pub mod auth;
|
|
||||||
pub mod games;
|
|
||||||
pub mod health;
|
|
||||||
pub mod me;
|
|
||||||
pub mod openapi;
|
|
||||||
pub mod stats;
|
|
||||||
|
|
||||||
use axum::routing::get;
|
|
||||||
use axum::Router;
|
|
||||||
|
|
||||||
use crate::platform::SharedPlatform;
|
|
||||||
use crate::ws;
|
|
||||||
|
|
||||||
/// All platform routes (HTTP + the live-play websocket).
|
|
||||||
///
|
|
||||||
/// `providers` lists the configured OIDC provider names; login/callback/
|
|
||||||
/// logout routes are registered per provider so unknown provider paths fall
|
|
||||||
/// through to the router's fallback.
|
|
||||||
pub fn router(providers: &[String]) -> Router<SharedPlatform> {
|
|
||||||
Router::new()
|
|
||||||
.merge(health::routes())
|
|
||||||
.merge(me::routes())
|
|
||||||
.merge(auth::routes(providers))
|
|
||||||
.merge(games::routes())
|
|
||||||
.merge(stats::routes())
|
|
||||||
.merge(openapi::routes())
|
|
||||||
.route("/ws/games/{game_id}", get(ws::game_socket))
|
|
||||||
}
|
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
//! OpenAPI document and Swagger UI, mirroring kaya-openapi's output for the
|
|
||||||
//! tavolo API.
|
|
||||||
//!
|
|
||||||
//! The document is assembled from the route metadata below (kaya generated it
|
|
||||||
//! from the routing tree on every request). Wildcard SPA routes and the
|
|
||||||
//! websocket endpoint are skipped, exactly like kaya does.
|
|
||||||
|
|
||||||
use axum::extract::State;
|
|
||||||
use axum::http::StatusCode;
|
|
||||||
use axum::response::Response;
|
|
||||||
use axum::routing::get;
|
|
||||||
use axum::Router;
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
|
|
||||||
use crate::http::{send_json, send_text};
|
|
||||||
use crate::platform::SharedPlatform;
|
|
||||||
|
|
||||||
pub const TITLE: &str = "tavolo";
|
|
||||||
pub const VERSION: &str = "0.1.0";
|
|
||||||
pub const DESCRIPTION: &str = "Multiplayer card-game platform API";
|
|
||||||
pub const SPEC_PATH: &str = "/api/openapi.json";
|
|
||||||
pub const DOCS_PATH: &str = "/api/docs";
|
|
||||||
|
|
||||||
fn query_parameter(name: &str, description: &str, schema: Value) -> Value {
|
|
||||||
json!({
|
|
||||||
"name": name,
|
|
||||||
"in": "query",
|
|
||||||
"required": false,
|
|
||||||
"schema": schema,
|
|
||||||
"description": description,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn game_type_parameter() -> Value {
|
|
||||||
query_parameter(
|
|
||||||
"game_type",
|
|
||||||
"Only count matches of this game (id from GET /api/game-types).",
|
|
||||||
json!({"type": "string"}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn game_id_parameter() -> Value {
|
|
||||||
json!({
|
|
||||||
"name": "game_id",
|
|
||||||
"in": "path",
|
|
||||||
"required": true,
|
|
||||||
"schema": {"type": "string"},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The OpenAPI 3.1 document for the platform API.
|
|
||||||
pub fn spec() -> Value {
|
|
||||||
json!({
|
|
||||||
"openapi": "3.1.0",
|
|
||||||
"info": {
|
|
||||||
"title": TITLE,
|
|
||||||
"version": VERSION,
|
|
||||||
"description": DESCRIPTION,
|
|
||||||
},
|
|
||||||
"paths": {
|
|
||||||
"/api/health": {
|
|
||||||
"get": {
|
|
||||||
"summary": "Health check",
|
|
||||||
"tags": ["health"],
|
|
||||||
"responses": {"200": {"description": "The service is up"}},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/me": {
|
|
||||||
"get": {
|
|
||||||
"summary": "Current user",
|
|
||||||
"description": "Returns the authenticated user's identity from the session; 401 when not logged in.",
|
|
||||||
"tags": ["auth"],
|
|
||||||
"responses": {
|
|
||||||
"200": {"description": "The current user"},
|
|
||||||
"401": {"description": "Not logged in"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/auth/providers": {
|
|
||||||
"get": {
|
|
||||||
"summary": "Configured login providers",
|
|
||||||
"description": "Names and login URLs of the configured OIDC providers, in configuration order.",
|
|
||||||
"tags": ["auth"],
|
|
||||||
"responses": {"200": {"description": "The configured providers"}},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/auth/{provider}/login": {
|
|
||||||
"get": {
|
|
||||||
"summary": "Start an OIDC login",
|
|
||||||
"tags": ["auth"],
|
|
||||||
"parameters": [{
|
|
||||||
"name": "provider",
|
|
||||||
"in": "path",
|
|
||||||
"required": true,
|
|
||||||
"schema": {"type": "string"},
|
|
||||||
}],
|
|
||||||
"responses": {"302": {"description": "Redirect to the provider"}},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/auth/{provider}/callback": {
|
|
||||||
"get": {
|
|
||||||
"summary": "OIDC login callback",
|
|
||||||
"tags": ["auth"],
|
|
||||||
"parameters": [{
|
|
||||||
"name": "provider",
|
|
||||||
"in": "path",
|
|
||||||
"required": true,
|
|
||||||
"schema": {"type": "string"},
|
|
||||||
}],
|
|
||||||
"responses": {
|
|
||||||
"302": {"description": "Redirect to the post-login target"},
|
|
||||||
"400": {"description": "Authentication failed"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/auth/{provider}/logout": {
|
|
||||||
"get": {
|
|
||||||
"summary": "Log out of an OIDC provider",
|
|
||||||
"tags": ["auth"],
|
|
||||||
"parameters": [{
|
|
||||||
"name": "provider",
|
|
||||||
"in": "path",
|
|
||||||
"required": true,
|
|
||||||
"schema": {"type": "string"},
|
|
||||||
}],
|
|
||||||
"responses": {"302": {"description": "Redirect to the provider or the SPA root"}},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/game-types": {
|
|
||||||
"get": {
|
|
||||||
"summary": "List available games",
|
|
||||||
"description": "Every game the platform can host, for the match-creation dropdown. ``options_schema`` describes the per-game creation options accepted by POST /api/games.",
|
|
||||||
"tags": ["games"],
|
|
||||||
"responses": {"200": {"description": "The available game types"}},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/games": {
|
|
||||||
"post": {
|
|
||||||
"summary": "Create a game",
|
|
||||||
"description": "Creates a lobby game and seats the caller in seat 0. Share the returned join_code with the other players. ``options`` is an opaque object validated by the chosen game (see GET /api/game-types).",
|
|
||||||
"tags": ["games"],
|
|
||||||
"requestBody": {
|
|
||||||
"required": false,
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"game_type": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "One of the ids from GET /api/game-types; defaults to the platform's first registered game",
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "Game-specific creation options",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"201": {"description": "The created lobby"},
|
|
||||||
"400": {"description": "Invalid game_type, options or body"},
|
|
||||||
"401": {"description": "Authentication required"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/games/join": {
|
|
||||||
"post": {
|
|
||||||
"summary": "Join a game by code",
|
|
||||||
"description": "Seats the caller in the next free chair. Joining as the last player starts the match.",
|
|
||||||
"tags": ["games"],
|
|
||||||
"requestBody": {
|
|
||||||
"required": true,
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {"code": {"type": "string"}},
|
|
||||||
"required": ["code"],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"200": {"description": "Seated; game state (may be playing)"},
|
|
||||||
"400": {"description": "Missing code"},
|
|
||||||
"401": {"description": "Authentication required"},
|
|
||||||
"404": {"description": "Unknown join code"},
|
|
||||||
"409": {"description": "Already joined or lobby full"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/games/preview": {
|
|
||||||
"get": {
|
|
||||||
"summary": "Preview a game by join code",
|
|
||||||
"description": "Looks up a game by its join code without joining, for the lobby's pre-join summary: game name and description, seated players, open seats and the game-specific options echoed by its engine.",
|
|
||||||
"tags": ["games"],
|
|
||||||
"responses": {
|
|
||||||
"200": {"description": "The lobby preview"},
|
|
||||||
"400": {"description": "Missing code"},
|
|
||||||
"401": {"description": "Authentication required"},
|
|
||||||
"404": {"description": "Unknown join code"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/games/{game_id}": {
|
|
||||||
"get": {
|
|
||||||
"summary": "Get a game snapshot",
|
|
||||||
"description": "Only seated players may read a game; the view is personalized by the engine (e.g. hidden hands).",
|
|
||||||
"tags": ["games"],
|
|
||||||
"parameters": [game_id_parameter()],
|
|
||||||
"responses": {
|
|
||||||
"200": {"description": "The personalized game state"},
|
|
||||||
"401": {"description": "Authentication required"},
|
|
||||||
"403": {"description": "Not a player in this game"},
|
|
||||||
"404": {"description": "Game not found"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/me/matches": {
|
|
||||||
"get": {
|
|
||||||
"summary": "List my matches",
|
|
||||||
"description": "Cursor-paginated history of finished matches the caller played, newest first, with the result.",
|
|
||||||
"tags": ["stats"],
|
|
||||||
"parameters": [
|
|
||||||
query_parameter(
|
|
||||||
"limit",
|
|
||||||
"Maximum number of results per page (clamped to [1, 100]).",
|
|
||||||
json!({"type": "integer", "minimum": 1, "maximum": 100, "default": 20}),
|
|
||||||
),
|
|
||||||
query_parameter(
|
|
||||||
"cursor",
|
|
||||||
"Opaque pagination cursor from a previous response's next_cursor.",
|
|
||||||
json!({"type": "string"}),
|
|
||||||
),
|
|
||||||
game_type_parameter(),
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {"description": "A page of matches"},
|
|
||||||
"400": {"description": "Invalid pagination cursor or game_type"},
|
|
||||||
"401": {"description": "Authentication required"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/leaderboard": {
|
|
||||||
"get": {
|
|
||||||
"summary": "Global leaderboard",
|
|
||||||
"description": "Elo rating, aggregated wins, matches played and points for every player with at least one finished match. Sorted by Elo rating (the rating for the requested game_type, or the default game when the filter is absent).",
|
|
||||||
"tags": ["stats"],
|
|
||||||
"parameters": [game_type_parameter()],
|
|
||||||
"responses": {
|
|
||||||
"200": {"description": "The leaderboard"},
|
|
||||||
"400": {"description": "Unknown game_type"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/me/ratings": {
|
|
||||||
"get": {
|
|
||||||
"summary": "My Elo ratings",
|
|
||||||
"description": "The caller's current Elo rating for every game type they have played.",
|
|
||||||
"tags": ["stats"],
|
|
||||||
"responses": {
|
|
||||||
"200": {"description": "The caller's ratings"},
|
|
||||||
"401": {"description": "Authentication required"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The Swagger UI page (assets loaded from a CDN, like kaya-openapi).
|
|
||||||
pub fn docs_page() -> String {
|
|
||||||
format!(
|
|
||||||
r#"<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>{TITLE} - API documentation</title>
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="swagger-ui"></div>
|
|
||||||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js" crossorigin></script>
|
|
||||||
<script>
|
|
||||||
window.onload = () => {{
|
|
||||||
window.ui = SwaggerUIBundle({{
|
|
||||||
url: '{SPEC_PATH}',
|
|
||||||
dom_id: '#swagger-ui',
|
|
||||||
}});
|
|
||||||
}};
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"#
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn routes() -> Router<SharedPlatform> {
|
|
||||||
Router::new()
|
|
||||||
.route(SPEC_PATH, get(spec_handler))
|
|
||||||
.route(DOCS_PATH, get(docs_handler))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn spec_handler(State(_platform): State<SharedPlatform>) -> Response {
|
|
||||||
send_json(StatusCode::OK, &spec())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn docs_handler(State(_platform): State<SharedPlatform>) -> Response {
|
|
||||||
send_text(StatusCode::OK, docs_page(), "text/html; charset=utf-8")
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
//! Player statistics endpoints, served from the stats repository.
|
|
||||||
//!
|
|
||||||
//! Every finished match is persisted by
|
|
||||||
//! [`StatsRepo::save_match`](crate::stats::StatsRepo::save_match). These
|
|
||||||
//! endpoints expose a player's own match history and a global leaderboard
|
|
||||||
//! aggregated from the same tables. The game-specific outcome of each match
|
|
||||||
//! is exposed verbatim through the `result` JSON column.
|
|
||||||
|
|
||||||
use axum::extract::{RawQuery, State};
|
|
||||||
use axum::http::StatusCode;
|
|
||||||
use axum::response::Response;
|
|
||||||
use axum::routing::get;
|
|
||||||
use axum::Extension;
|
|
||||||
use axum::Router;
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
|
|
||||||
use crate::auth::current_user;
|
|
||||||
use crate::http::{extract_query_params, py_repr, send_error, send_json, unauthorized};
|
|
||||||
use crate::pagination::{parse_cursor_params, CursorDecodeError};
|
|
||||||
use crate::platform::{Platform, SharedPlatform};
|
|
||||||
use crate::session::SessionHandle;
|
|
||||||
|
|
||||||
pub fn routes() -> Router<SharedPlatform> {
|
|
||||||
Router::new()
|
|
||||||
.route("/api/me/matches", get(my_matches))
|
|
||||||
.route("/api/me/ratings", get(my_ratings))
|
|
||||||
.route("/api/leaderboard", get(leaderboard))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_game_type(
|
|
||||||
platform: &Platform,
|
|
||||||
params: &std::collections::HashMap<String, Vec<String>>,
|
|
||||||
) -> Result<Option<String>, Response> {
|
|
||||||
let Some(game_type) = params.get("game_type").and_then(|values| values.first()) else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
if platform.registry.get(game_type).is_none() {
|
|
||||||
return Err(send_error(
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
format!(
|
|
||||||
"unknown game_type: {}",
|
|
||||||
py_repr(&Value::String(game_type.clone()))
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(Some(game_type.clone()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cursor_error(error: CursorDecodeError) -> Response {
|
|
||||||
send_error(StatusCode::BAD_REQUEST, error.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cursor-paginated history of finished matches the caller played.
|
|
||||||
async fn my_matches(
|
|
||||||
State(platform): State<SharedPlatform>,
|
|
||||||
Extension(session): Extension<SessionHandle>,
|
|
||||||
RawQuery(query): RawQuery,
|
|
||||||
) -> Response {
|
|
||||||
let params = extract_query_params(query.as_deref());
|
|
||||||
let cursor = match parse_cursor_params(¶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<Value> = entries
|
|
||||||
.iter()
|
|
||||||
.map(|entry| serde_json::to_value(entry).unwrap_or(Value::Null))
|
|
||||||
.collect();
|
|
||||||
send_json(
|
|
||||||
StatusCode::OK,
|
|
||||||
&json!({ "results": results, "next_cursor": next_cursor }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
tracing::error!("match history query failed: {err}");
|
|
||||||
send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not load matches")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Global leaderboard, sorted by Elo (the rating for the requested
|
|
||||||
/// game_type, or the default game when the filter is absent).
|
|
||||||
async fn leaderboard(
|
|
||||||
State(platform): State<SharedPlatform>,
|
|
||||||
RawQuery(query): RawQuery,
|
|
||||||
) -> Response {
|
|
||||||
let params = extract_query_params(query.as_deref());
|
|
||||||
let game_type = match parse_game_type(&platform, ¶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<Value> = ranking
|
|
||||||
.iter()
|
|
||||||
.map(|entry| serde_json::to_value(entry).unwrap_or(Value::Null))
|
|
||||||
.collect();
|
|
||||||
send_json(StatusCode::OK, &json!({ "results": results }))
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
tracing::error!("leaderboard query failed: {err}");
|
|
||||||
send_error(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"could not load leaderboard",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The caller's current Elo rating for every game type they have played.
|
|
||||||
async fn my_ratings(
|
|
||||||
State(platform): State<SharedPlatform>,
|
|
||||||
Extension(session): Extension<SessionHandle>,
|
|
||||||
) -> Response {
|
|
||||||
let Some(user) = current_user(&session).await else {
|
|
||||||
return unauthorized();
|
|
||||||
};
|
|
||||||
match platform.stats.my_ratings(&user.sub).await {
|
|
||||||
Ok(rows) => {
|
|
||||||
let results: Vec<Value> = rows
|
|
||||||
.iter()
|
|
||||||
.map(|entry| serde_json::to_value(entry).unwrap_or(Value::Null))
|
|
||||||
.collect();
|
|
||||||
send_json(StatusCode::OK, &json!({ "results": results }))
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
tracing::error!("ratings query failed: {err}");
|
|
||||||
send_error(StatusCode::INTERNAL_SERVER_ERROR, "could not load ratings")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,531 +0,0 @@
|
|||||||
//! Server-side HTTP sessions.
|
|
||||||
//!
|
|
||||||
//! Mirrors kaya's `SessionMixin` / `SessionStore`:
|
|
||||||
//!
|
|
||||||
//! * [`SessionData`] is a dict-like container with modification tracking
|
|
||||||
//! (`modified` / `regenerate` / `invalidated` flags) used by the
|
|
||||||
//! middleware to decide whether to persist, rotate or delete it;
|
|
||||||
//! * [`SessionStore`] is the pluggable backend: Redis in production,
|
|
||||||
//! in-memory for tests and ephemeral single-process dev;
|
|
||||||
//! * [`session_middleware`] loads the session from the `session_id` cookie,
|
|
||||||
//! exposes it as a [`SessionHandle`] request extension, and persists it
|
|
||||||
//! (refreshing the cookie) after the handler ran.
|
|
||||||
//!
|
|
||||||
//! Session data is serialized as JSON (the Python server used pickle; old
|
|
||||||
//! sessions are deliberately not readable, users log in again).
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use axum::extract::{Request, State};
|
|
||||||
use axum::middleware::Next;
|
|
||||||
use axum::response::Response;
|
|
||||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
|
||||||
use base64::Engine;
|
|
||||||
use rand::RngCore;
|
|
||||||
use serde_json::{Map, Value};
|
|
||||||
use tokio::sync::{Mutex, MutexGuard};
|
|
||||||
|
|
||||||
use crate::errors::{Error, Result};
|
|
||||||
|
|
||||||
/// The session cookie name (kaya's default).
|
|
||||||
pub const COOKIE_NAME: &str = "session_id";
|
|
||||||
/// The session idle timeout (kaya's default: 14 days).
|
|
||||||
pub const MAX_AGE_SECONDS: i64 = 14 * 24 * 60 * 60;
|
|
||||||
/// Key prefix of the Redis session store. The prefix differs from kaya's
|
|
||||||
/// (`kaya:session:`) because the payload format changed from pickle to JSON.
|
|
||||||
pub const REDIS_KEY_PREFIX: &str = "tavolo:session:";
|
|
||||||
|
|
||||||
/// Opaque session identifier with 256 bits of entropy, matching Python's
|
|
||||||
/// `secrets.token_urlsafe(32)` (43 URL-safe base64 characters, no padding).
|
|
||||||
pub fn new_session_id() -> String {
|
|
||||||
let mut bytes = [0u8; 32];
|
|
||||||
rand::thread_rng().fill_bytes(&mut bytes);
|
|
||||||
URL_SAFE_NO_PAD.encode(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dict-like session container with modification tracking.
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct SessionData {
|
|
||||||
id: Option<String>,
|
|
||||||
old_id: Option<String>,
|
|
||||||
data: Map<String, Value>,
|
|
||||||
modified: bool,
|
|
||||||
regenerate: bool,
|
|
||||||
invalidated: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SessionData {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_id(id: impl Into<String>, data: Map<String, Value>) -> Self {
|
|
||||||
Self {
|
|
||||||
id: Some(id.into()),
|
|
||||||
old_id: None,
|
|
||||||
data,
|
|
||||||
modified: false,
|
|
||||||
regenerate: false,
|
|
||||||
invalidated: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn id(&self) -> Option<&str> {
|
|
||||||
self.id.as_deref()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_modified(&self) -> bool {
|
|
||||||
self.modified
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_regenerate(&self) -> bool {
|
|
||||||
self.regenerate
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_invalidated(&self) -> bool {
|
|
||||||
self.invalidated
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get(&self, key: &str) -> Option<&Value> {
|
|
||||||
self.data.get(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn insert(&mut self, key: impl Into<String>, value: Value) {
|
|
||||||
self.data.insert(key.into(), value);
|
|
||||||
self.modified = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove(&mut self, key: &str) -> Option<Value> {
|
|
||||||
let removed = self.data.remove(key);
|
|
||||||
if removed.is_some() {
|
|
||||||
self.modified = true;
|
|
||||||
}
|
|
||||||
removed
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn keys(&self) -> impl Iterator<Item = &String> {
|
|
||||||
self.data.keys()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn data(&self) -> &Map<String, Value> {
|
|
||||||
&self.data
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn mark_modified(&mut self) {
|
|
||||||
self.modified = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mark the session for ID rotation (session fixation defense); the
|
|
||||||
/// middleware mints a new id and deletes the old store entry.
|
|
||||||
pub fn regenerate_id(&mut self) {
|
|
||||||
self.old_id = self.id.take();
|
|
||||||
self.regenerate = true;
|
|
||||||
self.modified = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mark the session for deletion and send an expired cookie.
|
|
||||||
pub fn invalidate(&mut self) {
|
|
||||||
self.invalidated = true;
|
|
||||||
self.modified = true;
|
|
||||||
self.data.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shared, mutable session attached to a request.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct SessionHandle(pub Arc<Mutex<SessionData>>);
|
|
||||||
|
|
||||||
impl SessionHandle {
|
|
||||||
pub fn new(session: SessionData) -> Self {
|
|
||||||
Self(Arc::new(Mutex::new(session)))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn lock(&self) -> MutexGuard<'_, SessionData> {
|
|
||||||
self.0.lock().await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pluggable backend for session persistence.
|
|
||||||
#[async_trait]
|
|
||||||
pub trait SessionStore: Send + Sync {
|
|
||||||
/// Load an existing session, or `None` if unknown or expired. When
|
|
||||||
/// `max_age` is given the store slides the server-side expiry.
|
|
||||||
async fn load(&self, session_id: &str, max_age: Option<i64>) -> Result<Option<SessionData>>;
|
|
||||||
|
|
||||||
/// Persist the session data under `session_id`.
|
|
||||||
async fn save(
|
|
||||||
&self,
|
|
||||||
session_id: &str,
|
|
||||||
data: &Map<String, Value>,
|
|
||||||
max_age: Option<i64>,
|
|
||||||
) -> Result<()>;
|
|
||||||
|
|
||||||
/// Remove the session from the store.
|
|
||||||
async fn delete(&self, session_id: &str) -> Result<()>;
|
|
||||||
|
|
||||||
/// Number of live sessions (for the `tavolo.active_users` gauge).
|
|
||||||
async fn count(&self) -> Result<usize>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// In-memory store
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
struct MemorySession {
|
|
||||||
data: Map<String, Value>,
|
|
||||||
expires: Option<Instant>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Process-local session store used by tests and when Redis is not configured.
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct InMemorySessionStore {
|
|
||||||
sessions: Mutex<HashMap<String, MemorySession>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InMemorySessionStore {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clock(&self) -> Instant {
|
|
||||||
Instant::now()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl SessionStore for InMemorySessionStore {
|
|
||||||
async fn load(&self, session_id: &str, max_age: Option<i64>) -> Result<Option<SessionData>> {
|
|
||||||
let mut sessions = self.sessions.lock().await;
|
|
||||||
let Some(entry) = sessions.get_mut(session_id) else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let now = self.clock();
|
|
||||||
if let Some(expires) = entry.expires {
|
|
||||||
if now > expires {
|
|
||||||
sessions.remove(session_id);
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if max_age.is_some() {
|
|
||||||
entry.expires = Some(now + Duration::from_secs(max_age.unwrap_or(0) as u64));
|
|
||||||
}
|
|
||||||
Ok(Some(SessionData::with_id(
|
|
||||||
session_id.to_string(),
|
|
||||||
entry.data.clone(),
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save(
|
|
||||||
&self,
|
|
||||||
session_id: &str,
|
|
||||||
data: &Map<String, Value>,
|
|
||||||
max_age: Option<i64>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let mut sessions = self.sessions.lock().await;
|
|
||||||
sessions.insert(
|
|
||||||
session_id.to_string(),
|
|
||||||
MemorySession {
|
|
||||||
data: data.clone(),
|
|
||||||
expires: max_age.map(|age| self.clock() + Duration::from_secs(age as u64)),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete(&self, session_id: &str) -> Result<()> {
|
|
||||||
self.sessions.lock().await.remove(session_id);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn count(&self) -> Result<usize> {
|
|
||||||
let sessions = self.sessions.lock().await;
|
|
||||||
let now = self.clock();
|
|
||||||
Ok(sessions
|
|
||||||
.values()
|
|
||||||
.filter(|entry| entry.expires.is_none_or(|expires| expires > now))
|
|
||||||
.count())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Redis store
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Redis-backed session store: one JSON value per session under
|
|
||||||
/// `tavolo:session:<id>`, expiry delegated to the key TTL.
|
|
||||||
pub struct RedisSessionStore {
|
|
||||||
client: redis::Client,
|
|
||||||
manager: redis::aio::ConnectionManager,
|
|
||||||
prefix: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RedisSessionStore {
|
|
||||||
pub async fn new(url: &str) -> Result<Self> {
|
|
||||||
let client = redis::Client::open(url)?;
|
|
||||||
let manager = redis::aio::ConnectionManager::new(client.clone()).await?;
|
|
||||||
Ok(Self {
|
|
||||||
client,
|
|
||||||
manager,
|
|
||||||
prefix: REDIS_KEY_PREFIX.to_string(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
|
|
||||||
self.prefix = prefix.into();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn client(&self) -> redis::Client {
|
|
||||||
self.client.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn key(&self, session_id: &str) -> String {
|
|
||||||
format!("{}{}", self.prefix, session_id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl SessionStore for RedisSessionStore {
|
|
||||||
async fn load(&self, session_id: &str, max_age: Option<i64>) -> Result<Option<SessionData>> {
|
|
||||||
let mut conn = self.manager.clone();
|
|
||||||
let key = self.key(session_id);
|
|
||||||
let payload: Option<String> = redis::cmd("GET").arg(&key).query_async(&mut conn).await?;
|
|
||||||
let Some(payload) = payload else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
if let Some(max_age) = max_age {
|
|
||||||
redis::cmd("EXPIRE")
|
|
||||||
.arg(&key)
|
|
||||||
.arg(max_age)
|
|
||||||
.query_async::<i64>(&mut conn)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
let data: Map<String, Value> = serde_json::from_str(&payload)?;
|
|
||||||
Ok(Some(SessionData::with_id(session_id.to_string(), data)))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save(
|
|
||||||
&self,
|
|
||||||
session_id: &str,
|
|
||||||
data: &Map<String, Value>,
|
|
||||||
max_age: Option<i64>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let mut conn = self.manager.clone();
|
|
||||||
let payload = serde_json::to_string(data)?;
|
|
||||||
let mut cmd = redis::cmd("SET");
|
|
||||||
cmd.arg(self.key(session_id)).arg(payload);
|
|
||||||
if let Some(max_age) = max_age {
|
|
||||||
cmd.arg("EX").arg(max_age);
|
|
||||||
}
|
|
||||||
cmd.query_async::<()>(&mut conn).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete(&self, session_id: &str) -> Result<()> {
|
|
||||||
let mut conn = self.manager.clone();
|
|
||||||
redis::cmd("DEL")
|
|
||||||
.arg(self.key(session_id))
|
|
||||||
.query_async::<i64>(&mut conn)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn count(&self) -> Result<usize> {
|
|
||||||
let mut conn = self.manager.clone();
|
|
||||||
let mut cursor: u64 = 0;
|
|
||||||
let mut total = 0usize;
|
|
||||||
loop {
|
|
||||||
let (next, keys): (u64, Vec<String>) = redis::cmd("SCAN")
|
|
||||||
.arg(cursor)
|
|
||||||
.arg("MATCH")
|
|
||||||
.arg(format!("{}*", self.prefix))
|
|
||||||
.arg("COUNT")
|
|
||||||
.arg(500)
|
|
||||||
.query_async(&mut conn)
|
|
||||||
.await?;
|
|
||||||
total += keys.len();
|
|
||||||
cursor = next;
|
|
||||||
if cursor == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(total)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convenience alias used by the application.
|
|
||||||
pub type SharedSessionStore = Arc<dyn SessionStore>;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Cookie helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Return the value of `name` from a `Cookie` header, if present.
|
|
||||||
pub fn parse_cookie_value(header_value: &str, name: &str) -> Option<String> {
|
|
||||||
for part in header_value.split(';') {
|
|
||||||
let part = part.trim();
|
|
||||||
if let Some((key, value)) = part.split_once('=') {
|
|
||||||
if key.trim() == name {
|
|
||||||
return Some(value.trim().trim_matches('"').to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a `Set-Cookie` value the way Python's `SimpleCookie` renders one,
|
|
||||||
/// i.e. sorted attributes: `HttpOnly; Max-Age=n; Path=/; SameSite=Lax`.
|
|
||||||
pub fn format_set_cookie(
|
|
||||||
name: &str,
|
|
||||||
value: &str,
|
|
||||||
path: &str,
|
|
||||||
max_age: Option<i64>,
|
|
||||||
httponly: bool,
|
|
||||||
secure: bool,
|
|
||||||
samesite: Option<&str>,
|
|
||||||
) -> String {
|
|
||||||
let mut out = format!("{name}={value}");
|
|
||||||
if httponly {
|
|
||||||
out.push_str("; HttpOnly");
|
|
||||||
}
|
|
||||||
if let Some(max_age) = max_age {
|
|
||||||
out.push_str(&format!("; Max-Age={max_age}"));
|
|
||||||
}
|
|
||||||
out.push_str(&format!("; Path={path}"));
|
|
||||||
if let Some(samesite) = samesite {
|
|
||||||
out.push_str(&format!("; SameSite={samesite}"));
|
|
||||||
}
|
|
||||||
if secure {
|
|
||||||
out.push_str("; Secure");
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_session_id(headers: &axum::http::HeaderMap) -> Option<String> {
|
|
||||||
let mut found = None;
|
|
||||||
for value in headers.get_all(axum::http::header::COOKIE) {
|
|
||||||
if let Ok(value) = value.to_str() {
|
|
||||||
if let Some(id) = parse_cookie_value(value, COOKIE_NAME) {
|
|
||||||
found = Some(id);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
found
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load the session, expose it as a [`SessionHandle`] extension and persist
|
|
||||||
/// it (refreshing the cookie) after the handler ran.
|
|
||||||
///
|
|
||||||
/// Mirrors kaya's `SessionMixin`: an existing session is always re-saved and
|
|
||||||
/// its cookie re-sent, so the client and server expiry windows slide on
|
|
||||||
/// every request.
|
|
||||||
pub async fn session_middleware(
|
|
||||||
State(store): State<SharedSessionStore>,
|
|
||||||
mut request: Request,
|
|
||||||
next: Next,
|
|
||||||
) -> Response {
|
|
||||||
// Static assets never reach the application in the Python deployment
|
|
||||||
// (Granian serves them): skip the session round-trip for them.
|
|
||||||
let path = request.uri().path();
|
|
||||||
if path.starts_with("/static") || path.starts_with("/assets") {
|
|
||||||
return next.run(request).await;
|
|
||||||
}
|
|
||||||
let session_id = extract_session_id(request.headers());
|
|
||||||
let session = match session_id.as_deref() {
|
|
||||||
Some(id) => match store.load(id, Some(MAX_AGE_SECONDS)).await {
|
|
||||||
Ok(Some(session)) => session,
|
|
||||||
Ok(None) => SessionData::new(),
|
|
||||||
Err(err) => {
|
|
||||||
tracing::error!("session load failed: {err}");
|
|
||||||
SessionData::new()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
None => SessionData::new(),
|
|
||||||
};
|
|
||||||
let handle = SessionHandle::new(session);
|
|
||||||
request.extensions_mut().insert(handle.clone());
|
|
||||||
|
|
||||||
let mut response = next.run(request).await;
|
|
||||||
|
|
||||||
let mut session = handle.lock().await;
|
|
||||||
let mut cookie: Option<String> = None;
|
|
||||||
if session.invalidated {
|
|
||||||
let id = session.old_id.clone().or_else(|| session.id.clone());
|
|
||||||
if let Some(id) = &id {
|
|
||||||
if let Err(err) = store.delete(id).await {
|
|
||||||
tracing::error!("session delete failed: {err}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cookie = Some(format_set_cookie(
|
|
||||||
COOKIE_NAME,
|
|
||||||
id.as_deref().unwrap_or(""),
|
|
||||||
"/",
|
|
||||||
Some(0),
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
Some("Lax"),
|
|
||||||
));
|
|
||||||
} else if session.regenerate {
|
|
||||||
if let Some(old_id) = session.old_id.clone() {
|
|
||||||
if let Err(err) = store.delete(&old_id).await {
|
|
||||||
tracing::error!("session delete failed: {err}");
|
|
||||||
}
|
|
||||||
session.old_id = None;
|
|
||||||
}
|
|
||||||
if session.id.is_none() {
|
|
||||||
session.id = Some(new_session_id());
|
|
||||||
}
|
|
||||||
session.regenerate = false;
|
|
||||||
if let Some(id) = session.id.clone() {
|
|
||||||
if let Err(err) = store.save(&id, session.data(), Some(MAX_AGE_SECONDS)).await {
|
|
||||||
tracing::error!("session save failed: {err}");
|
|
||||||
}
|
|
||||||
cookie = Some(format_set_cookie(
|
|
||||||
COOKIE_NAME,
|
|
||||||
&id,
|
|
||||||
"/",
|
|
||||||
Some(MAX_AGE_SECONDS),
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
Some("Lax"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if session.id.is_none() && session.is_modified() {
|
|
||||||
session.id = Some(new_session_id());
|
|
||||||
}
|
|
||||||
if let Some(id) = session.id.clone() {
|
|
||||||
if let Err(err) = store.save(&id, session.data(), Some(MAX_AGE_SECONDS)).await {
|
|
||||||
tracing::error!("session save failed: {err}");
|
|
||||||
}
|
|
||||||
cookie = Some(format_set_cookie(
|
|
||||||
COOKIE_NAME,
|
|
||||||
&id,
|
|
||||||
"/",
|
|
||||||
Some(MAX_AGE_SECONDS),
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
Some("Lax"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
drop(session);
|
|
||||||
|
|
||||||
if let Some(cookie) = cookie {
|
|
||||||
if let Ok(value) = axum::http::HeaderValue::from_str(&cookie) {
|
|
||||||
response
|
|
||||||
.headers_mut()
|
|
||||||
.append(axum::http::header::SET_COOKIE, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
response
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Errors from the session layer surfaced as platform errors.
|
|
||||||
pub fn session_error(message: impl Into<String>) -> Error {
|
|
||||||
Error::Internal(message.into())
|
|
||||||
}
|
|
||||||
@@ -1,825 +0,0 @@
|
|||||||
//! Copy finished match results from the live store into Postgres, plus the
|
|
||||||
//! read side of the statistics endpoints.
|
|
||||||
//!
|
|
||||||
//! Called once when a session reaches the finished state (guarded by the
|
|
||||||
//! `stats_saved` flag on the session). The write is transactional so a match
|
|
||||||
//! never appears with only some of its players. The same transaction also
|
|
||||||
//! updates the participants' Elo ratings (see [`crate::elo`]).
|
|
||||||
//!
|
|
||||||
//! This module is game-independent: everything it persists comes from the
|
|
||||||
//! engine's [`crate::engine::MatchResult`]. The data access is behind
|
|
||||||
//! [`StatsRepo`] so tests can run without a database.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::Serialize;
|
|
||||||
use serde_json::{Map, Value};
|
|
||||||
use sqlx::{PgPool, Postgres, Transaction};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::datetime::serialize_iso;
|
|
||||||
use crate::elo::{match_delta, INITIAL_RATING};
|
|
||||||
use crate::engine::{GameEngine, GameSession};
|
|
||||||
use crate::errors::{Error, Result};
|
|
||||||
use crate::models::{MatchPlayerRow, MatchRow};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct MatchPlayerView {
|
|
||||||
pub user_sub: String,
|
|
||||||
pub display_name: String,
|
|
||||||
pub seat: i32,
|
|
||||||
pub team: Option<String>,
|
|
||||||
pub won: bool,
|
|
||||||
pub score: f64,
|
|
||||||
pub elo_delta: Option<i32>,
|
|
||||||
pub details: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct MatchHistoryEntry {
|
|
||||||
pub id: String,
|
|
||||||
pub game_type: String,
|
|
||||||
pub result: Value,
|
|
||||||
#[serde(serialize_with = "serialize_iso")]
|
|
||||||
pub started_at: DateTime<Utc>,
|
|
||||||
#[serde(serialize_with = "serialize_iso")]
|
|
||||||
pub finished_at: DateTime<Utc>,
|
|
||||||
pub you_won: bool,
|
|
||||||
pub your_elo_delta: Option<i32>,
|
|
||||||
pub players: Vec<MatchPlayerView>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct RatingEntry {
|
|
||||||
pub game_type: String,
|
|
||||||
pub rating: i32,
|
|
||||||
pub matches_played: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct LeaderboardEntry {
|
|
||||||
pub user_sub: String,
|
|
||||||
pub display_name: String,
|
|
||||||
pub matches: u64,
|
|
||||||
pub wins: u64,
|
|
||||||
pub points: f64,
|
|
||||||
pub elo: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persistence + queries for finished matches and ratings.
|
|
||||||
#[async_trait]
|
|
||||||
pub trait StatsRepo: Send + Sync {
|
|
||||||
/// Persist a finished session. Callers must check `stats_saved` and
|
|
||||||
/// `engine.is_finished` first and set `session.stats_saved` after success.
|
|
||||||
async fn save_match(&self, session: &GameSession, engine: &dyn GameEngine) -> Result<()>;
|
|
||||||
|
|
||||||
/// One page of finished matches the user played, newest first.
|
|
||||||
async fn my_matches(
|
|
||||||
&self,
|
|
||||||
user_sub: &str,
|
|
||||||
game_type: Option<&str>,
|
|
||||||
limit: usize,
|
|
||||||
after: Option<&Map<String, Value>>,
|
|
||||||
) -> Result<(Vec<MatchHistoryEntry>, Option<String>)>;
|
|
||||||
|
|
||||||
/// The caller's rating per game type, ordered by game type.
|
|
||||||
async fn my_ratings(&self, user_sub: &str) -> Result<Vec<RatingEntry>>;
|
|
||||||
|
|
||||||
/// Global leaderboard; `rating_game` selects the rating table to use when
|
|
||||||
/// no game type filter is given.
|
|
||||||
async fn leaderboard(
|
|
||||||
&self,
|
|
||||||
game_type: Option<&str>,
|
|
||||||
rating_game: &str,
|
|
||||||
) -> Result<Vec<LeaderboardEntry>>;
|
|
||||||
|
|
||||||
/// Recompute every rating from the match history; returns the number of
|
|
||||||
/// matches replayed.
|
|
||||||
async fn backfill_elo(&self) -> Result<usize>;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type SharedStatsRepo = std::sync::Arc<dyn StatsRepo>;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Postgres implementation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pub struct PostgresStatsRepo {
|
|
||||||
pool: PgPool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PostgresStatsRepo {
|
|
||||||
pub fn new(pool: PgPool) -> Self {
|
|
||||||
Self { pool }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pool(&self) -> &PgPool {
|
|
||||||
&self.pool
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update the Elo ratings of `teams` for `game_type` inside a transaction.
|
|
||||||
/// Returns the per-player delta.
|
|
||||||
pub async fn apply_elo(
|
|
||||||
tx: &mut Transaction<'_, Postgres>,
|
|
||||||
game_type: &str,
|
|
||||||
teams: &[Vec<String>],
|
|
||||||
winner_team: usize,
|
|
||||||
) -> Result<HashMap<String, i64>> {
|
|
||||||
if teams.len() != 2 {
|
|
||||||
return Err(Error::Internal(
|
|
||||||
"Elo rating requires exactly two teams".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let mut ratings: HashMap<String, i32> = HashMap::new();
|
|
||||||
for team in teams {
|
|
||||||
for sub in team {
|
|
||||||
sqlx::query(
|
|
||||||
r#"INSERT INTO player_rating (id, user_sub, game_type, rating, matches_played, updated_at)
|
|
||||||
VALUES ($1, $2, $3, $4, 0, now())
|
|
||||||
ON CONFLICT (user_sub, game_type) DO NOTHING"#,
|
|
||||||
)
|
|
||||||
.bind(Uuid::new_v4())
|
|
||||||
.bind(sub)
|
|
||||||
.bind(game_type)
|
|
||||||
.bind(INITIAL_RATING as i32)
|
|
||||||
.execute(&mut **tx)
|
|
||||||
.await?;
|
|
||||||
let rating: (i32,) = sqlx::query_as(
|
|
||||||
r#"SELECT rating FROM player_rating WHERE user_sub = $1 AND game_type = $2 FOR UPDATE"#,
|
|
||||||
)
|
|
||||||
.bind(sub)
|
|
||||||
.bind(game_type)
|
|
||||||
.fetch_one(&mut **tx)
|
|
||||||
.await?;
|
|
||||||
ratings.insert(sub.clone(), rating.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let team_a: Vec<f64> = teams[0].iter().map(|sub| ratings[sub] as f64).collect();
|
|
||||||
let team_b: Vec<f64> = teams[1].iter().map(|sub| ratings[sub] as f64).collect();
|
|
||||||
let delta_a = match_delta(&team_a, &team_b, winner_team);
|
|
||||||
let mut deltas = HashMap::new();
|
|
||||||
for sub in &teams[0] {
|
|
||||||
deltas.insert(sub.clone(), delta_a);
|
|
||||||
}
|
|
||||||
for sub in &teams[1] {
|
|
||||||
deltas.insert(sub.clone(), -delta_a);
|
|
||||||
}
|
|
||||||
for (sub, delta) in &deltas {
|
|
||||||
sqlx::query(
|
|
||||||
r#"UPDATE player_rating
|
|
||||||
SET rating = rating + $1, matches_played = matches_played + 1, updated_at = now()
|
|
||||||
WHERE user_sub = $2 AND game_type = $3"#,
|
|
||||||
)
|
|
||||||
.bind(*delta as i32)
|
|
||||||
.bind(sub)
|
|
||||||
.bind(game_type)
|
|
||||||
.execute(&mut **tx)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
Ok(deltas)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn row_to_entry(row: MatchRow, viewer: &str, players: &[MatchPlayerRow]) -> MatchHistoryEntry {
|
|
||||||
let viewer_won = players.iter().any(|p| p.user_sub == viewer && p.won);
|
|
||||||
let viewer_delta = players
|
|
||||||
.iter()
|
|
||||||
.find(|p| p.user_sub == viewer)
|
|
||||||
.and_then(|p| p.elo_delta)
|
|
||||||
.map(i32::from);
|
|
||||||
MatchHistoryEntry {
|
|
||||||
id: row.id.to_string(),
|
|
||||||
game_type: row.game_type,
|
|
||||||
result: row.result,
|
|
||||||
started_at: row.started_at,
|
|
||||||
finished_at: row.finished_at,
|
|
||||||
you_won: viewer_won,
|
|
||||||
your_elo_delta: viewer_delta,
|
|
||||||
players: players
|
|
||||||
.iter()
|
|
||||||
.map(|p| MatchPlayerView {
|
|
||||||
user_sub: p.user_sub.clone(),
|
|
||||||
display_name: p.display_name.clone(),
|
|
||||||
seat: p.seat as i32,
|
|
||||||
team: p.team.clone(),
|
|
||||||
won: p.won,
|
|
||||||
score: p.score,
|
|
||||||
elo_delta: p.elo_delta.map(i32::from),
|
|
||||||
details: p.details.clone(),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl StatsRepo for PostgresStatsRepo {
|
|
||||||
async fn save_match(&self, session: &GameSession, engine: &dyn GameEngine) -> Result<()> {
|
|
||||||
let result = engine.result(session)?;
|
|
||||||
let started_at = session.created_at.unwrap_or_else(Utc::now);
|
|
||||||
let finished_at = session.finished_at.unwrap_or_else(Utc::now);
|
|
||||||
let display_names: HashMap<&str, &str> = session
|
|
||||||
.players
|
|
||||||
.iter()
|
|
||||||
.map(|seat| (seat.user_sub.as_str(), seat.display_name.as_str()))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let mut tx = self.pool.begin().await?;
|
|
||||||
let match_id = Uuid::new_v4();
|
|
||||||
sqlx::query(
|
|
||||||
r#"INSERT INTO "match" (id, game_type, started_at, finished_at, result)
|
|
||||||
VALUES ($1, $2, $3, $4, $5)"#,
|
|
||||||
)
|
|
||||||
.bind(match_id)
|
|
||||||
.bind(&session.game_type)
|
|
||||||
.bind(started_at)
|
|
||||||
.bind(finished_at)
|
|
||||||
.bind(&result.summary)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
let deltas = apply_elo(
|
|
||||||
&mut tx,
|
|
||||||
&session.game_type,
|
|
||||||
&result.teams,
|
|
||||||
result.winner_team,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
for player in &result.players {
|
|
||||||
let display_name = display_names
|
|
||||||
.get(player.user_sub.as_str())
|
|
||||||
.copied()
|
|
||||||
.unwrap_or(player.user_sub.as_str());
|
|
||||||
let delta = deltas.get(&player.user_sub).copied().unwrap_or(0);
|
|
||||||
sqlx::query(
|
|
||||||
r#"INSERT INTO match_player
|
|
||||||
(id, match_id, user_sub, display_name, seat, team, won, score, elo_delta, details)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)"#,
|
|
||||||
)
|
|
||||||
.bind(Uuid::new_v4())
|
|
||||||
.bind(match_id)
|
|
||||||
.bind(&player.user_sub)
|
|
||||||
.bind(display_name)
|
|
||||||
.bind(player.seat as i16)
|
|
||||||
.bind(&player.team)
|
|
||||||
.bind(player.won)
|
|
||||||
.bind(player.score)
|
|
||||||
.bind(delta as i16)
|
|
||||||
.bind(&player.details)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
tx.commit().await?;
|
|
||||||
tracing::info!(
|
|
||||||
"match result persisted: game {} ({}), team {} of {} won",
|
|
||||||
session.id,
|
|
||||||
session.game_type,
|
|
||||||
result.winner_team,
|
|
||||||
result.teams.len()
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn my_matches(
|
|
||||||
&self,
|
|
||||||
user_sub: &str,
|
|
||||||
game_type: Option<&str>,
|
|
||||||
limit: usize,
|
|
||||||
after: Option<&Map<String, Value>>,
|
|
||||||
) -> Result<(Vec<MatchHistoryEntry>, Option<String>)> {
|
|
||||||
let cursor_finished = after
|
|
||||||
.and_then(|map| map.get("finished_at"))
|
|
||||||
.and_then(crate::datetime::from_json);
|
|
||||||
let cursor_id = after
|
|
||||||
.and_then(|map| map.get("id"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.and_then(|value| Uuid::parse_str(value).ok());
|
|
||||||
let rows: Vec<MatchRow> = sqlx::query_as(
|
|
||||||
r#"SELECT DISTINCT m.id, m.game_type, m.started_at, m.finished_at, m.result
|
|
||||||
FROM "match" m
|
|
||||||
JOIN match_player p ON p.match_id = m.id
|
|
||||||
WHERE p.user_sub = $1
|
|
||||||
AND ($2::text IS NULL OR m.game_type = $2)
|
|
||||||
AND ($3::timestamptz IS NULL
|
|
||||||
OR m.finished_at < $3
|
|
||||||
OR (m.finished_at = $3 AND m.id > $4))
|
|
||||||
ORDER BY m.finished_at DESC, m.id ASC
|
|
||||||
LIMIT $5"#,
|
|
||||||
)
|
|
||||||
.bind(user_sub)
|
|
||||||
.bind(game_type)
|
|
||||||
.bind(cursor_finished)
|
|
||||||
.bind(cursor_id)
|
|
||||||
.bind((limit + 1) as i64)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let has_more = rows.len() > limit;
|
|
||||||
let page: Vec<MatchRow> = rows.into_iter().take(limit).collect();
|
|
||||||
let next_cursor = if has_more {
|
|
||||||
page.last().map(|row| {
|
|
||||||
let mut values = Map::new();
|
|
||||||
values.insert(
|
|
||||||
"finished_at".into(),
|
|
||||||
Value::String(crate::datetime::to_iso(row.finished_at)),
|
|
||||||
);
|
|
||||||
values.insert("id".into(), Value::String(row.id.to_string()));
|
|
||||||
crate::pagination::encode_cursor(&values)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut players_by_match: HashMap<Uuid, Vec<MatchPlayerRow>> = HashMap::new();
|
|
||||||
if !page.is_empty() {
|
|
||||||
let ids: Vec<Uuid> = page.iter().map(|row| row.id).collect();
|
|
||||||
let players: Vec<MatchPlayerRow> = sqlx::query_as(
|
|
||||||
r#"SELECT id, match_id, user_sub, display_name, seat, team, won, score, elo_delta, details
|
|
||||||
FROM match_player WHERE match_id = ANY($1)
|
|
||||||
ORDER BY match_id, seat"#,
|
|
||||||
)
|
|
||||||
.bind(&ids)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await?;
|
|
||||||
for player in players {
|
|
||||||
players_by_match
|
|
||||||
.entry(player.match_id)
|
|
||||||
.or_default()
|
|
||||||
.push(player);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let entries = page
|
|
||||||
.into_iter()
|
|
||||||
.map(|row| {
|
|
||||||
let players = players_by_match.remove(&row.id).unwrap_or_default();
|
|
||||||
row_to_entry(row, user_sub, &players)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
Ok((entries, next_cursor))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn my_ratings(&self, user_sub: &str) -> Result<Vec<RatingEntry>> {
|
|
||||||
let rows: Vec<(String, i32, i32)> = sqlx::query_as(
|
|
||||||
r#"SELECT game_type, rating, matches_played FROM player_rating
|
|
||||||
WHERE user_sub = $1 ORDER BY game_type"#,
|
|
||||||
)
|
|
||||||
.bind(user_sub)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await?;
|
|
||||||
Ok(rows
|
|
||||||
.into_iter()
|
|
||||||
.map(|(game_type, rating, matches_played)| RatingEntry {
|
|
||||||
game_type,
|
|
||||||
rating,
|
|
||||||
matches_played,
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn leaderboard(
|
|
||||||
&self,
|
|
||||||
game_type: Option<&str>,
|
|
||||||
rating_game: &str,
|
|
||||||
) -> Result<Vec<LeaderboardEntry>> {
|
|
||||||
let rows: Vec<(String, String, bool, f64)> = sqlx::query_as(
|
|
||||||
r#"SELECT mp.user_sub, mp.display_name, mp.won, mp.score
|
|
||||||
FROM match_player mp
|
|
||||||
JOIN "match" m ON m.id = mp.match_id
|
|
||||||
WHERE ($1::text IS NULL OR m.game_type = $1)"#,
|
|
||||||
)
|
|
||||||
.bind(game_type)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await?;
|
|
||||||
let ratings: Vec<(String, i32)> =
|
|
||||||
sqlx::query_as(r#"SELECT user_sub, rating FROM player_rating WHERE game_type = $1"#)
|
|
||||||
.bind(rating_game)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await?;
|
|
||||||
let ratings: HashMap<String, i32> = ratings.into_iter().collect();
|
|
||||||
|
|
||||||
let mut order: Vec<String> = Vec::new();
|
|
||||||
let mut aggregate: HashMap<String, LeaderboardEntry> = HashMap::new();
|
|
||||||
for (user_sub, display_name, won, score) in rows {
|
|
||||||
let entry = aggregate.entry(user_sub.clone()).or_insert_with(|| {
|
|
||||||
order.push(user_sub.clone());
|
|
||||||
LeaderboardEntry {
|
|
||||||
user_sub: user_sub.clone(),
|
|
||||||
display_name: display_name.clone(),
|
|
||||||
matches: 0,
|
|
||||||
wins: 0,
|
|
||||||
points: 0.0,
|
|
||||||
elo: ratings
|
|
||||||
.get(&user_sub)
|
|
||||||
.copied()
|
|
||||||
.unwrap_or(INITIAL_RATING as i32),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
entry.matches += 1;
|
|
||||||
entry.wins += u64::from(won);
|
|
||||||
entry.points += score;
|
|
||||||
// Keep the most recent display name seen.
|
|
||||||
entry.display_name = display_name;
|
|
||||||
}
|
|
||||||
let mut ranking: Vec<LeaderboardEntry> = order
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|sub| aggregate.remove(&sub))
|
|
||||||
.collect();
|
|
||||||
ranking.sort_by(|a, b| {
|
|
||||||
b.elo
|
|
||||||
.cmp(&a.elo)
|
|
||||||
.then(b.wins.cmp(&a.wins))
|
|
||||||
.then(
|
|
||||||
b.points
|
|
||||||
.partial_cmp(&a.points)
|
|
||||||
.unwrap_or(std::cmp::Ordering::Equal),
|
|
||||||
)
|
|
||||||
.then(a.matches.cmp(&b.matches))
|
|
||||||
});
|
|
||||||
Ok(ranking)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn backfill_elo(&self) -> Result<usize> {
|
|
||||||
let mut tx = self.pool.begin().await?;
|
|
||||||
sqlx::query("DELETE FROM player_rating")
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
let matches: Vec<MatchRow> = sqlx::query_as(
|
|
||||||
r#"SELECT id, game_type, started_at, finished_at, result FROM "match"
|
|
||||||
ORDER BY finished_at, id"#,
|
|
||||||
)
|
|
||||||
.fetch_all(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
let mut replayed = 0usize;
|
|
||||||
for row in matches {
|
|
||||||
let players: Vec<MatchPlayerRow> = sqlx::query_as(
|
|
||||||
r#"SELECT id, match_id, user_sub, display_name, seat, team, won, score, elo_delta, details
|
|
||||||
FROM match_player WHERE match_id = $1"#,
|
|
||||||
)
|
|
||||||
.bind(row.id)
|
|
||||||
.fetch_all(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
let mut by_team: std::collections::BTreeMap<String, Vec<String>> = Default::default();
|
|
||||||
for player in &players {
|
|
||||||
if let Some(team) = &player.team {
|
|
||||||
by_team
|
|
||||||
.entry(team.clone())
|
|
||||||
.or_default()
|
|
||||||
.push(player.user_sub.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let teams: Vec<Vec<String>> = by_team.into_values().collect();
|
|
||||||
let winner_index = teams
|
|
||||||
.iter()
|
|
||||||
.position(|members| {
|
|
||||||
players
|
|
||||||
.iter()
|
|
||||||
.any(|p| p.won && members.contains(&p.user_sub))
|
|
||||||
})
|
|
||||||
.unwrap_or(0);
|
|
||||||
let deltas = apply_elo(&mut tx, &row.game_type, &teams, winner_index).await?;
|
|
||||||
for player in &players {
|
|
||||||
let delta = deltas.get(&player.user_sub).copied().unwrap_or(0);
|
|
||||||
sqlx::query("UPDATE match_player SET elo_delta = $1 WHERE id = $2")
|
|
||||||
.bind(delta as i16)
|
|
||||||
.bind(player.id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
replayed += 1;
|
|
||||||
}
|
|
||||||
tx.commit().await?;
|
|
||||||
tracing::info!("elo backfill complete: {replayed} matches replayed");
|
|
||||||
Ok(replayed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// In-memory implementation (tests and ephemeral dev)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct StoredMatch {
|
|
||||||
id: Uuid,
|
|
||||||
game_type: String,
|
|
||||||
started_at: DateTime<Utc>,
|
|
||||||
finished_at: DateTime<Utc>,
|
|
||||||
result: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct StoredPlayer {
|
|
||||||
match_id: Uuid,
|
|
||||||
user_sub: String,
|
|
||||||
display_name: String,
|
|
||||||
seat: i16,
|
|
||||||
team: Option<String>,
|
|
||||||
won: bool,
|
|
||||||
score: f64,
|
|
||||||
elo_delta: Option<i32>,
|
|
||||||
details: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct MemoryStats {
|
|
||||||
matches: Vec<StoredMatch>,
|
|
||||||
players: Vec<StoredPlayer>,
|
|
||||||
ratings: HashMap<(String, String), (i32, i32)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct InMemoryStatsRepo {
|
|
||||||
inner: tokio::sync::Mutex<MemoryStats>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InMemoryStatsRepo {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn memory_apply_elo(
|
|
||||||
inner: &mut MemoryStats,
|
|
||||||
game_type: &str,
|
|
||||||
teams: &[Vec<String>],
|
|
||||||
winner_team: usize,
|
|
||||||
) -> Result<HashMap<String, i64>> {
|
|
||||||
if teams.len() != 2 {
|
|
||||||
return Err(Error::Internal(
|
|
||||||
"Elo rating requires exactly two teams".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let mut ratings: HashMap<String, i32> = HashMap::new();
|
|
||||||
for team in teams {
|
|
||||||
for sub in team {
|
|
||||||
let entry = inner
|
|
||||||
.ratings
|
|
||||||
.entry((sub.clone(), game_type.to_string()))
|
|
||||||
.or_insert((INITIAL_RATING as i32, 0));
|
|
||||||
ratings.insert(sub.clone(), entry.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let team_a: Vec<f64> = teams[0].iter().map(|sub| ratings[sub] as f64).collect();
|
|
||||||
let team_b: Vec<f64> = teams[1].iter().map(|sub| ratings[sub] as f64).collect();
|
|
||||||
let delta_a = match_delta(&team_a, &team_b, winner_team);
|
|
||||||
let mut deltas = HashMap::new();
|
|
||||||
for sub in &teams[0] {
|
|
||||||
deltas.insert(sub.clone(), delta_a);
|
|
||||||
}
|
|
||||||
for sub in &teams[1] {
|
|
||||||
deltas.insert(sub.clone(), -delta_a);
|
|
||||||
}
|
|
||||||
for (sub, delta) in &deltas {
|
|
||||||
let entry = inner
|
|
||||||
.ratings
|
|
||||||
.get_mut(&(sub.clone(), game_type.to_string()))
|
|
||||||
.expect("rating entry exists");
|
|
||||||
entry.0 += *delta as i32;
|
|
||||||
entry.1 += 1;
|
|
||||||
}
|
|
||||||
Ok(deltas)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl StatsRepo for InMemoryStatsRepo {
|
|
||||||
async fn save_match(&self, session: &GameSession, engine: &dyn GameEngine) -> Result<()> {
|
|
||||||
let result = engine.result(session)?;
|
|
||||||
let started_at = session.created_at.unwrap_or_else(Utc::now);
|
|
||||||
let finished_at = session.finished_at.unwrap_or_else(Utc::now);
|
|
||||||
let mut inner = self.inner.lock().await;
|
|
||||||
let match_id = Uuid::new_v4();
|
|
||||||
let deltas = memory_apply_elo(
|
|
||||||
&mut inner,
|
|
||||||
&session.game_type,
|
|
||||||
&result.teams,
|
|
||||||
result.winner_team,
|
|
||||||
)?;
|
|
||||||
inner.matches.push(StoredMatch {
|
|
||||||
id: match_id,
|
|
||||||
game_type: session.game_type.clone(),
|
|
||||||
started_at,
|
|
||||||
finished_at,
|
|
||||||
result: result.summary.clone(),
|
|
||||||
});
|
|
||||||
for player in &result.players {
|
|
||||||
let display_name = session
|
|
||||||
.seat_of(&player.user_sub)
|
|
||||||
.map(|seat| seat.display_name.clone())
|
|
||||||
.unwrap_or_else(|| player.user_sub.clone());
|
|
||||||
inner.players.push(StoredPlayer {
|
|
||||||
match_id,
|
|
||||||
user_sub: player.user_sub.clone(),
|
|
||||||
display_name,
|
|
||||||
seat: player.seat as i16,
|
|
||||||
team: player.team.clone(),
|
|
||||||
won: player.won,
|
|
||||||
score: player.score,
|
|
||||||
elo_delta: deltas.get(&player.user_sub).map(|d| *d as i32),
|
|
||||||
details: player.details.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn my_matches(
|
|
||||||
&self,
|
|
||||||
user_sub: &str,
|
|
||||||
game_type: Option<&str>,
|
|
||||||
limit: usize,
|
|
||||||
after: Option<&Map<String, Value>>,
|
|
||||||
) -> Result<(Vec<MatchHistoryEntry>, Option<String>)> {
|
|
||||||
let inner = self.inner.lock().await;
|
|
||||||
let cursor_finished = after
|
|
||||||
.and_then(|map| map.get("finished_at"))
|
|
||||||
.and_then(crate::datetime::from_json);
|
|
||||||
let cursor_id = after
|
|
||||||
.and_then(|map| map.get("id"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.and_then(|value| Uuid::parse_str(value).ok());
|
|
||||||
|
|
||||||
let mut candidate_ids: Vec<Uuid> = inner
|
|
||||||
.players
|
|
||||||
.iter()
|
|
||||||
.filter(|p| p.user_sub == user_sub)
|
|
||||||
.map(|p| p.match_id)
|
|
||||||
.collect();
|
|
||||||
candidate_ids.sort();
|
|
||||||
candidate_ids.dedup();
|
|
||||||
|
|
||||||
let mut matches: Vec<&StoredMatch> = candidate_ids
|
|
||||||
.iter()
|
|
||||||
.filter_map(|id| inner.matches.iter().find(|m| m.id == *id))
|
|
||||||
.filter(|m| game_type.is_none_or(|game_type| m.game_type == game_type))
|
|
||||||
.filter(|m| match (cursor_finished, cursor_id) {
|
|
||||||
(Some(finished), Some(id)) => {
|
|
||||||
m.finished_at < finished || (m.finished_at == finished && m.id > id)
|
|
||||||
}
|
|
||||||
_ => true,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
matches.sort_by(|a, b| b.finished_at.cmp(&a.finished_at).then(a.id.cmp(&b.id)));
|
|
||||||
let has_more = matches.len() > limit;
|
|
||||||
matches.truncate(limit);
|
|
||||||
let next_cursor = if has_more {
|
|
||||||
matches.last().map(|row| {
|
|
||||||
let mut values = Map::new();
|
|
||||||
values.insert(
|
|
||||||
"finished_at".into(),
|
|
||||||
Value::String(crate::datetime::to_iso(row.finished_at)),
|
|
||||||
);
|
|
||||||
values.insert("id".into(), Value::String(row.id.to_string()));
|
|
||||||
crate::pagination::encode_cursor(&values)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
let entries = matches
|
|
||||||
.into_iter()
|
|
||||||
.map(|row| {
|
|
||||||
let players: Vec<MatchPlayerRow> = inner
|
|
||||||
.players
|
|
||||||
.iter()
|
|
||||||
.filter(|p| p.match_id == row.id)
|
|
||||||
.map(|p| MatchPlayerRow {
|
|
||||||
id: Uuid::nil(),
|
|
||||||
match_id: p.match_id,
|
|
||||||
user_sub: p.user_sub.clone(),
|
|
||||||
display_name: p.display_name.clone(),
|
|
||||||
seat: p.seat,
|
|
||||||
team: p.team.clone(),
|
|
||||||
won: p.won,
|
|
||||||
score: p.score,
|
|
||||||
elo_delta: p.elo_delta.map(|d| d as i16),
|
|
||||||
details: p.details.clone(),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
row_to_entry(
|
|
||||||
MatchRow {
|
|
||||||
id: row.id,
|
|
||||||
game_type: row.game_type.clone(),
|
|
||||||
started_at: row.started_at,
|
|
||||||
finished_at: row.finished_at,
|
|
||||||
result: row.result.clone(),
|
|
||||||
},
|
|
||||||
user_sub,
|
|
||||||
&players,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
Ok((entries, next_cursor))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn my_ratings(&self, user_sub: &str) -> Result<Vec<RatingEntry>> {
|
|
||||||
let inner = self.inner.lock().await;
|
|
||||||
let mut rows: Vec<RatingEntry> = inner
|
|
||||||
.ratings
|
|
||||||
.iter()
|
|
||||||
.filter(|((sub, _), _)| sub == user_sub)
|
|
||||||
.map(|((_, game_type), (rating, matches_played))| RatingEntry {
|
|
||||||
game_type: game_type.clone(),
|
|
||||||
rating: *rating,
|
|
||||||
matches_played: *matches_played,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
rows.sort_by(|a, b| a.game_type.cmp(&b.game_type));
|
|
||||||
Ok(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn leaderboard(
|
|
||||||
&self,
|
|
||||||
game_type: Option<&str>,
|
|
||||||
rating_game: &str,
|
|
||||||
) -> Result<Vec<LeaderboardEntry>> {
|
|
||||||
let inner = self.inner.lock().await;
|
|
||||||
let ratings: HashMap<&str, i32> = inner
|
|
||||||
.ratings
|
|
||||||
.iter()
|
|
||||||
.filter(|((_, gt), _)| gt == rating_game)
|
|
||||||
.map(|((sub, _), (rating, _))| (sub.as_str(), *rating))
|
|
||||||
.collect();
|
|
||||||
let mut order: Vec<String> = Vec::new();
|
|
||||||
let mut aggregate: HashMap<String, LeaderboardEntry> = HashMap::new();
|
|
||||||
for player in &inner.players {
|
|
||||||
let in_scope = match game_type {
|
|
||||||
Some(game_type) => inner
|
|
||||||
.matches
|
|
||||||
.iter()
|
|
||||||
.any(|m| m.id == player.match_id && m.game_type == game_type),
|
|
||||||
None => true,
|
|
||||||
};
|
|
||||||
if !in_scope {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let entry = aggregate.entry(player.user_sub.clone()).or_insert_with(|| {
|
|
||||||
order.push(player.user_sub.clone());
|
|
||||||
LeaderboardEntry {
|
|
||||||
user_sub: player.user_sub.clone(),
|
|
||||||
display_name: player.display_name.clone(),
|
|
||||||
matches: 0,
|
|
||||||
wins: 0,
|
|
||||||
points: 0.0,
|
|
||||||
elo: ratings
|
|
||||||
.get(player.user_sub.as_str())
|
|
||||||
.copied()
|
|
||||||
.unwrap_or(INITIAL_RATING as i32),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
entry.matches += 1;
|
|
||||||
entry.wins += u64::from(player.won);
|
|
||||||
entry.points += player.score;
|
|
||||||
entry.display_name = player.display_name.clone();
|
|
||||||
}
|
|
||||||
let mut ranking: Vec<LeaderboardEntry> = order
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|sub| aggregate.remove(&sub))
|
|
||||||
.collect();
|
|
||||||
ranking.sort_by(|a, b| {
|
|
||||||
b.elo
|
|
||||||
.cmp(&a.elo)
|
|
||||||
.then(b.wins.cmp(&a.wins))
|
|
||||||
.then(
|
|
||||||
b.points
|
|
||||||
.partial_cmp(&a.points)
|
|
||||||
.unwrap_or(std::cmp::Ordering::Equal),
|
|
||||||
)
|
|
||||||
.then(a.matches.cmp(&b.matches))
|
|
||||||
});
|
|
||||||
Ok(ranking)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn backfill_elo(&self) -> Result<usize> {
|
|
||||||
let mut inner = self.inner.lock().await;
|
|
||||||
inner.ratings.clear();
|
|
||||||
let matches = inner.matches.clone();
|
|
||||||
for row in &matches {
|
|
||||||
let players: Vec<StoredPlayer> = inner
|
|
||||||
.players
|
|
||||||
.iter()
|
|
||||||
.filter(|p| p.match_id == row.id)
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
let mut by_team: std::collections::BTreeMap<String, Vec<String>> = Default::default();
|
|
||||||
for player in &players {
|
|
||||||
if let Some(team) = &player.team {
|
|
||||||
by_team
|
|
||||||
.entry(team.clone())
|
|
||||||
.or_default()
|
|
||||||
.push(player.user_sub.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let teams: Vec<Vec<String>> = by_team.into_values().collect();
|
|
||||||
let winner_index = teams
|
|
||||||
.iter()
|
|
||||||
.position(|members| {
|
|
||||||
players
|
|
||||||
.iter()
|
|
||||||
.any(|p| p.won && members.contains(&p.user_sub))
|
|
||||||
})
|
|
||||||
.unwrap_or(0);
|
|
||||||
let deltas = memory_apply_elo(&mut inner, &row.game_type, &teams, winner_index)?;
|
|
||||||
for player in inner.players.iter_mut().filter(|p| p.match_id == row.id) {
|
|
||||||
player.elo_delta = deltas.get(&player.user_sub).map(|d| *d as i32);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(matches.len())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,588 +0,0 @@
|
|||||||
//! Persistence for live games.
|
|
||||||
//!
|
|
||||||
//! Game sessions are small, mutable and short-lived, which makes Redis a
|
|
||||||
//! natural fit: the whole match is a single JSON value under
|
|
||||||
//! `tavolo:game:<id>` with a sliding TTL, and a join-code index maps the
|
|
||||||
//! short code a player shares to that id. Completed matches are copied to
|
|
||||||
//! Postgres (see [`crate::stats`]); Redis keeps serving the finished session
|
|
||||||
//! until it expires.
|
|
||||||
//!
|
|
||||||
//! Two implementations satisfy [`GameStore`]:
|
|
||||||
//!
|
|
||||||
//! * [`RedisGameStore`] — production, used when `REDIS_URL` is set.
|
|
||||||
//! * [`InMemoryGameStore`] — tests and ephemeral dev, used otherwise.
|
|
||||||
//!
|
|
||||||
//! Concurrency is handled with a per-game lock so two simultaneous actions
|
|
||||||
//! cannot interleave. State changes are broadcast on a per-game pub/sub
|
|
||||||
//! channel as a simple "something changed" signal; every open websocket
|
|
||||||
//! reloads the session and renders the personalized view.
|
|
||||||
//!
|
|
||||||
//! Timeouts are driven by a shared delayed-deadline queue (see
|
|
||||||
//! [`crate::deadlines`]).
|
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use futures_util::stream::{BoxStream, StreamExt};
|
|
||||||
use tokio::sync::{broadcast, mpsc, Mutex, OwnedMutexGuard};
|
|
||||||
|
|
||||||
use crate::engine::{session_from_json, session_to_json, GameSession};
|
|
||||||
use crate::errors::{Error, Result};
|
|
||||||
use crate::registry::GameRegistry;
|
|
||||||
|
|
||||||
pub const GAME_KEY_PREFIX: &str = "tavolo:game:";
|
|
||||||
pub const CODE_KEY_PREFIX: &str = "tavolo:code:";
|
|
||||||
/// Channel prefix of the per-game change notifications.
|
|
||||||
pub const CHANNEL_PREFIX: &str = "tavolo:game:";
|
|
||||||
pub const DEADLINES_KEY: &str = "tavolo:deadlines";
|
|
||||||
/// Channel announcing newly enqueued deadline entries, so every worker's
|
|
||||||
/// consumer can wake immediately instead of polling on its heartbeat.
|
|
||||||
pub const DEADLINES_CHANNEL: &str = "tavolo:deadlines:events";
|
|
||||||
/// Sorted set of running games (score = expiry timestamp), maintained by the
|
|
||||||
/// stores so the telemetry gauge can count ongoing matches without loading
|
|
||||||
/// every session. Finished games are removed on their final save.
|
|
||||||
pub const ONGOING_GAMES_KEY: &str = "tavolo:games:ongoing";
|
|
||||||
|
|
||||||
const LOCK_TIMEOUT_SECONDS: f64 = 10.0;
|
|
||||||
const LOCK_BLOCKING_TIMEOUT_SECONDS: f64 = 10.0;
|
|
||||||
|
|
||||||
fn channel(game_id: &str) -> String {
|
|
||||||
format!("{CHANNEL_PREFIX}{game_id}:events")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn now_secs() -> f64 {
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_secs_f64())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A held per-game mutation lock. Released explicitly with
|
|
||||||
/// [`GameLock::release`] or best-effort on drop.
|
|
||||||
pub enum GameLock {
|
|
||||||
Memory(OwnedMutexGuard<()>),
|
|
||||||
Redis {
|
|
||||||
manager: redis::aio::ConnectionManager,
|
|
||||||
key: String,
|
|
||||||
token: String,
|
|
||||||
released: bool,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const UNLOCK_SCRIPT: &str = "if redis.call('get', KEYS[1]) == ARGV[1] \
|
|
||||||
then return redis.call('del', KEYS[1]) else return 0 end";
|
|
||||||
|
|
||||||
impl GameLock {
|
|
||||||
/// Release the lock. Consumes the guard; plain drop also releases it
|
|
||||||
/// (best-effort for the Redis variant).
|
|
||||||
pub async fn release(mut self) -> Result<()> {
|
|
||||||
if let GameLock::Redis {
|
|
||||||
manager,
|
|
||||||
key,
|
|
||||||
token,
|
|
||||||
released,
|
|
||||||
} = &mut self
|
|
||||||
{
|
|
||||||
if !*released {
|
|
||||||
*released = true;
|
|
||||||
let script = redis::Script::new(UNLOCK_SCRIPT);
|
|
||||||
let mut conn = manager.clone();
|
|
||||||
let _: i64 = script
|
|
||||||
.key(key.clone())
|
|
||||||
.arg(token.clone())
|
|
||||||
.invoke_async(&mut conn)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for GameLock {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
if let GameLock::Redis {
|
|
||||||
manager,
|
|
||||||
key,
|
|
||||||
token,
|
|
||||||
released,
|
|
||||||
} = self
|
|
||||||
{
|
|
||||||
if !*released {
|
|
||||||
*released = true;
|
|
||||||
let mut manager = manager.clone();
|
|
||||||
let key = key.clone();
|
|
||||||
let token = token.clone();
|
|
||||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
|
||||||
handle.spawn(async move {
|
|
||||||
let script = redis::Script::new(UNLOCK_SCRIPT);
|
|
||||||
let _: std::result::Result<i64, _> =
|
|
||||||
script.key(key).arg(token).invoke_async(&mut manager).await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Abstract persistence + notification layer for live games.
|
|
||||||
#[async_trait]
|
|
||||||
pub trait GameStore: Send + Sync {
|
|
||||||
/// Return the live session for `game_id` or `None`.
|
|
||||||
async fn load(&self, game_id: &str) -> Result<Option<GameSession>>;
|
|
||||||
|
|
||||||
/// Persist `session`, refreshing its TTL and code index.
|
|
||||||
async fn save(&self, session: &GameSession) -> Result<()>;
|
|
||||||
|
|
||||||
/// Return the live session for a join `code` or `None`.
|
|
||||||
async fn find_by_code(&self, code: &str) -> Result<Option<GameSession>>;
|
|
||||||
|
|
||||||
/// Number of live games that have not finished yet.
|
|
||||||
async fn count_ongoing(&self) -> Result<usize>;
|
|
||||||
|
|
||||||
/// Acquire the per-game mutation lock, waiting up to ten seconds.
|
|
||||||
async fn acquire_lock(&self, game_id: &str) -> Result<GameLock>;
|
|
||||||
|
|
||||||
/// Signal that the session of `game_id` changed.
|
|
||||||
async fn publish(&self, game_id: &str) -> Result<()>;
|
|
||||||
|
|
||||||
/// Stream of change signals for one game.
|
|
||||||
async fn subscribe(&self, game_id: &str) -> Result<BoxStream<'static, ()>>;
|
|
||||||
|
|
||||||
/// Enqueue `member` to fire at `due_at` (epoch seconds). Idempotent for
|
|
||||||
/// identical members: re-adding only updates the due time.
|
|
||||||
async fn add_deadline(&self, member: &str, due_at: f64) -> Result<()>;
|
|
||||||
|
|
||||||
/// Return up to `limit` enqueued members due at or before `now`.
|
|
||||||
async fn due_deadlines(&self, now: f64, limit: usize) -> Result<Vec<String>>;
|
|
||||||
|
|
||||||
/// Return the earliest pending due time (epoch seconds), if any.
|
|
||||||
async fn next_deadline(&self) -> Result<Option<f64>>;
|
|
||||||
|
|
||||||
/// Remove `member` from the queue; a no-op when absent.
|
|
||||||
async fn remove_deadline(&self, member: &str) -> Result<()>;
|
|
||||||
|
|
||||||
/// Wake every worker's deadline consumer (best effort).
|
|
||||||
async fn notify_deadline(&self) -> Result<()>;
|
|
||||||
|
|
||||||
/// Stream of deadline-enqueue signals.
|
|
||||||
async fn subscribe_deadlines(&self) -> Result<BoxStream<'static, ()>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type SharedGameStore = Arc<dyn GameStore>;
|
|
||||||
|
|
||||||
fn broadcast_stream(rx: broadcast::Receiver<()>) -> BoxStream<'static, ()> {
|
|
||||||
Box::pin(futures_util::stream::unfold(rx, |mut rx| async move {
|
|
||||||
loop {
|
|
||||||
match rx.recv().await {
|
|
||||||
Ok(()) => return Some(((), rx)),
|
|
||||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
|
||||||
Err(broadcast::error::RecvError::Closed) => return None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mpsc_stream(rx: mpsc::Receiver<()>) -> BoxStream<'static, ()> {
|
|
||||||
Box::pin(futures_util::stream::unfold(rx, |mut rx| async move {
|
|
||||||
rx.recv().await.map(|()| ((), rx))
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Redis store
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
pub struct RedisGameStore {
|
|
||||||
client: redis::Client,
|
|
||||||
manager: redis::aio::ConnectionManager,
|
|
||||||
registry: Arc<GameRegistry>,
|
|
||||||
ttl: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RedisGameStore {
|
|
||||||
pub async fn new(url: &str, registry: Arc<GameRegistry>, ttl_seconds: i64) -> Result<Self> {
|
|
||||||
let client = redis::Client::open(url)?;
|
|
||||||
let manager = redis::aio::ConnectionManager::new(client.clone()).await?;
|
|
||||||
Ok(Self {
|
|
||||||
client,
|
|
||||||
manager,
|
|
||||||
registry,
|
|
||||||
ttl: ttl_seconds,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn game_key(&self, game_id: &str) -> String {
|
|
||||||
format!("{GAME_KEY_PREFIX}{game_id}")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn lock_key(&self, game_id: &str) -> String {
|
|
||||||
format!("{GAME_KEY_PREFIX}{game_id}:lock")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn code_key(&self, code: &str) -> String {
|
|
||||||
format!("{CODE_KEY_PREFIX}{}", code.to_uppercase())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn subscribe_to(&self, target: String) -> Result<BoxStream<'static, ()>> {
|
|
||||||
let mut pubsub = self.client.get_async_pubsub().await?;
|
|
||||||
pubsub.subscribe(target).await?;
|
|
||||||
let (tx, rx) = mpsc::channel(32);
|
|
||||||
let mut messages = pubsub.into_on_message();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
while let Some(_message) = messages.next().await {
|
|
||||||
if tx.send(()).await.is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Ok(mpsc_stream(rx))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl GameStore for RedisGameStore {
|
|
||||||
async fn load(&self, game_id: &str) -> Result<Option<GameSession>> {
|
|
||||||
let mut conn = self.manager.clone();
|
|
||||||
let raw: Option<String> = redis::cmd("GET")
|
|
||||||
.arg(self.game_key(game_id))
|
|
||||||
.query_async(&mut conn)
|
|
||||||
.await?;
|
|
||||||
let Some(raw) = raw else {
|
|
||||||
tracing::debug!("redis load {game_id}: miss");
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
tracing::debug!("redis load {game_id}: hit");
|
|
||||||
let data: serde_json::Value = serde_json::from_str(&raw)?;
|
|
||||||
let game_type = data
|
|
||||||
.get("game_type")
|
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
.ok_or_else(|| Error::Store("stored session is missing game_type".into()))?;
|
|
||||||
let engine = self.registry.require(game_type)?;
|
|
||||||
Ok(Some(session_from_json(&data, engine.as_ref())?))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save(&self, session: &GameSession) -> Result<()> {
|
|
||||||
let engine = self.registry.require(&session.game_type)?;
|
|
||||||
let payload = serde_json::to_string(&session_to_json(session, engine.as_ref())?)?;
|
|
||||||
let mut conn = self.manager.clone();
|
|
||||||
let mut pipe = redis::pipe();
|
|
||||||
pipe.atomic();
|
|
||||||
pipe.cmd("SET")
|
|
||||||
.arg(self.game_key(&session.id))
|
|
||||||
.arg(&payload)
|
|
||||||
.arg("EX")
|
|
||||||
.arg(self.ttl);
|
|
||||||
pipe.cmd("SET")
|
|
||||||
.arg(self.code_key(&session.join_code))
|
|
||||||
.arg(&session.id)
|
|
||||||
.arg("EX")
|
|
||||||
.arg(self.ttl);
|
|
||||||
if engine.is_finished(session) {
|
|
||||||
pipe.cmd("ZREM").arg(ONGOING_GAMES_KEY).arg(&session.id);
|
|
||||||
} else {
|
|
||||||
pipe.cmd("ZADD")
|
|
||||||
.arg(ONGOING_GAMES_KEY)
|
|
||||||
.arg(now_secs() + self.ttl as f64)
|
|
||||||
.arg(&session.id);
|
|
||||||
pipe.cmd("EXPIRE").arg(ONGOING_GAMES_KEY).arg(self.ttl);
|
|
||||||
}
|
|
||||||
let _: Vec<redis::Value> = pipe.query_async(&mut conn).await?;
|
|
||||||
tracing::debug!(
|
|
||||||
"redis save {} (game {}, ttl {}s)",
|
|
||||||
session.id,
|
|
||||||
session.game_type,
|
|
||||||
self.ttl
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn find_by_code(&self, code: &str) -> Result<Option<GameSession>> {
|
|
||||||
let mut conn = self.manager.clone();
|
|
||||||
let game_id: Option<String> = redis::cmd("GET")
|
|
||||||
.arg(self.code_key(code))
|
|
||||||
.query_async(&mut conn)
|
|
||||||
.await?;
|
|
||||||
match game_id {
|
|
||||||
Some(game_id) => self.load(&game_id).await,
|
|
||||||
None => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn count_ongoing(&self) -> Result<usize> {
|
|
||||||
let mut conn = self.manager.clone();
|
|
||||||
let mut pipe = redis::pipe();
|
|
||||||
pipe.atomic();
|
|
||||||
pipe.cmd("ZREMRANGEBYSCORE")
|
|
||||||
.arg(ONGOING_GAMES_KEY)
|
|
||||||
.arg("-inf")
|
|
||||||
.arg(now_secs());
|
|
||||||
pipe.cmd("ZCARD").arg(ONGOING_GAMES_KEY);
|
|
||||||
let values: Vec<i64> = pipe.query_async(&mut conn).await?;
|
|
||||||
Ok(values.last().copied().unwrap_or(0).max(0) as usize)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn acquire_lock(&self, game_id: &str) -> Result<GameLock> {
|
|
||||||
let key = self.lock_key(game_id);
|
|
||||||
let token = uuid::Uuid::new_v4().to_string();
|
|
||||||
let mut conn = self.manager.clone();
|
|
||||||
let deadline = std::time::Instant::now()
|
|
||||||
+ std::time::Duration::from_secs_f64(LOCK_BLOCKING_TIMEOUT_SECONDS);
|
|
||||||
loop {
|
|
||||||
let acquired: Option<String> = redis::cmd("SET")
|
|
||||||
.arg(&key)
|
|
||||||
.arg(&token)
|
|
||||||
.arg("NX")
|
|
||||||
.arg("PX")
|
|
||||||
.arg((LOCK_TIMEOUT_SECONDS * 1000.0) as i64)
|
|
||||||
.query_async(&mut conn)
|
|
||||||
.await?;
|
|
||||||
if acquired.is_some() {
|
|
||||||
return Ok(GameLock::Redis {
|
|
||||||
manager: self.manager.clone(),
|
|
||||||
key,
|
|
||||||
token,
|
|
||||||
released: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if std::time::Instant::now() >= deadline {
|
|
||||||
return Err(Error::Store(format!(
|
|
||||||
"could not acquire the lock for game {game_id}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn publish(&self, game_id: &str) -> Result<()> {
|
|
||||||
let mut conn = self.manager.clone();
|
|
||||||
redis::cmd("PUBLISH")
|
|
||||||
.arg(channel(game_id))
|
|
||||||
.arg("update")
|
|
||||||
.query_async::<i64>(&mut conn)
|
|
||||||
.await?;
|
|
||||||
tracing::debug!("redis publish {game_id}");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn subscribe(&self, game_id: &str) -> Result<BoxStream<'static, ()>> {
|
|
||||||
self.subscribe_to(channel(game_id)).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn add_deadline(&self, member: &str, due_at: f64) -> Result<()> {
|
|
||||||
let mut conn = self.manager.clone();
|
|
||||||
redis::cmd("ZADD")
|
|
||||||
.arg(DEADLINES_KEY)
|
|
||||||
.arg(due_at)
|
|
||||||
.arg(member)
|
|
||||||
.query_async::<i64>(&mut conn)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn due_deadlines(&self, now: f64, limit: usize) -> Result<Vec<String>> {
|
|
||||||
let mut conn = self.manager.clone();
|
|
||||||
let members: Vec<String> = redis::cmd("ZRANGE")
|
|
||||||
.arg(DEADLINES_KEY)
|
|
||||||
.arg("-inf")
|
|
||||||
.arg(now)
|
|
||||||
.arg("BYSCORE")
|
|
||||||
.arg("LIMIT")
|
|
||||||
.arg(0)
|
|
||||||
.arg(limit)
|
|
||||||
.query_async(&mut conn)
|
|
||||||
.await?;
|
|
||||||
Ok(members)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn next_deadline(&self) -> Result<Option<f64>> {
|
|
||||||
let mut conn = self.manager.clone();
|
|
||||||
let earliest: Vec<(String, f64)> = redis::cmd("ZRANGE")
|
|
||||||
.arg(DEADLINES_KEY)
|
|
||||||
.arg(0)
|
|
||||||
.arg(0)
|
|
||||||
.arg("WITHSCORES")
|
|
||||||
.query_async(&mut conn)
|
|
||||||
.await?;
|
|
||||||
Ok(earliest.first().map(|(_, score)| *score))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn remove_deadline(&self, member: &str) -> Result<()> {
|
|
||||||
let mut conn = self.manager.clone();
|
|
||||||
redis::cmd("ZREM")
|
|
||||||
.arg(DEADLINES_KEY)
|
|
||||||
.arg(member)
|
|
||||||
.query_async::<i64>(&mut conn)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn notify_deadline(&self) -> Result<()> {
|
|
||||||
let mut conn = self.manager.clone();
|
|
||||||
redis::cmd("PUBLISH")
|
|
||||||
.arg(DEADLINES_CHANNEL)
|
|
||||||
.arg("deadline")
|
|
||||||
.query_async::<i64>(&mut conn)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn subscribe_deadlines(&self) -> Result<BoxStream<'static, ()>> {
|
|
||||||
self.subscribe_to(DEADLINES_CHANNEL.to_string()).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// In-memory store
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Process-local store used by tests and when Redis is not configured.
|
|
||||||
pub struct InMemoryGameStore {
|
|
||||||
registry: Arc<GameRegistry>,
|
|
||||||
games: Mutex<HashMap<String, String>>,
|
|
||||||
codes: Mutex<HashMap<String, String>>,
|
|
||||||
ongoing: Mutex<HashSet<String>>,
|
|
||||||
locks: Mutex<HashMap<String, Arc<Mutex<()>>>>,
|
|
||||||
subscribers: Mutex<HashMap<String, broadcast::Sender<()>>>,
|
|
||||||
deadlines: Mutex<HashMap<String, f64>>,
|
|
||||||
deadline_subscribers: broadcast::Sender<()>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InMemoryGameStore {
|
|
||||||
pub fn new(registry: Arc<GameRegistry>) -> Self {
|
|
||||||
let (deadline_subscribers, _) = broadcast::channel(64);
|
|
||||||
Self {
|
|
||||||
registry,
|
|
||||||
games: Mutex::new(HashMap::new()),
|
|
||||||
codes: Mutex::new(HashMap::new()),
|
|
||||||
ongoing: Mutex::new(HashSet::new()),
|
|
||||||
locks: Mutex::new(HashMap::new()),
|
|
||||||
subscribers: Mutex::new(HashMap::new()),
|
|
||||||
deadlines: Mutex::new(HashMap::new()),
|
|
||||||
deadline_subscribers,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn sender_for(&self, game_id: &str) -> broadcast::Sender<()> {
|
|
||||||
let mut subscribers = self.subscribers.lock().await;
|
|
||||||
subscribers
|
|
||||||
.entry(game_id.to_string())
|
|
||||||
.or_insert_with(|| broadcast::channel(64).0)
|
|
||||||
.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl GameStore for InMemoryGameStore {
|
|
||||||
async fn load(&self, game_id: &str) -> Result<Option<GameSession>> {
|
|
||||||
let raw = self.games.lock().await.get(game_id).cloned();
|
|
||||||
let Some(raw) = raw else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let data: serde_json::Value = serde_json::from_str(&raw)?;
|
|
||||||
let game_type = data
|
|
||||||
.get("game_type")
|
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
.ok_or_else(|| Error::Store("stored session is missing game_type".into()))?;
|
|
||||||
let engine = self.registry.require(game_type)?;
|
|
||||||
Ok(Some(session_from_json(&data, engine.as_ref())?))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save(&self, session: &GameSession) -> Result<()> {
|
|
||||||
let engine = self.registry.require(&session.game_type)?;
|
|
||||||
let payload = serde_json::to_string(&session_to_json(session, engine.as_ref())?)?;
|
|
||||||
self.games.lock().await.insert(session.id.clone(), payload);
|
|
||||||
self.codes
|
|
||||||
.lock()
|
|
||||||
.await
|
|
||||||
.insert(session.join_code.to_uppercase(), session.id.clone());
|
|
||||||
if engine.is_finished(session) {
|
|
||||||
self.ongoing.lock().await.remove(&session.id);
|
|
||||||
} else {
|
|
||||||
self.ongoing.lock().await.insert(session.id.clone());
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn find_by_code(&self, code: &str) -> Result<Option<GameSession>> {
|
|
||||||
let game_id = self.codes.lock().await.get(&code.to_uppercase()).cloned();
|
|
||||||
match game_id {
|
|
||||||
Some(game_id) => self.load(&game_id).await,
|
|
||||||
None => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn count_ongoing(&self) -> Result<usize> {
|
|
||||||
Ok(self.ongoing.lock().await.len())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn acquire_lock(&self, game_id: &str) -> Result<GameLock> {
|
|
||||||
let lock = {
|
|
||||||
let mut locks = self.locks.lock().await;
|
|
||||||
locks
|
|
||||||
.entry(game_id.to_string())
|
|
||||||
.or_insert_with(|| Arc::new(Mutex::new(())))
|
|
||||||
.clone()
|
|
||||||
};
|
|
||||||
Ok(GameLock::Memory(lock.lock_owned().await))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn publish(&self, game_id: &str) -> Result<()> {
|
|
||||||
let sender = self.sender_for(game_id).await;
|
|
||||||
let _ = sender.send(());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn subscribe(&self, game_id: &str) -> Result<BoxStream<'static, ()>> {
|
|
||||||
let sender = self.sender_for(game_id).await;
|
|
||||||
Ok(broadcast_stream(sender.subscribe()))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn add_deadline(&self, member: &str, due_at: f64) -> Result<()> {
|
|
||||||
self.deadlines
|
|
||||||
.lock()
|
|
||||||
.await
|
|
||||||
.insert(member.to_string(), due_at);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn due_deadlines(&self, now: f64, limit: usize) -> Result<Vec<String>> {
|
|
||||||
let deadlines = self.deadlines.lock().await;
|
|
||||||
let mut due: Vec<(String, f64)> = deadlines
|
|
||||||
.iter()
|
|
||||||
.filter(|(_, due_at)| **due_at <= now)
|
|
||||||
.map(|(member, due_at)| (member.clone(), *due_at))
|
|
||||||
.collect();
|
|
||||||
due.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
|
|
||||||
Ok(due
|
|
||||||
.into_iter()
|
|
||||||
.take(limit)
|
|
||||||
.map(|(member, _)| member)
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn next_deadline(&self) -> Result<Option<f64>> {
|
|
||||||
Ok(self
|
|
||||||
.deadlines
|
|
||||||
.lock()
|
|
||||||
.await
|
|
||||||
.values()
|
|
||||||
.copied()
|
|
||||||
.min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn remove_deadline(&self, member: &str) -> Result<()> {
|
|
||||||
self.deadlines.lock().await.remove(member);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn notify_deadline(&self) -> Result<()> {
|
|
||||||
let _ = self.deadline_subscribers.send(());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn subscribe_deadlines(&self) -> Result<BoxStream<'static, ()>> {
|
|
||||||
Ok(broadcast_stream(self.deadline_subscribers.subscribe()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,324 +0,0 @@
|
|||||||
//! WebSocket endpoint for live play.
|
|
||||||
//!
|
|
||||||
//! Clients connect to `/ws/games/{game_id}` using their session cookie. Only
|
|
||||||
//! seated players are accepted; the upgrade is refused before the handshake
|
|
||||||
//! completes with an HTTP status mirroring `GET /api/games/{id}`: 401
|
|
||||||
//! unauthenticated, 404 unknown game, 403 not seated.
|
|
||||||
//!
|
|
||||||
//! Server -> client messages are JSON objects with a `type`: `state`,
|
|
||||||
//! `game_over` and `error`. Client -> server messages are JSON objects with
|
|
||||||
//! an `action`: the platform handles `state`/`sync` itself; every other
|
|
||||||
//! action is dispatched to the session's game engine with the rest of the
|
|
||||||
//! message as its payload.
|
|
||||||
//!
|
|
||||||
//! Mutations run under the per-game lock; after a successful action the new
|
|
||||||
//! session is saved and a change signal is published. Every connected
|
|
||||||
//! websocket is subscribed to that signal and re-renders the state.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
|
||||||
use axum::extract::{Path, State};
|
|
||||||
use axum::http::StatusCode;
|
|
||||||
use axum::response::{IntoResponse, Response};
|
|
||||||
use axum::Extension;
|
|
||||||
use futures_util::stream::{SplitSink, SplitStream};
|
|
||||||
use futures_util::{SinkExt, StreamExt};
|
|
||||||
use serde_json::{json, Map, Value};
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use tracing::Instrument;
|
|
||||||
|
|
||||||
use crate::auth::current_user;
|
|
||||||
use crate::engine::GameSession;
|
|
||||||
use crate::http::py_repr;
|
|
||||||
use crate::platform::{Platform, SharedPlatform};
|
|
||||||
use crate::session::SessionHandle;
|
|
||||||
|
|
||||||
/// The live-play endpoint; rejects non-seated callers before the upgrade.
|
|
||||||
pub async fn game_socket(
|
|
||||||
State(platform): State<SharedPlatform>,
|
|
||||||
Extension(session): Extension<SessionHandle>,
|
|
||||||
Path(game_id): Path<String>,
|
|
||||||
ws: WebSocketUpgrade,
|
|
||||||
) -> Response {
|
|
||||||
let Some(user) = current_user(&session).await else {
|
|
||||||
tracing::debug!("websocket {game_id} rejected: no authenticated user");
|
|
||||||
return StatusCode::UNAUTHORIZED.into_response();
|
|
||||||
};
|
|
||||||
let game = match platform.game_store.load(&game_id).await {
|
|
||||||
Ok(Some(game)) => game,
|
|
||||||
Ok(None) => {
|
|
||||||
tracing::debug!("websocket rejected: unknown game {game_id}");
|
|
||||||
return StatusCode::NOT_FOUND.into_response();
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
tracing::error!("websocket game load failed: {err}");
|
|
||||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if !game.seated(&user.sub) {
|
|
||||||
tracing::debug!("websocket {game_id} rejected: {} is not seated", user.sub);
|
|
||||||
return StatusCode::FORBIDDEN.into_response();
|
|
||||||
}
|
|
||||||
let session_tag = {
|
|
||||||
let guard = session.lock().await;
|
|
||||||
guard.id().map(crate::auth::session_tag)
|
|
||||||
};
|
|
||||||
let sub = user.sub.clone();
|
|
||||||
let span = tracing::info_span!(
|
|
||||||
"websocket",
|
|
||||||
"user.id" = %user.sub,
|
|
||||||
"session.id" = tracing::field::Empty,
|
|
||||||
"game.id" = %game_id,
|
|
||||||
);
|
|
||||||
if let Some(tag) = session_tag {
|
|
||||||
span.record("session.id", tag);
|
|
||||||
}
|
|
||||||
ws.on_upgrade(move |socket| {
|
|
||||||
async move {
|
|
||||||
run_socket(platform, socket, game_id, sub).await;
|
|
||||||
}
|
|
||||||
.instrument(span)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct Sender {
|
|
||||||
sink: Arc<Mutex<SplitSink<WebSocket, Message>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Sender {
|
|
||||||
async fn send(&self, payload: Value) {
|
|
||||||
let Ok(text) = serde_json::to_string(&payload) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let mut sink = self.sink.lock().await;
|
|
||||||
let _ = sink.send(Message::Text(text.into())).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn error_payload(message: impl Into<String>, code: &str) -> Value {
|
|
||||||
json!({ "type": "error", "code": code, "message": message.into() })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn state_message(platform: &Platform, game: &GameSession, sub: &str) -> Option<Value> {
|
|
||||||
let engine = platform.registry.require(&game.game_type).ok()?;
|
|
||||||
let mut game_view = json!({
|
|
||||||
"id": game.id,
|
|
||||||
"join_code": game.join_code,
|
|
||||||
"game_type": game.game_type,
|
|
||||||
});
|
|
||||||
if let (Value::Object(view), Value::Object(base)) = (engine.view_for(game, sub), &mut game_view)
|
|
||||||
{
|
|
||||||
for (key, value) in view {
|
|
||||||
base.insert(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(json!({ "type": "state", "game": game_view }))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_socket(platform: SharedPlatform, socket: WebSocket, game_id: String, sub: String) {
|
|
||||||
let (sink, mut stream): (SplitSink<WebSocket, Message>, SplitStream<WebSocket>) =
|
|
||||||
socket.split();
|
|
||||||
let sender = Sender {
|
|
||||||
sink: Arc::new(Mutex::new(sink)),
|
|
||||||
};
|
|
||||||
tracing::info!("{sub} connected to game {game_id}");
|
|
||||||
|
|
||||||
// Initial snapshot and a deadline backstop (the entry may have been lost
|
|
||||||
// while the game lived on thanks to its sliding TTL).
|
|
||||||
match platform.game_store.load(&game_id).await {
|
|
||||||
Ok(Some(game)) => {
|
|
||||||
if let Some(message) = state_message(&platform, &game, &sub) {
|
|
||||||
sender.send(message).await;
|
|
||||||
}
|
|
||||||
let _ = platform.scheduler.sync_deadline(&game).await;
|
|
||||||
}
|
|
||||||
_ => return,
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut events = match platform.game_store.subscribe(&game_id).await {
|
|
||||||
Ok(events) => events,
|
|
||||||
Err(err) => {
|
|
||||||
tracing::error!("websocket subscribe failed: {err}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let forward_sender = sender.clone();
|
|
||||||
let forward_platform = platform.clone();
|
|
||||||
let forward_game_id = game_id.clone();
|
|
||||||
let forward_sub = sub.clone();
|
|
||||||
let forward = tokio::spawn(async move {
|
|
||||||
while events.next().await.is_some() {
|
|
||||||
let Ok(Some(game)) = forward_platform.game_store.load(&forward_game_id).await else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if let Some(message) = state_message(&forward_platform, &game, &forward_sub) {
|
|
||||||
forward_sender.send(message).await;
|
|
||||||
}
|
|
||||||
if let Ok(engine) = forward_platform.registry.require(&game.game_type) {
|
|
||||||
if engine.is_finished(&game) {
|
|
||||||
let mut payload = json!({ "type": "game_over" });
|
|
||||||
if let (Value::Object(extra), Value::Object(base)) =
|
|
||||||
(engine.game_over_view(&game, &forward_sub), &mut payload)
|
|
||||||
{
|
|
||||||
for (key, value) in extra {
|
|
||||||
base.insert(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
forward_sender.send(payload).await;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
while let Some(message) = stream.next().await {
|
|
||||||
match message {
|
|
||||||
Ok(Message::Text(text)) => {
|
|
||||||
handle_message(&platform, &sender, &game_id, &sub, text.as_str()).await;
|
|
||||||
}
|
|
||||||
Ok(Message::Binary(_)) => {
|
|
||||||
sender
|
|
||||||
.send(error_payload(
|
|
||||||
"expected a text frame with a JSON object",
|
|
||||||
"invalid",
|
|
||||||
))
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
Ok(Message::Close(_)) => break,
|
|
||||||
Ok(Message::Ping(_)) | Ok(Message::Pong(_)) => {}
|
|
||||||
Err(err) => {
|
|
||||||
tracing::debug!("websocket {game_id} receive error: {err}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
forward.abort();
|
|
||||||
tracing::debug!("{sub} disconnected from game {game_id}");
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_message(
|
|
||||||
platform: &SharedPlatform,
|
|
||||||
sender: &Sender,
|
|
||||||
game_id: &str,
|
|
||||||
sub: &str,
|
|
||||||
raw: &str,
|
|
||||||
) {
|
|
||||||
let parsed: Value = match serde_json::from_str(raw) {
|
|
||||||
Ok(parsed) => parsed,
|
|
||||||
Err(_) => {
|
|
||||||
tracing::debug!("game {game_id}: malformed message from {sub} (not JSON)");
|
|
||||||
sender.send(error_payload("invalid JSON", "invalid")).await;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let Value::Object(data) = parsed else {
|
|
||||||
tracing::debug!("game {game_id}: malformed message from {sub} (not an object)");
|
|
||||||
sender
|
|
||||||
.send(error_payload("message must be a JSON object", "invalid"))
|
|
||||||
.await;
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let action = data.get("action");
|
|
||||||
if let Some(Value::String(action)) = action {
|
|
||||||
if action == "state" || action == "sync" {
|
|
||||||
if let Ok(Some(game)) = platform.game_store.load(game_id).await {
|
|
||||||
if let Some(message) = state_message(platform, &game, sub) {
|
|
||||||
sender.send(message).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let Some(Value::String(action)) = action else {
|
|
||||||
let value = action.cloned().unwrap_or(Value::Null);
|
|
||||||
sender
|
|
||||||
.send(error_payload(
|
|
||||||
format!("unknown action: {}", py_repr(&value)),
|
|
||||||
"invalid",
|
|
||||||
))
|
|
||||||
.await;
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
handle_action(platform, sender, game_id, sub, action, &data).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_action(
|
|
||||||
platform: &SharedPlatform,
|
|
||||||
sender: &Sender,
|
|
||||||
game_id: &str,
|
|
||||||
sub: &str,
|
|
||||||
action: &str,
|
|
||||||
data: &Map<String, Value>,
|
|
||||||
) {
|
|
||||||
let lock = match platform.game_store.acquire_lock(game_id).await {
|
|
||||||
Ok(lock) => lock,
|
|
||||||
Err(err) => {
|
|
||||||
tracing::error!("game {game_id}: lock failed: {err}");
|
|
||||||
sender
|
|
||||||
.send(error_payload("internal error", "internal"))
|
|
||||||
.await;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let result = async {
|
|
||||||
let Some(mut game) = (match platform.game_store.load(game_id).await {
|
|
||||||
Ok(game) => game,
|
|
||||||
Err(err) => {
|
|
||||||
tracing::error!("game {game_id}: load failed: {err}");
|
|
||||||
return Err(());
|
|
||||||
}
|
|
||||||
}) else {
|
|
||||||
sender
|
|
||||||
.send(error_payload("game not found", "not_found"))
|
|
||||||
.await;
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
let engine = match platform.registry.require(&game.game_type) {
|
|
||||||
Ok(engine) => engine.clone(),
|
|
||||||
Err(err) => {
|
|
||||||
tracing::error!("game {game_id}: engine lookup failed: {err}");
|
|
||||||
return Err(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let payload: Map<String, Value> = data
|
|
||||||
.iter()
|
|
||||||
.filter(|(key, _)| key.as_str() != "action")
|
|
||||||
.map(|(key, value)| (key.clone(), value.clone()))
|
|
||||||
.collect();
|
|
||||||
match engine.handle_action(&mut game, sub, action, &payload) {
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(err) => match err.as_game() {
|
|
||||||
Some(game_error) => {
|
|
||||||
tracing::debug!(
|
|
||||||
"game {game_id}: rejected {action:?} by {sub}: {}",
|
|
||||||
game_error.message
|
|
||||||
);
|
|
||||||
sender
|
|
||||||
.send(error_payload(&game_error.message, game_error.code))
|
|
||||||
.await;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
tracing::error!("game {game_id}: action failed: {err}");
|
|
||||||
return Err(());
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
tracing::debug!("game {game_id}: {sub} performed {action:?}");
|
|
||||||
if let Err(err) = platform.scheduler.finalize_mutation(&mut game).await {
|
|
||||||
tracing::error!("game {game_id}: finalize failed: {err}");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
.await;
|
|
||||||
let _ = lock.release().await;
|
|
||||||
if result.is_err() {
|
|
||||||
sender
|
|
||||||
.send(error_payload("internal error", "internal"))
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,469 +0,0 @@
|
|||||||
//! Shared fixtures for the tavolo-platform test suite.
|
|
||||||
//!
|
|
||||||
//! `DummyEngine` is a tiny two-player game implementing the platform's
|
|
||||||
//! `GameEngine` contract, so every platform behaviour (lobby, store,
|
|
||||||
//! websockets, deadlines, stats) is exercised without importing any real
|
|
||||||
//! game. Its rules: the first player to reach `target` plays wins.
|
|
||||||
|
|
||||||
#![allow(dead_code)]
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use axum::body::Body;
|
|
||||||
use axum::http::{Request, Response, StatusCode};
|
|
||||||
use axum::Router;
|
|
||||||
use futures_util::StreamExt;
|
|
||||||
use serde_json::{json, Map, Value};
|
|
||||||
use tower::ServiceExt;
|
|
||||||
|
|
||||||
use tavolo_platform::deadlines::DeadlineScheduler;
|
|
||||||
use tavolo_platform::engine::{Deadline, GameEngine, GameSession, MatchResult, PlayerResult, Seat};
|
|
||||||
use tavolo_platform::errors::{Error, GameError, Result};
|
|
||||||
use tavolo_platform::oidc::{OidcProviderConfig, OidcProviders};
|
|
||||||
use tavolo_platform::platform::{Platform, SharedPlatform};
|
|
||||||
use tavolo_platform::registry::GameRegistry;
|
|
||||||
use tavolo_platform::session::{session_middleware, InMemorySessionStore, SharedSessionStore};
|
|
||||||
use tavolo_platform::stats::{InMemoryStatsRepo, SharedStatsRepo};
|
|
||||||
use tavolo_platform::store::{InMemoryGameStore, SharedGameStore};
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
|
||||||
pub struct DummyState {
|
|
||||||
pub target: i64,
|
|
||||||
pub plays: Vec<String>,
|
|
||||||
pub started: bool,
|
|
||||||
pub finished: bool,
|
|
||||||
pub winner: Option<String>,
|
|
||||||
pub deadline_in_seconds: Option<f64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct DummyEngine;
|
|
||||||
|
|
||||||
fn state_of(session: &GameSession) -> Result<&DummyState> {
|
|
||||||
session
|
|
||||||
.state
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|state| state.downcast_ref::<DummyState>())
|
|
||||||
.ok_or_else(|| Error::Internal("dummy state missing".into()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn state_of_mut(session: &mut GameSession) -> Result<&mut DummyState> {
|
|
||||||
session
|
|
||||||
.state
|
|
||||||
.as_mut()
|
|
||||||
.and_then(|state| state.downcast_mut::<DummyState>())
|
|
||||||
.ok_or_else(|| Error::Internal("dummy state missing".into()))
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DummyEngine {
|
|
||||||
fn play(session: &mut GameSession, user_sub: &str) -> Result<()> {
|
|
||||||
let state = state_of_mut(session)?;
|
|
||||||
state.plays.push(user_sub.to_string());
|
|
||||||
if state.plays.len() as i64 >= state.target {
|
|
||||||
state.finished = true;
|
|
||||||
state.winner = Some(user_sub.to_string());
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GameEngine for DummyEngine {
|
|
||||||
fn id(&self) -> &'static str {
|
|
||||||
"dummy"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"Dummy game"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn description(&self) -> &'static str {
|
|
||||||
"A two-player toy game for testing the platform."
|
|
||||||
}
|
|
||||||
|
|
||||||
fn min_players(&self) -> usize {
|
|
||||||
2
|
|
||||||
}
|
|
||||||
|
|
||||||
fn max_players(&self) -> usize {
|
|
||||||
2
|
|
||||||
}
|
|
||||||
|
|
||||||
fn options_schema(&self) -> Value {
|
|
||||||
json!({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"target": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1,
|
|
||||||
"default": 3,
|
|
||||||
"description": "Plays needed to win the match.",
|
|
||||||
},
|
|
||||||
"deadline_in_seconds": {
|
|
||||||
"type": "number",
|
|
||||||
"description": "Arm a tick deadline this far in the future.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create(&self, session: &mut GameSession, options: &Map<String, Value>) -> Result<()> {
|
|
||||||
let target = options.get("target").and_then(Value::as_i64).unwrap_or(3);
|
|
||||||
if target < 1 || options.get("target").is_some_and(|v| v.is_boolean()) {
|
|
||||||
return Err(GameError::illegal_move("target must be a positive integer").into());
|
|
||||||
}
|
|
||||||
let creator = session.players[0].clone();
|
|
||||||
session.players[0] = Seat {
|
|
||||||
user_sub: creator.user_sub,
|
|
||||||
display_name: creator.display_name,
|
|
||||||
team: Some("A".into()),
|
|
||||||
};
|
|
||||||
session.state = Some(Box::new(DummyState {
|
|
||||||
target,
|
|
||||||
plays: Vec::new(),
|
|
||||||
started: false,
|
|
||||||
finished: false,
|
|
||||||
winner: None,
|
|
||||||
deadline_in_seconds: options.get("deadline_in_seconds").and_then(Value::as_f64),
|
|
||||||
}));
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn join(&self, session: &mut GameSession, user_sub: &str, display_name: &str) -> Result<()> {
|
|
||||||
if state_of(session)?.started {
|
|
||||||
return Err(GameError::game_not_started("game has already started").into());
|
|
||||||
}
|
|
||||||
if session.seated(user_sub) {
|
|
||||||
return Err(GameError::already_joined("already joined this game").into());
|
|
||||||
}
|
|
||||||
if session.players.len() >= 2 {
|
|
||||||
return Err(GameError::lobby_full("game is full").into());
|
|
||||||
}
|
|
||||||
let team = if session.players.is_empty() { "A" } else { "B" };
|
|
||||||
session.players.push(Seat {
|
|
||||||
user_sub: user_sub.into(),
|
|
||||||
display_name: display_name.into(),
|
|
||||||
team: Some(team.into()),
|
|
||||||
});
|
|
||||||
if session.players.len() == 2 {
|
|
||||||
state_of_mut(session)?.started = true;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_action(
|
|
||||||
&self,
|
|
||||||
session: &mut GameSession,
|
|
||||||
user_sub: &str,
|
|
||||||
action: &str,
|
|
||||||
_payload: &Map<String, Value>,
|
|
||||||
) -> Result<()> {
|
|
||||||
if !state_of(session)?.started {
|
|
||||||
return Err(GameError::game_not_started("the game has not started yet").into());
|
|
||||||
}
|
|
||||||
if state_of(session)?.finished {
|
|
||||||
return Err(GameError::game_finished("the match is over").into());
|
|
||||||
}
|
|
||||||
if !session.seated(user_sub) {
|
|
||||||
return Err(GameError::not_your_turn("you are not seated in this game").into());
|
|
||||||
}
|
|
||||||
if action != "play" {
|
|
||||||
return Err(GameError::illegal_move(format!(
|
|
||||||
"unknown action: {}",
|
|
||||||
tavolo_platform::http::py_repr(&Value::String(action.to_string()))
|
|
||||||
))
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
DummyEngine::play(session, user_sub)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view_for(&self, session: &GameSession, user_sub: &str) -> Value {
|
|
||||||
let state = state_of(session).expect("dummy state");
|
|
||||||
json!({
|
|
||||||
"started": state.started,
|
|
||||||
"finished": state.finished,
|
|
||||||
"winner": state.winner,
|
|
||||||
"plays": state.plays.len(),
|
|
||||||
"target": state.target,
|
|
||||||
"viewer_seated": session.seated(user_sub),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn lobby_view(&self, session: &GameSession) -> Value {
|
|
||||||
let state = state_of(session).expect("dummy state");
|
|
||||||
json!({
|
|
||||||
"phase": if state.started { "playing" } else { "lobby" },
|
|
||||||
"target": state.target,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn in_lobby(&self, session: &GameSession) -> bool {
|
|
||||||
state_of(session)
|
|
||||||
.map(|state| !state.started)
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_finished(&self, session: &GameSession) -> bool {
|
|
||||||
state_of(session)
|
|
||||||
.map(|state| state.finished)
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn result(&self, session: &GameSession) -> Result<MatchResult> {
|
|
||||||
let state = state_of(session)?;
|
|
||||||
let Some(winner) = state.winner.clone() else {
|
|
||||||
return Err(GameError::illegal_move("no result: the match is not finished").into());
|
|
||||||
};
|
|
||||||
let subs: Vec<String> = session
|
|
||||||
.players
|
|
||||||
.iter()
|
|
||||||
.map(|seat| seat.user_sub.clone())
|
|
||||||
.collect();
|
|
||||||
Ok(MatchResult {
|
|
||||||
teams: vec![vec![subs[0].clone()], vec![subs[1].clone()]],
|
|
||||||
winner_team: subs.iter().position(|sub| *sub == winner).unwrap_or(0),
|
|
||||||
players: session
|
|
||||||
.players
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(index, seat)| PlayerResult {
|
|
||||||
user_sub: seat.user_sub.clone(),
|
|
||||||
seat: index as i64,
|
|
||||||
won: seat.user_sub == winner,
|
|
||||||
team: seat.team.clone(),
|
|
||||||
score: state
|
|
||||||
.plays
|
|
||||||
.iter()
|
|
||||||
.filter(|sub| **sub == seat.user_sub)
|
|
||||||
.count() as f64,
|
|
||||||
details: json!({
|
|
||||||
"plays": state.plays.iter().filter(|sub| **sub == seat.user_sub).count(),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
summary: json!({
|
|
||||||
"target": state.target,
|
|
||||||
"plays": state.plays.len(),
|
|
||||||
"winner": winner,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn state_to_json(&self, state: &(dyn std::any::Any + Send + Sync)) -> Result<Value> {
|
|
||||||
let state = state
|
|
||||||
.downcast_ref::<DummyState>()
|
|
||||||
.ok_or_else(|| Error::Internal("dummy state missing".into()))?;
|
|
||||||
Ok(serde_json::to_value(state)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn state_from_json(&self, data: &Value) -> Result<Box<dyn std::any::Any + Send + Sync>> {
|
|
||||||
Ok(Box::new(serde_json::from_value::<DummyState>(
|
|
||||||
data.clone(),
|
|
||||||
)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn next_deadline(&self, session: &GameSession) -> Result<Option<Deadline>> {
|
|
||||||
let state = state_of(session)?;
|
|
||||||
let Some(seconds) = state.deadline_in_seconds else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
if !state.started || state.finished {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
let due_at = tavolo_platform::datetime::now()
|
|
||||||
+ chrono::Duration::milliseconds((seconds * 1000.0) as i64);
|
|
||||||
Ok(Some(Deadline {
|
|
||||||
kind: "tick".into(),
|
|
||||||
due_at,
|
|
||||||
token: format!("tick:{}", state.plays.len()),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fire_deadline(&self, session: &mut GameSession, kind: &str, token: &str) -> Result<()> {
|
|
||||||
let current = self.next_deadline(session)?;
|
|
||||||
match current {
|
|
||||||
Some(current) if current.kind == kind && current.token == token => {}
|
|
||||||
_ => return Err(GameError::illegal_move("stale deadline").into()),
|
|
||||||
}
|
|
||||||
let user_sub = session.players[0].user_sub.clone();
|
|
||||||
DummyEngine::play(session, &user_sub)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn test_oidc_config(provider: &str) -> OidcProviderConfig {
|
|
||||||
OidcProviderConfig {
|
|
||||||
provider: provider.into(),
|
|
||||||
issuer: format!("http://localhost:8180/{provider}"),
|
|
||||||
client_id: "tavolo".into(),
|
|
||||||
client_secret: None,
|
|
||||||
redirect_uri: format!("http://localhost:8080/auth/{provider}/callback"),
|
|
||||||
post_login_redirect: "/".into(),
|
|
||||||
post_logout_redirect: "/".into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct TestPlatform {
|
|
||||||
pub platform: SharedPlatform,
|
|
||||||
pub session_store: SharedSessionStore,
|
|
||||||
pub stats: Arc<InMemoryStatsRepo>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TestPlatform {
|
|
||||||
pub fn router(&self) -> Router {
|
|
||||||
Router::new()
|
|
||||||
.merge(
|
|
||||||
tavolo_platform::routes::router(&["default".to_string()])
|
|
||||||
.with_state(self.platform.clone()),
|
|
||||||
)
|
|
||||||
.layer(axum::middleware::from_fn_with_state(
|
|
||||||
self.session_store.clone(),
|
|
||||||
session_middleware,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn make_platform(heartbeat_ms: u64) -> TestPlatform {
|
|
||||||
let mut registry = GameRegistry::new();
|
|
||||||
registry
|
|
||||||
.register(Arc::new(DummyEngine))
|
|
||||||
.expect("register dummy");
|
|
||||||
let registry = Arc::new(registry);
|
|
||||||
let game_store: SharedGameStore = Arc::new(InMemoryGameStore::new(registry.clone()));
|
|
||||||
let session_store: SharedSessionStore = Arc::new(InMemorySessionStore::new());
|
|
||||||
let stats = Arc::new(InMemoryStatsRepo::new());
|
|
||||||
let stats_repo: SharedStatsRepo = stats.clone();
|
|
||||||
let oidc =
|
|
||||||
Arc::new(OidcProviders::new(vec![test_oidc_config("default")]).expect("oidc providers"));
|
|
||||||
let scheduler = DeadlineScheduler::new(
|
|
||||||
game_store.clone(),
|
|
||||||
registry.clone(),
|
|
||||||
stats_repo.clone(),
|
|
||||||
heartbeat_ms,
|
|
||||||
20,
|
|
||||||
);
|
|
||||||
scheduler.start();
|
|
||||||
let platform = Arc::new(Platform {
|
|
||||||
registry,
|
|
||||||
game_store,
|
|
||||||
scheduler,
|
|
||||||
oidc,
|
|
||||||
stats: stats_repo,
|
|
||||||
});
|
|
||||||
TestPlatform {
|
|
||||||
platform,
|
|
||||||
session_store,
|
|
||||||
stats,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persist a logged-in session and return the `Cookie` header value for it.
|
|
||||||
pub async fn login_cookie(
|
|
||||||
store: &SharedSessionStore,
|
|
||||||
provider: &str,
|
|
||||||
sub: &str,
|
|
||||||
name: &str,
|
|
||||||
) -> String {
|
|
||||||
let id = format!("session-{sub}");
|
|
||||||
let mut data = Map::new();
|
|
||||||
data.insert(
|
|
||||||
format!("oidc:{provider}:user"),
|
|
||||||
json!({"sub": sub, "preferred_username": name}),
|
|
||||||
);
|
|
||||||
store
|
|
||||||
.save(&id, &data, Some(3600))
|
|
||||||
.await
|
|
||||||
.expect("save session");
|
|
||||||
format!("session_id={id}")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn call(router: &Router, request: Request<Body>) -> Response<axum::body::Body> {
|
|
||||||
router.clone().oneshot(request).await.expect("request")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn body_json(response: Response<Body>) -> Value {
|
|
||||||
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
||||||
.await
|
|
||||||
.expect("body");
|
|
||||||
serde_json::from_slice(&bytes).expect("json body")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get(uri: &str, cookie: Option<&str>) -> Request<Body> {
|
|
||||||
let mut builder = Request::builder().method("GET").uri(uri);
|
|
||||||
if let Some(cookie) = cookie {
|
|
||||||
builder = builder.header("cookie", cookie);
|
|
||||||
}
|
|
||||||
builder.body(Body::empty()).expect("request")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn post_json(uri: &str, cookie: Option<&str>, body: &Value) -> Request<Body> {
|
|
||||||
let mut builder = Request::builder()
|
|
||||||
.method("POST")
|
|
||||||
.uri(uri)
|
|
||||||
.header("content-type", "application/json");
|
|
||||||
if let Some(cookie) = cookie {
|
|
||||||
builder = builder.header("cookie", cookie);
|
|
||||||
}
|
|
||||||
builder
|
|
||||||
.body(Body::from(serde_json::to_vec(body).expect("json")))
|
|
||||||
.expect("request")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn read_text(
|
|
||||||
mut socket: tokio_tungstenite::WebSocketStream<
|
|
||||||
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
|
||||||
>,
|
|
||||||
) -> String {
|
|
||||||
let message = socket
|
|
||||||
.next()
|
|
||||||
.await
|
|
||||||
.expect("some message")
|
|
||||||
.expect("ok message");
|
|
||||||
match message {
|
|
||||||
tokio_tungstenite::tungstenite::Message::Text(text) => text.to_string(),
|
|
||||||
other => panic!("expected text message, got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn read_json(
|
|
||||||
socket: &mut tokio_tungstenite::WebSocketStream<
|
|
||||||
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
|
||||||
>,
|
|
||||||
) -> Value {
|
|
||||||
let message = socket
|
|
||||||
.next()
|
|
||||||
.await
|
|
||||||
.expect("some message")
|
|
||||||
.expect("ok message");
|
|
||||||
match message {
|
|
||||||
tokio_tungstenite::tungstenite::Message::Text(text) => {
|
|
||||||
serde_json::from_str(&text).expect("json message")
|
|
||||||
}
|
|
||||||
other => panic!("expected text message, got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ws_request(addr: std::net::SocketAddr, game_id: &str, cookie: Option<&str>) -> Request<()> {
|
|
||||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
|
||||||
let url = format!("ws://{addr}/ws/games/{game_id}");
|
|
||||||
let mut request = url.into_client_request().expect("websocket client request");
|
|
||||||
if let Some(cookie) = cookie {
|
|
||||||
request.headers_mut().insert(
|
|
||||||
"cookie",
|
|
||||||
axum::http::HeaderValue::from_str(cookie).expect("cookie header"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
request
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn start_server(router: Router) -> std::net::SocketAddr {
|
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
||||||
.await
|
|
||||||
.expect("bind");
|
|
||||||
let addr = listener.local_addr().expect("addr");
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let _ = axum::serve(listener, router).await;
|
|
||||||
});
|
|
||||||
addr
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn json_status(response: &Response<Body>) -> StatusCode {
|
|
||||||
response.status()
|
|
||||||
}
|
|
||||||
@@ -1,410 +0,0 @@
|
|||||||
//! 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");
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
//! Postgres-backed stats tests, run only when `TEST_DATABASE_URL` points at a
|
|
||||||
//! throwaway Postgres database. The compose stack exposes one on
|
|
||||||
//! `postgres://tavolo:password@localhost:5432/tavolo`.
|
|
||||||
|
|
||||||
mod helpers;
|
|
||||||
|
|
||||||
use helpers::DummyEngine;
|
|
||||||
use serde_json::Map;
|
|
||||||
use sqlx::PgPool;
|
|
||||||
use tavolo_platform::engine::{GameEngine, GameSession, Seat};
|
|
||||||
use tavolo_platform::stats::{PostgresStatsRepo, StatsRepo};
|
|
||||||
|
|
||||||
async fn pool() -> Option<PgPool> {
|
|
||||||
let url = std::env::var("TEST_DATABASE_URL")
|
|
||||||
.ok()
|
|
||||||
.filter(|url| !url.is_empty())?;
|
|
||||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
|
||||||
.max_connections(4)
|
|
||||||
.connect(&url)
|
|
||||||
.await
|
|
||||||
.expect("connect");
|
|
||||||
sqlx::raw_sql(include_str!("../../tavolo-app/migrations/0001_init.sql"))
|
|
||||||
.execute(&pool)
|
|
||||||
.await
|
|
||||||
.expect("schema");
|
|
||||||
// Each test starts from a clean slate; other sessions may hold rows, so
|
|
||||||
// only this test's users are removed.
|
|
||||||
sqlx::query("DELETE FROM match_player WHERE user_sub IN ('alice', 'bob', 'carol', 'dave')")
|
|
||||||
.execute(&pool)
|
|
||||||
.await
|
|
||||||
.expect("clean players");
|
|
||||||
sqlx::query("DELETE FROM player_rating WHERE user_sub IN ('alice', 'bob', 'carol', 'dave')")
|
|
||||||
.execute(&pool)
|
|
||||||
.await
|
|
||||||
.expect("clean ratings");
|
|
||||||
sqlx::query(
|
|
||||||
"DELETE FROM \"match\" WHERE id NOT IN (SELECT DISTINCT match_id FROM match_player)",
|
|
||||||
)
|
|
||||||
.execute(&pool)
|
|
||||||
.await
|
|
||||||
.expect("clean matches");
|
|
||||||
Some(pool)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn finished_game(id: &str, winner_plays: &str) -> GameSession {
|
|
||||||
let engine = DummyEngine;
|
|
||||||
let mut game = GameSession::new(id.to_string(), engine.id(), "ABCDEF");
|
|
||||||
game.creator_sub = "alice".into();
|
|
||||||
game.players = vec![Seat::new("alice", "Alice"), Seat::new("bob", "Bob")];
|
|
||||||
game.players[0].team = Some("A".into());
|
|
||||||
game.players[1].team = Some("B".into());
|
|
||||||
game.created_at = Some(tavolo_platform::datetime::now() - chrono::Duration::minutes(5));
|
|
||||||
let mut options = Map::new();
|
|
||||||
options.insert("target".into(), serde_json::json!(1));
|
|
||||||
engine
|
|
||||||
.create(&mut game, &options)
|
|
||||||
.expect("create dummy state");
|
|
||||||
{
|
|
||||||
let state = game
|
|
||||||
.state
|
|
||||||
.as_mut()
|
|
||||||
.and_then(|state| state.downcast_mut::<helpers::DummyState>())
|
|
||||||
.expect("dummy state");
|
|
||||||
state.started = true;
|
|
||||||
}
|
|
||||||
engine
|
|
||||||
.handle_action(&mut game, winner_plays, "play", &Map::new())
|
|
||||||
.expect("winning play");
|
|
||||||
game.finished_at = Some(tavolo_platform::datetime::now());
|
|
||||||
game
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn postgres_persists_matches_history_ratings_and_leaderboard() {
|
|
||||||
let Some(pool) = pool().await else {
|
|
||||||
eprintln!("TEST_DATABASE_URL unset: skipping");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let repo = PostgresStatsRepo::new(pool.clone());
|
|
||||||
let engine = DummyEngine;
|
|
||||||
|
|
||||||
let game = finished_game("00000000-0000-0000-0000-000000000001", "bob");
|
|
||||||
assert!(engine.is_finished(&game));
|
|
||||||
repo.save_match(&game, &engine).await.expect("save match");
|
|
||||||
|
|
||||||
// History for the winner includes the result and the Elo delta.
|
|
||||||
let (matches, cursor) = repo
|
|
||||||
.my_matches("bob", None, 20, None)
|
|
||||||
.await
|
|
||||||
.expect("history");
|
|
||||||
assert_eq!(matches.len(), 1);
|
|
||||||
assert!(cursor.is_none());
|
|
||||||
assert_eq!(matches[0].result["winner"], "bob");
|
|
||||||
assert!(matches[0].you_won);
|
|
||||||
assert_eq!(matches[0].your_elo_delta, Some(16));
|
|
||||||
assert_eq!(matches[0].players.len(), 2);
|
|
||||||
|
|
||||||
// Ratings: 1500 + 16 for bob, 1500 - 16 for alice.
|
|
||||||
let ratings = repo.my_ratings("bob").await.expect("ratings");
|
|
||||||
assert_eq!(ratings.len(), 1);
|
|
||||||
assert_eq!(ratings[0].rating, 1516);
|
|
||||||
assert_eq!(ratings[0].matches_played, 1);
|
|
||||||
let alice_ratings = repo.my_ratings("alice").await.expect("ratings");
|
|
||||||
assert_eq!(alice_ratings[0].rating, 1484);
|
|
||||||
|
|
||||||
// Leaderboard: winner first.
|
|
||||||
let leaderboard = repo.leaderboard(None, "dummy").await.expect("leaderboard");
|
|
||||||
let bob = leaderboard
|
|
||||||
.iter()
|
|
||||||
.find(|entry| entry.user_sub == "bob")
|
|
||||||
.expect("bob ranked");
|
|
||||||
let alice = leaderboard
|
|
||||||
.iter()
|
|
||||||
.find(|entry| entry.user_sub == "alice")
|
|
||||||
.expect("alice ranked");
|
|
||||||
assert_eq!(bob.elo, 1516);
|
|
||||||
assert_eq!(bob.wins, 1);
|
|
||||||
assert_eq!(alice.elo, 1484);
|
|
||||||
assert_eq!(leaderboard[0].user_sub, "bob");
|
|
||||||
|
|
||||||
// Backfill replays the same match and reproduces the deltas.
|
|
||||||
let replayed = repo.backfill_elo().await.expect("backfill");
|
|
||||||
assert!(replayed >= 1);
|
|
||||||
let ratings = repo.my_ratings("bob").await.expect("ratings");
|
|
||||||
assert_eq!(ratings[0].rating, 1516);
|
|
||||||
|
|
||||||
// A second match loses to a fresh page cursor: page size 1 has more.
|
|
||||||
let game2 = finished_game("00000000-0000-0000-0000-000000000002", "alice");
|
|
||||||
repo.save_match(&game2, &engine)
|
|
||||||
.await
|
|
||||||
.expect("save match 2");
|
|
||||||
let (page, cursor) = repo.my_matches("alice", None, 1, None).await.expect("page");
|
|
||||||
assert_eq!(page.len(), 1);
|
|
||||||
assert!(cursor.is_some(), "expected a next cursor");
|
|
||||||
let cursor_map = cursor.as_deref().map(parse_cursor);
|
|
||||||
let (next, _) = repo
|
|
||||||
.my_matches("alice", None, 1, cursor_map.as_ref())
|
|
||||||
.await
|
|
||||||
.expect("next page");
|
|
||||||
assert_eq!(next.len(), 1);
|
|
||||||
assert_ne!(page[0].id, next[0].id);
|
|
||||||
|
|
||||||
// Game-type filters are applied.
|
|
||||||
let (filtered, _) = repo
|
|
||||||
.my_matches("alice", Some("dummy"), 20, None)
|
|
||||||
.await
|
|
||||||
.expect("filtered");
|
|
||||||
assert_eq!(filtered.len(), 2);
|
|
||||||
let (none, _) = repo
|
|
||||||
.my_matches("alice", Some("scopone_scientifico"), 20, None)
|
|
||||||
.await
|
|
||||||
.expect("filtered");
|
|
||||||
assert!(none.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_cursor(cursor: &str) -> serde_json::Map<String, serde_json::Value> {
|
|
||||||
tavolo_platform::pagination::decode_cursor(Some(cursor))
|
|
||||||
.expect("cursor")
|
|
||||||
.expect("some")
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
//! Redis-backed store tests, run only when `TEST_REDIS_URL` points at a
|
|
||||||
//! throwaway Redis instance. The compose stack exposes one on
|
|
||||||
//! `redis://127.0.0.1:6379/0`.
|
|
||||||
|
|
||||||
mod helpers;
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use helpers::DummyEngine;
|
|
||||||
use tavolo_platform::registry::GameRegistry;
|
|
||||||
use tavolo_platform::store::{GameStore, RedisGameStore};
|
|
||||||
|
|
||||||
fn redis_url() -> Option<String> {
|
|
||||||
std::env::var("TEST_REDIS_URL")
|
|
||||||
.ok()
|
|
||||||
.filter(|url| !url.is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn store(ttl: i64) -> Option<RedisGameStore> {
|
|
||||||
let url = redis_url()?;
|
|
||||||
let mut registry = GameRegistry::new();
|
|
||||||
registry.register(Arc::new(DummyEngine)).expect("register");
|
|
||||||
Some(
|
|
||||||
RedisGameStore::new(&url, Arc::new(registry), ttl)
|
|
||||||
.await
|
|
||||||
.expect("redis store"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_game(store: &RedisGameStore) -> tavolo_platform::GameSession {
|
|
||||||
use tavolo_platform::engine::{GameEngine, GameSession, Seat};
|
|
||||||
let engine = DummyEngine;
|
|
||||||
let unique = uuid::Uuid::new_v4().to_string();
|
|
||||||
let mut game = GameSession::new(unique.clone(), engine.id(), unique[..6].to_uppercase());
|
|
||||||
game.creator_sub = "alice".into();
|
|
||||||
game.players = vec![Seat::new("alice", "Alice")];
|
|
||||||
game.created_at = Some(tavolo_platform::datetime::now());
|
|
||||||
engine
|
|
||||||
.create(&mut game, &serde_json::Map::new())
|
|
||||||
.expect("create");
|
|
||||||
store.save(&game).await.expect("save");
|
|
||||||
game
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn redis_round_trips_sessions_codes_and_ongoing_counts() {
|
|
||||||
let Some(store) = store(60).await else {
|
|
||||||
eprintln!("TEST_REDIS_URL unset: skipping");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let game = create_game(&store).await;
|
|
||||||
|
|
||||||
let loaded = store.load(&game.id).await.expect("load").expect("found");
|
|
||||||
assert_eq!(loaded.join_code, game.join_code);
|
|
||||||
assert!(loaded.seated("alice"));
|
|
||||||
let by_code = store
|
|
||||||
.find_by_code(&game.join_code.to_lowercase())
|
|
||||||
.await
|
|
||||||
.expect("find")
|
|
||||||
.expect("found");
|
|
||||||
assert_eq!(by_code.id, game.id);
|
|
||||||
assert!(store.count_ongoing().await.expect("count") >= 1);
|
|
||||||
|
|
||||||
// Save with TTL still lists the game; a second save does not duplicate.
|
|
||||||
store.save(&loaded).await.expect("save again");
|
|
||||||
assert!(store.count_ongoing().await.expect("count") >= 1);
|
|
||||||
|
|
||||||
store
|
|
||||||
.remove_deadline(&format!("missing-{}", game.id))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
store
|
|
||||||
.remove_deadline(&serde_json::json!({}).to_string())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn redis_locks_are_exclusive_and_released() {
|
|
||||||
let Some(store) = store(60).await else {
|
|
||||||
eprintln!("TEST_REDIS_URL unset: skipping");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let game = create_game(&store).await;
|
|
||||||
let first = store.acquire_lock(&game.id).await.expect("first lock");
|
|
||||||
let second_store = store_second().await.expect("second store");
|
|
||||||
let acquired = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
||||||
let acquired_clone = acquired.clone();
|
|
||||||
let game_id = game.id.clone();
|
|
||||||
let waiter = tokio::spawn(async move {
|
|
||||||
let _lock = second_store
|
|
||||||
.acquire_lock(&game_id)
|
|
||||||
.await
|
|
||||||
.expect("second lock");
|
|
||||||
acquired_clone.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
||||||
});
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
|
||||||
assert!(!acquired.load(std::sync::atomic::Ordering::SeqCst));
|
|
||||||
first.release().await.expect("release");
|
|
||||||
tokio::time::timeout(std::time::Duration::from_secs(3), waiter)
|
|
||||||
.await
|
|
||||||
.expect("waiter acquired")
|
|
||||||
.expect("waiter task");
|
|
||||||
assert!(acquired.load(std::sync::atomic::Ordering::SeqCst));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn redis_pubsub_signals_and_deadline_queue() {
|
|
||||||
let Some(store) = store(60).await else {
|
|
||||||
eprintln!("TEST_REDIS_URL unset: skipping");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
use futures_util::StreamExt;
|
|
||||||
|
|
||||||
let game = create_game(&store).await;
|
|
||||||
let mut events = store.subscribe(&game.id).await.expect("subscribe");
|
|
||||||
// The subscription registers asynchronously; a short delay avoids racing
|
|
||||||
// the PUBLISH with the SUBSCRIBE.
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
|
|
||||||
store.publish(&game.id).await.expect("publish");
|
|
||||||
let signalled = tokio::time::timeout(std::time::Duration::from_secs(3), events.next()).await;
|
|
||||||
assert!(signalled.is_ok(), "no change signal received");
|
|
||||||
|
|
||||||
let member = format!("deadline-{}", uuid::Uuid::new_v4());
|
|
||||||
store.add_deadline(&member, 0.0).await.expect("add");
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
|
||||||
let due = store
|
|
||||||
.due_deadlines(tavolo_platform::datetime::now().timestamp() as f64, 10)
|
|
||||||
.await
|
|
||||||
.expect("due");
|
|
||||||
assert!(due.contains(&member), "{due:?}");
|
|
||||||
assert!(store.next_deadline().await.expect("next").is_some());
|
|
||||||
store.remove_deadline(&member).await.expect("remove");
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn store_second() -> Option<RedisGameStore> {
|
|
||||||
store(60).await
|
|
||||||
}
|
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
//! 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()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
[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 }
|
|
||||||
@@ -1,791 +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) [`ScoponeState`] and returns a
|
|
||||||
//! [`GameError`] on rule violations. This makes the whole rule set
|
|
||||||
//! unit-testable without a store or transport. The platform-facing adapter is
|
|
||||||
//! [`crate::plugin::ScoponeEngine`].
|
|
||||||
//!
|
|
||||||
//! Rules implemented
|
|
||||||
//! -----------------
|
|
||||||
//! * 40-card Italian deck (4 suits x ranks 1-10), ten cards per player, empty
|
|
||||||
//! table at the start of every hand.
|
|
||||||
//! * A card captures either a **single card of equal rank** or a
|
|
||||||
//! **combination of cards whose ranks sum to its own**. When an equal-ranked
|
|
||||||
//! card is on the table that capture is mandatory; the player may not take
|
|
||||||
//! an alternative combination instead.
|
|
||||||
//! * Emptying the table with a capture is a **scopa** (+1), except on the very
|
|
||||||
//! last play of a hand.
|
|
||||||
//! * At the end of a hand the remaining table cards go to the player who made
|
|
||||||
//! the last capture.
|
|
||||||
//! * Hand points: `carte` (most captured cards), `denara` (most diamond
|
|
||||||
//! cards), `settebello` (the 7 of diamonds), `primiera` (best
|
|
||||||
//! seven/five/four/three card of each suit, all four suits required), plus
|
|
||||||
//! one point per `scopa`. Ties on carte/denara/primiera award nothing.
|
|
||||||
//! * Optional `napola` rule (enabled by default): the longest run of
|
|
||||||
//! consecutive denari starting from the ace scores one point per card when
|
|
||||||
//! it reaches at least three cards (A-2-3 = 3, A-2-3-4 = 4, ...). A team
|
|
||||||
//! capturing the whole denari suit (ace to king) wins the match instantly.
|
|
||||||
//! * Optional `asso_pigliatutto` rule (disabled by default): an ace played
|
|
||||||
//! when no equal rank is on the table sweeps the whole table — or captures
|
|
||||||
//! itself, leaving the table empty, when the table is bare. The sweep never
|
|
||||||
//! scores a scopa, even when cards remain in hand, but it counts as a
|
|
||||||
//! capture for the last-taker rule. When another ace is on the table the
|
|
||||||
//! mandatory equal-rank capture still applies.
|
|
||||||
//! * The match ends when a team reaches the target score with a clear lead; a
|
|
||||||
//! tie at or above the target is broken by playing another hand.
|
|
||||||
|
|
||||||
use std::collections::HashSet;
|
|
||||||
|
|
||||||
use rand::seq::SliceRandom;
|
|
||||||
use rand::Rng;
|
|
||||||
use serde_json::{json, Map, Value};
|
|
||||||
use tavolo_platform::datetime::{now, to_iso};
|
|
||||||
use tavolo_platform::errors::{GameError, Result};
|
|
||||||
|
|
||||||
use crate::errors::card_not_in_hand;
|
|
||||||
use crate::state::{
|
|
||||||
parse_card, Card, Move, PlayerState, ScoponeState, PHASE_FINISHED, PHASE_HAND_END, PHASE_LOBBY,
|
|
||||||
PHASE_PLAYING, SUITS, TEAM_NAMES,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Number of cards dealt to each player at the start of a hand.
|
|
||||||
pub const HAND_SIZE: usize = 10;
|
|
||||||
pub const PLAYERS: usize = 4;
|
|
||||||
|
|
||||||
/// Default seconds the hand-end summary waits before dealing anyway. Games
|
|
||||||
/// carry their own copy in [`ScoponeState::hand_ack_timeout`].
|
|
||||||
pub const DEFAULT_HAND_ACK_TIMEOUT_SECONDS: i64 = 30;
|
|
||||||
|
|
||||||
/// Default seconds a player has to play before the server plays a random
|
|
||||||
/// legal card for them. Games carry their own copy in
|
|
||||||
/// [`ScoponeState::turn_timeout`].
|
|
||||||
pub const DEFAULT_TURN_TIMEOUT_SECONDS: i64 = 30;
|
|
||||||
|
|
||||||
/// Primiera card values: sevens are best, then sixes, then aces, then the
|
|
||||||
/// remaining ranks in descending order. All of 8/9/10 are worth 10.
|
|
||||||
fn primiera_value(rank: u32) -> i64 {
|
|
||||||
match rank {
|
|
||||||
7 => 21,
|
|
||||||
6 => 18,
|
|
||||||
1 => 16,
|
|
||||||
5 => 15,
|
|
||||||
4 => 14,
|
|
||||||
3 => 13,
|
|
||||||
2 => 12,
|
|
||||||
8..=10 => 10,
|
|
||||||
_ => 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the 40 cards of the Italian deck in canonical order.
|
|
||||||
pub fn full_deck() -> Vec<Card> {
|
|
||||||
let mut deck = Vec::with_capacity(40);
|
|
||||||
for &suit in SUITS.iter() {
|
|
||||||
for rank in 1..=10u32 {
|
|
||||||
deck.push(Card { rank, suit });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
deck
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return a shuffled deck. Pass `rng` for deterministic tests.
|
|
||||||
pub fn shuffled_deck<R: Rng + ?Sized>(rng: &mut R) -> Vec<Card> {
|
|
||||||
let mut deck = full_deck();
|
|
||||||
deck.shuffle(rng);
|
|
||||||
deck
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every `size`-element subset of `items`, in the same lexicographic order
|
|
||||||
/// `itertools.combinations` produces.
|
|
||||||
fn combinations(items: &[Card], size: usize) -> Vec<Vec<Card>> {
|
|
||||||
let mut result = Vec::new();
|
|
||||||
if size == 0 {
|
|
||||||
return vec![Vec::new()];
|
|
||||||
}
|
|
||||||
if size > items.len() {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
let mut indices: Vec<usize> = (0..size).collect();
|
|
||||||
loop {
|
|
||||||
result.push(indices.iter().map(|&i| items[i].clone()).collect());
|
|
||||||
// Advance to the next combination; stop once the last one is reached.
|
|
||||||
let mut i = size;
|
|
||||||
loop {
|
|
||||||
if i == 0 {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
i -= 1;
|
|
||||||
if indices[i] != i + items.len() - size {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
indices[i] += 1;
|
|
||||||
for j in (i + 1)..size {
|
|
||||||
indices[j] = indices[j - 1] + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return every legal capture (a list of card sets) for `card`.
|
|
||||||
///
|
|
||||||
/// If an equal-ranked card is on the table, only those single-card captures
|
|
||||||
/// are returned (the rule forbids taking a combination instead). Otherwise,
|
|
||||||
/// when `asso_pigliatutto` is enabled and `card` is an ace, the only capture
|
|
||||||
/// is the whole table — or an empty capture when the table is bare, the ace
|
|
||||||
/// taking itself. Without the option every subset of the table whose ranks sum
|
|
||||||
/// to `card.rank` is returned.
|
|
||||||
pub fn legal_captures(table: &[Card], card: Card, asso_pigliatutto: bool) -> Vec<Vec<Card>> {
|
|
||||||
let equal: Vec<Card> = table
|
|
||||||
.iter()
|
|
||||||
.filter(|c| c.rank == card.rank)
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
if !equal.is_empty() {
|
|
||||||
return equal.into_iter().map(|c| vec![c]).collect();
|
|
||||||
}
|
|
||||||
if asso_pigliatutto && card.rank == 1 {
|
|
||||||
return vec![table.to_vec()];
|
|
||||||
}
|
|
||||||
|
|
||||||
let candidates: Vec<Card> = table
|
|
||||||
.iter()
|
|
||||||
.filter(|c| c.rank <= card.rank)
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
let mut captures: Vec<Vec<Card>> = Vec::new();
|
|
||||||
// A sum-equal capture needs at least two cards (single non-equal cards
|
|
||||||
// cannot sum to the played card).
|
|
||||||
for size in 2..=candidates.len() {
|
|
||||||
for combo in combinations(&candidates, size) {
|
|
||||||
if combo.iter().map(|c| c.rank).sum::<u32>() == card.rank {
|
|
||||||
captures.push(combo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
captures
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a lobby game with the creator seated first.
|
|
||||||
pub fn create_game(
|
|
||||||
creator_sub: &str,
|
|
||||||
creator_name: &str,
|
|
||||||
target_score: i64,
|
|
||||||
hand_ack_timeout: i64,
|
|
||||||
turn_timeout: i64,
|
|
||||||
napola: bool,
|
|
||||||
asso_pigliatutto: bool,
|
|
||||||
) -> Result<ScoponeState> {
|
|
||||||
if !(1..=100).contains(&target_score) {
|
|
||||||
return Err(GameError::illegal_move("target_score must be between 1 and 100").into());
|
|
||||||
}
|
|
||||||
Ok(ScoponeState {
|
|
||||||
target_score,
|
|
||||||
napola,
|
|
||||||
asso_pigliatutto,
|
|
||||||
phase: PHASE_LOBBY.to_string(),
|
|
||||||
players: vec![PlayerState {
|
|
||||||
sub: creator_sub.to_string(),
|
|
||||||
name: creator_name.to_string(),
|
|
||||||
seat: 0,
|
|
||||||
hand: Vec::new(),
|
|
||||||
captured: Vec::new(),
|
|
||||||
scope: 0,
|
|
||||||
}],
|
|
||||||
hand_ack_timeout,
|
|
||||||
turn_timeout,
|
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Seat `sub` in the next free chair, starting the match when full.
|
|
||||||
pub fn join_game<R: Rng + ?Sized>(
|
|
||||||
state: &mut ScoponeState,
|
|
||||||
sub: &str,
|
|
||||||
name: &str,
|
|
||||||
rng: &mut R,
|
|
||||||
) -> Result<()> {
|
|
||||||
if state.phase != PHASE_LOBBY {
|
|
||||||
return Err(GameError::game_not_started("game has already started").into());
|
|
||||||
}
|
|
||||||
if state.seated(sub) {
|
|
||||||
return Err(GameError::already_joined("already joined this game").into());
|
|
||||||
}
|
|
||||||
if state.players.len() >= PLAYERS {
|
|
||||||
return Err(GameError::lobby_full("game is full").into());
|
|
||||||
}
|
|
||||||
let seat = state.players.len() as i64;
|
|
||||||
state.players.push(PlayerState {
|
|
||||||
sub: sub.to_string(),
|
|
||||||
name: name.to_string(),
|
|
||||||
seat,
|
|
||||||
hand: Vec::new(),
|
|
||||||
captured: Vec::new(),
|
|
||||||
scope: 0,
|
|
||||||
});
|
|
||||||
if state.players.len() == PLAYERS {
|
|
||||||
start_game(state, rng)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deal the first hand and switch the game to playing.
|
|
||||||
pub fn start_game<R: Rng + ?Sized>(state: &mut ScoponeState, rng: &mut R) -> Result<()> {
|
|
||||||
if state.players.len() != PLAYERS {
|
|
||||||
return Err(GameError::game_not_started("need exactly four players to start").into());
|
|
||||||
}
|
|
||||||
state.phase = PHASE_PLAYING.to_string();
|
|
||||||
deal_hand(state, rng);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Arm the auto-play deadline for whoever is on turn.
|
|
||||||
fn set_turn_deadline(state: &mut ScoponeState) {
|
|
||||||
let deadline = now() + chrono::Duration::seconds(state.turn_timeout);
|
|
||||||
state.turn_deadline = Some(to_iso(deadline));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn deal_hand<R: Rng + ?Sized>(state: &mut ScoponeState, rng: &mut R) {
|
|
||||||
let mut deck = shuffled_deck(rng);
|
|
||||||
for player in state.players.iter_mut() {
|
|
||||||
player.hand = Vec::new();
|
|
||||||
player.captured = Vec::new();
|
|
||||||
player.scope = 0;
|
|
||||||
}
|
|
||||||
state.table = Vec::new();
|
|
||||||
state.last_taker = None;
|
|
||||||
// Dealer rotates each hand; the first card is played by the player to the
|
|
||||||
// dealer's left.
|
|
||||||
state.turn = (state.dealer + 1) % PLAYERS as i64;
|
|
||||||
set_turn_deadline(state);
|
|
||||||
for _offset in 0..HAND_SIZE {
|
|
||||||
for seat_offset in 0..PLAYERS {
|
|
||||||
let seat = (state.dealer + 1 + seat_offset as i64) % PLAYERS as i64;
|
|
||||||
let index = player_index_at(state, seat).expect("seated player");
|
|
||||||
state.players[index]
|
|
||||||
.hand
|
|
||||||
.push(deck.pop().expect("deck has enough cards"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The index in `state.players` of the player in `seat`.
|
|
||||||
fn player_index_at(state: &ScoponeState, seat: i64) -> Result<usize> {
|
|
||||||
state
|
|
||||||
.players
|
|
||||||
.iter()
|
|
||||||
.position(|player| player.seat == seat)
|
|
||||||
.ok_or_else(|| GameError::illegal_move(format!("no player in seat {seat}")).into())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the option matching `requested` exactly, if any.
|
|
||||||
///
|
|
||||||
/// An empty request matches only an empty option — the asso pigliatutto
|
|
||||||
/// self-capture — so requesting nothing never satisfies a real capture.
|
|
||||||
fn match_option(options: &[Vec<Card>], requested: &[Card]) -> Option<Vec<Card>> {
|
|
||||||
let mut wanted: Vec<String> = requested.iter().map(Card::code).collect();
|
|
||||||
wanted.sort();
|
|
||||||
for option in options {
|
|
||||||
let mut have: Vec<String> = option.iter().map(Card::code).collect();
|
|
||||||
have.sort();
|
|
||||||
if have == wanted {
|
|
||||||
return Some(option.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply one move by the player identified by `sub`.
|
|
||||||
///
|
|
||||||
/// `capture_codes` selects which table cards to capture; it must be a legal
|
|
||||||
/// capture (see [`legal_captures`]) when one exists and empty otherwise.
|
|
||||||
/// Returns a [`GameError`] on any violation.
|
|
||||||
pub fn play(
|
|
||||||
state: &mut ScoponeState,
|
|
||||||
sub: &str,
|
|
||||||
card_code: &str,
|
|
||||||
capture_codes: Option<&[String]>,
|
|
||||||
) -> Result<()> {
|
|
||||||
if state.phase == PHASE_FINISHED {
|
|
||||||
return Err(GameError::game_finished("the match is over").into());
|
|
||||||
}
|
|
||||||
if state.phase == PHASE_HAND_END {
|
|
||||||
return Err(GameError::illegal_move(
|
|
||||||
"the hand is over; acknowledge the summary to continue",
|
|
||||||
)
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
if state.phase != PHASE_PLAYING {
|
|
||||||
return Err(GameError::game_not_started("the game has not started yet").into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let index = state
|
|
||||||
.players
|
|
||||||
.iter()
|
|
||||||
.position(|player| player.sub == sub)
|
|
||||||
.filter(|&i| state.players[i].seat == state.turn);
|
|
||||||
let index = match index {
|
|
||||||
Some(index) => index,
|
|
||||||
None => return Err(GameError::not_your_turn("it is not your turn").into()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let card = parse_card(card_code)?;
|
|
||||||
let hand_position = state.players[index].hand.iter().position(|c| c == &card);
|
|
||||||
let hand_position = match hand_position {
|
|
||||||
Some(position) => position,
|
|
||||||
None => return Err(card_not_in_hand(&card.code()).into()),
|
|
||||||
};
|
|
||||||
// Remove the card now so the scopa check below can tell whether this was
|
|
||||||
// the last play of the hand.
|
|
||||||
state.players[index].hand.remove(hand_position);
|
|
||||||
|
|
||||||
let mut requested: Vec<Card> = Vec::new();
|
|
||||||
if let Some(codes) = capture_codes {
|
|
||||||
for code in codes {
|
|
||||||
requested.push(parse_card(code)?);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let table_before = state.table.clone();
|
|
||||||
let options = legal_captures(&table_before, card.clone(), state.asso_pigliatutto);
|
|
||||||
// An asso pigliatutto capture: the ace takes the whole table because no
|
|
||||||
// equal rank was on it, or takes itself when the table is bare. Such a
|
|
||||||
// capture never scores a scopa.
|
|
||||||
let pigliatutto =
|
|
||||||
state.asso_pigliatutto && card.rank == 1 && table_before.iter().all(|c| c.rank != 1);
|
|
||||||
|
|
||||||
let mut taken: Vec<Card> = Vec::new();
|
|
||||||
let mut scopa = false;
|
|
||||||
if options.is_empty() {
|
|
||||||
if !requested.is_empty() {
|
|
||||||
return Err(GameError::illegal_move("no capture is possible with that card").into());
|
|
||||||
}
|
|
||||||
state.table.push(card.clone());
|
|
||||||
} else {
|
|
||||||
let chosen = match match_option(&options, &requested) {
|
|
||||||
Some(chosen) => chosen,
|
|
||||||
None => {
|
|
||||||
return Err(GameError::illegal_move("the requested capture is not legal").into())
|
|
||||||
}
|
|
||||||
};
|
|
||||||
for captured in &chosen {
|
|
||||||
if let Some(position) = state.table.iter().position(|c| c == captured) {
|
|
||||||
state.table.remove(position);
|
|
||||||
}
|
|
||||||
state.players[index].captured.push(captured.clone());
|
|
||||||
}
|
|
||||||
state.players[index].captured.push(card.clone());
|
|
||||||
state.last_taker = Some(state.players[index].seat);
|
|
||||||
// A scopa scores only if cards remain to be played this hand.
|
|
||||||
let hands_empty = state.players.iter().all(|player| player.hand.is_empty());
|
|
||||||
if state.table.is_empty() && !hands_empty && !pigliatutto {
|
|
||||||
state.players[index].scope += 1;
|
|
||||||
scopa = true;
|
|
||||||
}
|
|
||||||
taken = chosen;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.last_move = Some(Move {
|
|
||||||
seat: state.players[index].seat,
|
|
||||||
name: state.players[index].name.clone(),
|
|
||||||
card: card.code(),
|
|
||||||
captured: taken.iter().map(Card::code).collect(),
|
|
||||||
scopa,
|
|
||||||
pigliatutto,
|
|
||||||
});
|
|
||||||
|
|
||||||
if state.players.iter().all(|player| player.hand.is_empty()) {
|
|
||||||
end_hand(state)?;
|
|
||||||
} else {
|
|
||||||
state.turn = (state.turn + 1) % PLAYERS as i64;
|
|
||||||
set_turn_deadline(state);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Play a random legal move for the player currently on turn.
|
|
||||||
///
|
|
||||||
/// A card is drawn at random from that player's hand; if it can capture, one
|
|
||||||
/// of the legal captures is chosen at random (the rules require a capture when
|
|
||||||
/// one exists). Delegates to [`play`], so the move is fully validated and can
|
|
||||||
/// end the hand or the match. Pass `rng` for deterministic tests.
|
|
||||||
pub fn auto_play<R: Rng + ?Sized>(state: &mut ScoponeState, rng: &mut R) -> Result<()> {
|
|
||||||
if state.phase != PHASE_PLAYING {
|
|
||||||
return Err(GameError::game_not_started("the game has not started yet").into());
|
|
||||||
}
|
|
||||||
let index = player_index_at(state, state.turn)?;
|
|
||||||
if state.players[index].hand.is_empty() {
|
|
||||||
return Err(GameError::illegal_move("the player on turn has no cards").into());
|
|
||||||
}
|
|
||||||
let card = state.players[index]
|
|
||||||
.hand
|
|
||||||
.choose(rng)
|
|
||||||
.cloned()
|
|
||||||
.expect("hand is not empty");
|
|
||||||
let options = legal_captures(&state.table, card.clone(), state.asso_pigliatutto);
|
|
||||||
let capture: Option<Vec<Card>> = if options.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
options.choose(rng).cloned()
|
|
||||||
};
|
|
||||||
let sub = state.players[index].sub.clone();
|
|
||||||
let capture_codes: Option<Vec<String>> = capture
|
|
||||||
.as_ref()
|
|
||||||
.map(|cards| cards.iter().map(Card::code).collect());
|
|
||||||
play(state, &sub, &card.code(), capture_codes.as_deref())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sweep the table and score the hand.
|
|
||||||
///
|
|
||||||
/// If the match continues, the game pauses in the `hand_end` phase so every
|
|
||||||
/// player can read the scoring summary; the next hand is dealt by
|
|
||||||
/// [`acknowledge_hand`] once all four players have acknowledged (or by the
|
|
||||||
/// hand-end timeout fired by the platform's deadline scheduler). If the match
|
|
||||||
/// is over the game goes to `finished` immediately.
|
|
||||||
fn end_hand(state: &mut ScoponeState) -> Result<()> {
|
|
||||||
state.turn_deadline = None;
|
|
||||||
if !state.table.is_empty() {
|
|
||||||
if let Some(taker) = state.last_taker {
|
|
||||||
let index = player_index_at(state, taker)?;
|
|
||||||
let swept: Vec<Card> = std::mem::take(&mut state.table);
|
|
||||||
state.players[index].captured.extend(swept);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let (points, mut details) = hand_points(state);
|
|
||||||
for (score, awarded) in state.scores.iter_mut().zip(points.iter()) {
|
|
||||||
*score += awarded;
|
|
||||||
}
|
|
||||||
details.insert("hand".to_string(), json!(state.hand_number));
|
|
||||||
details.insert("team_a_points".to_string(), json!(points[0]));
|
|
||||||
details.insert("team_b_points".to_string(), json!(points[1]));
|
|
||||||
state.hand_scores.push(Value::Object(details.clone()));
|
|
||||||
|
|
||||||
// A full napola (the whole denari suit) wins the match outright,
|
|
||||||
// regardless of the score.
|
|
||||||
if let Some(Value::Object(napola)) = details.get("napola") {
|
|
||||||
for (team, name) in TEAM_NAMES.iter().enumerate() {
|
|
||||||
if napola.get(*name).and_then(Value::as_i64) == Some(10) {
|
|
||||||
state.phase = PHASE_FINISHED.to_string();
|
|
||||||
state.winner = Some(team as i64);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let a = state.scores[0];
|
|
||||||
let b = state.scores[1];
|
|
||||||
let reached = a.max(b) >= state.target_score;
|
|
||||||
if reached && a != b {
|
|
||||||
state.phase = PHASE_FINISHED.to_string();
|
|
||||||
state.winner = Some(if a > b { 0 } else { 1 });
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pause for the scoring summary instead of dealing immediately.
|
|
||||||
state.phase = PHASE_HAND_END.to_string();
|
|
||||||
state.acked = Vec::new();
|
|
||||||
let deadline = now() + chrono::Duration::seconds(state.hand_ack_timeout);
|
|
||||||
state.hand_end_deadline = Some(to_iso(deadline));
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record that `sub` has read the hand-end summary.
|
|
||||||
///
|
|
||||||
/// When all four players have acknowledged, the next hand is dealt.
|
|
||||||
/// Acknowledging twice is a no-op; acknowledging outside the `hand_end` phase
|
|
||||||
/// returns an error.
|
|
||||||
pub fn acknowledge_hand<R: Rng + ?Sized>(
|
|
||||||
state: &mut ScoponeState,
|
|
||||||
sub: &str,
|
|
||||||
rng: &mut R,
|
|
||||||
) -> Result<()> {
|
|
||||||
if state.phase != PHASE_HAND_END {
|
|
||||||
return Err(
|
|
||||||
GameError::illegal_move("no hand summary is waiting for acknowledgement").into(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let seat = match state.players.iter().find(|player| player.sub == sub) {
|
|
||||||
Some(player) => player.seat,
|
|
||||||
None => return Err(GameError::not_your_turn("you are not seated in this game").into()),
|
|
||||||
};
|
|
||||||
if state.acked.contains(&seat) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
state.acked.push(seat);
|
|
||||||
if state.acked.len() < PLAYERS {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
state.hand_number += 1;
|
|
||||||
state.dealer = (state.dealer + 1) % PLAYERS as i64;
|
|
||||||
state.acked = Vec::new();
|
|
||||||
state.hand_end_deadline = None;
|
|
||||||
state.last_move = None;
|
|
||||||
state.phase = PHASE_PLAYING.to_string();
|
|
||||||
deal_hand(state, rng);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the primiera value of a capture pile (0 if a suit is absent).
|
|
||||||
pub fn primiera_score(captured: &[Card]) -> i64 {
|
|
||||||
let mut best: [Option<i64>; 4] = [None; 4];
|
|
||||||
for card in captured {
|
|
||||||
let value = primiera_value(card.rank);
|
|
||||||
if let Some(index) = SUITS.iter().position(|&suit| suit == card.suit) {
|
|
||||||
if best[index].is_none_or(|current| value > current) {
|
|
||||||
best[index] = Some(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if best.iter().any(Option::is_none) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
best.iter().map(|value| value.unwrap_or(0)).sum()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the napola value of a capture pile.
|
|
||||||
///
|
|
||||||
/// The longest run of consecutive denari starting from the ace scores one
|
|
||||||
/// point per card once it reaches three cards (A-2-3 = 3, A-2-3-4 = 4, ...),
|
|
||||||
/// so the whole suit (ace to king) is worth 10. Shorter runs score nothing.
|
|
||||||
/// Only one team can score a napola: the ace of denari belongs to exactly one
|
|
||||||
/// capture pile.
|
|
||||||
pub fn napola_score(captured: &[Card]) -> i64 {
|
|
||||||
let ranks: HashSet<u32> = captured
|
|
||||||
.iter()
|
|
||||||
.filter(|card| card.suit == 'D')
|
|
||||||
.map(|card| card.rank)
|
|
||||||
.collect();
|
|
||||||
let mut run = 0u32;
|
|
||||||
while ranks.contains(&(run + 1)) {
|
|
||||||
run += 1;
|
|
||||||
}
|
|
||||||
if run >= 3 {
|
|
||||||
run as i64
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compute the hand points for both teams (index 0 = team A).
|
|
||||||
pub fn hand_points(state: &ScoponeState) -> (Vec<i64>, Map<String, Value>) {
|
|
||||||
let mut piles: [Vec<Card>; 2] = [Vec::new(), Vec::new()];
|
|
||||||
let mut scope: [i64; 2] = [0, 0];
|
|
||||||
for player in &state.players {
|
|
||||||
let team = player.team() as usize;
|
|
||||||
piles[team].extend(player.captured.iter().cloned());
|
|
||||||
scope[team] += player.scope;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut points = [0i64, 0i64];
|
|
||||||
let mut award: Map<String, Value> = Map::new();
|
|
||||||
|
|
||||||
// Carte: most captured cards. Ties award nothing.
|
|
||||||
let cards = [piles[0].len() as i64, piles[1].len() as i64];
|
|
||||||
if cards[0] != cards[1] {
|
|
||||||
let winner = if cards[0] > cards[1] { 0 } else { 1 };
|
|
||||||
points[winner] += 1;
|
|
||||||
award.insert(
|
|
||||||
"carte".to_string(),
|
|
||||||
Value::String(TEAM_NAMES[winner].to_string()),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
award.insert("carte".to_string(), Value::Null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Denara: most diamond cards. Ties award nothing.
|
|
||||||
let coins = [
|
|
||||||
piles[0].iter().filter(|c| c.suit == 'D').count() as i64,
|
|
||||||
piles[1].iter().filter(|c| c.suit == 'D').count() as i64,
|
|
||||||
];
|
|
||||||
if coins[0] != coins[1] {
|
|
||||||
let winner = if coins[0] > coins[1] { 0 } else { 1 };
|
|
||||||
points[winner] += 1;
|
|
||||||
award.insert(
|
|
||||||
"denara".to_string(),
|
|
||||||
Value::String(TEAM_NAMES[winner].to_string()),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
award.insert("denara".to_string(), Value::Null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settebello: the 7 of diamonds always belongs to someone.
|
|
||||||
let settebello = [
|
|
||||||
piles[0].iter().any(|c| c.rank == 7 && c.suit == 'D'),
|
|
||||||
piles[1].iter().any(|c| c.rank == 7 && c.suit == 'D'),
|
|
||||||
];
|
|
||||||
if settebello[0] != settebello[1] {
|
|
||||||
let winner = if settebello[0] { 0 } else { 1 };
|
|
||||||
points[winner] += 1;
|
|
||||||
award.insert(
|
|
||||||
"settebello".to_string(),
|
|
||||||
Value::String(TEAM_NAMES[winner].to_string()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Primiera: highest value, only if the team holds all four suits.
|
|
||||||
let primiera = [primiera_score(&piles[0]), primiera_score(&piles[1])];
|
|
||||||
if primiera[0] != primiera[1] {
|
|
||||||
let winner = if primiera[0] > primiera[1] { 0 } else { 1 };
|
|
||||||
points[winner] += 1;
|
|
||||||
award.insert(
|
|
||||||
"primiera".to_string(),
|
|
||||||
Value::String(TEAM_NAMES[winner].to_string()),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
award.insert("primiera".to_string(), Value::Null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scope: one point each.
|
|
||||||
points[0] += scope[0];
|
|
||||||
points[1] += scope[1];
|
|
||||||
|
|
||||||
// Napola (optional rule): consecutive denari from the ace. A run of 10
|
|
||||||
// means the team swept the whole suit and wins the match instantly.
|
|
||||||
let mut napola_details: Option<Value> = None;
|
|
||||||
if state.napola {
|
|
||||||
let napola = [napola_score(&piles[0]), napola_score(&piles[1])];
|
|
||||||
for team in 0..2 {
|
|
||||||
points[team] += napola[team];
|
|
||||||
}
|
|
||||||
let name = (0..2usize)
|
|
||||||
.find(|&team| napola[team] > 0)
|
|
||||||
.map(|team| Value::String(TEAM_NAMES[team].to_string()))
|
|
||||||
.unwrap_or(Value::Null);
|
|
||||||
award.insert("napola".to_string(), name);
|
|
||||||
napola_details = Some(json!({"A": napola[0], "B": napola[1]}));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut details: Map<String, Value> = Map::new();
|
|
||||||
details.insert("cards".to_string(), json!({"A": cards[0], "B": cards[1]}));
|
|
||||||
details.insert("denara".to_string(), json!({"A": coins[0], "B": coins[1]}));
|
|
||||||
details.insert(
|
|
||||||
"settebello".to_string(),
|
|
||||||
json!({"A": settebello[0], "B": settebello[1]}),
|
|
||||||
);
|
|
||||||
details.insert(
|
|
||||||
"primiera".to_string(),
|
|
||||||
json!({"A": primiera[0], "B": primiera[1]}),
|
|
||||||
);
|
|
||||||
details.insert("scope".to_string(), json!({"A": scope[0], "B": scope[1]}));
|
|
||||||
details.insert("award".to_string(), Value::Object(award));
|
|
||||||
if let Some(napola) = napola_details {
|
|
||||||
details.insert("napola".to_string(), napola);
|
|
||||||
}
|
|
||||||
(points.to_vec(), details)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn optional_string(value: &Option<String>) -> Value {
|
|
||||||
value
|
|
||||||
.as_ref()
|
|
||||||
.map(|text| Value::String(text.clone()))
|
|
||||||
.unwrap_or(Value::Null)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serialize `state` hiding other players' hands.
|
|
||||||
///
|
|
||||||
/// Hands are reduced to a count, except for the requesting player's own hand.
|
|
||||||
/// Non-seated viewers simply see no hand at all.
|
|
||||||
pub fn state_for_player(state: &ScoponeState, sub: &str) -> Value {
|
|
||||||
let viewer = state.player_for(sub);
|
|
||||||
let viewer_seat = viewer.map(|player| player.seat);
|
|
||||||
|
|
||||||
let mut players: Vec<Value> = Vec::new();
|
|
||||||
for player in &state.players {
|
|
||||||
let mut view: Map<String, Value> = Map::new();
|
|
||||||
view.insert("sub".to_string(), json!(player.sub));
|
|
||||||
view.insert("name".to_string(), json!(player.name));
|
|
||||||
view.insert("seat".to_string(), json!(player.seat));
|
|
||||||
view.insert(
|
|
||||||
"team".to_string(),
|
|
||||||
json!(TEAM_NAMES[player.team() as usize]),
|
|
||||||
);
|
|
||||||
view.insert("cards_left".to_string(), json!(player.hand.len()));
|
|
||||||
view.insert("captured_count".to_string(), json!(player.captured.len()));
|
|
||||||
view.insert("scope".to_string(), json!(player.scope));
|
|
||||||
if viewer_seat == Some(player.seat) {
|
|
||||||
view.insert(
|
|
||||||
"hand".to_string(),
|
|
||||||
Value::Array(player.hand.iter().map(Card::to_json).collect()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
players.push(Value::Object(view));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut payload: Map<String, Value> = Map::new();
|
|
||||||
payload.insert("phase".to_string(), json!(state.phase));
|
|
||||||
payload.insert("target_score".to_string(), json!(state.target_score));
|
|
||||||
payload.insert("napola".to_string(), json!(state.napola));
|
|
||||||
payload.insert(
|
|
||||||
"asso_pigliatutto".to_string(),
|
|
||||||
json!(state.asso_pigliatutto),
|
|
||||||
);
|
|
||||||
payload.insert("hand_number".to_string(), json!(state.hand_number));
|
|
||||||
payload.insert("dealer".to_string(), json!(state.dealer));
|
|
||||||
payload.insert("turn".to_string(), json!(state.turn));
|
|
||||||
payload.insert(
|
|
||||||
"scores".to_string(),
|
|
||||||
json!({"A": state.scores[0], "B": state.scores[1]}),
|
|
||||||
);
|
|
||||||
payload.insert(
|
|
||||||
"winner".to_string(),
|
|
||||||
match state.winner {
|
|
||||||
Some(winner) => json!(TEAM_NAMES[winner as usize]),
|
|
||||||
None => Value::Null,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
payload.insert(
|
|
||||||
"table".to_string(),
|
|
||||||
Value::Array(state.table.iter().map(Card::to_json).collect()),
|
|
||||||
);
|
|
||||||
payload.insert("players".to_string(), Value::Array(players));
|
|
||||||
payload.insert(
|
|
||||||
"last_hand".to_string(),
|
|
||||||
state.hand_scores.last().cloned().unwrap_or(Value::Null),
|
|
||||||
);
|
|
||||||
payload.insert(
|
|
||||||
"last_move".to_string(),
|
|
||||||
state
|
|
||||||
.last_move
|
|
||||||
.as_ref()
|
|
||||||
.map(Move::to_json)
|
|
||||||
.unwrap_or(Value::Null),
|
|
||||||
);
|
|
||||||
payload.insert("acknowledged".to_string(), json!(state.acked));
|
|
||||||
payload.insert(
|
|
||||||
"hand_end_deadline".to_string(),
|
|
||||||
optional_string(&state.hand_end_deadline),
|
|
||||||
);
|
|
||||||
payload.insert(
|
|
||||||
"turn_deadline".to_string(),
|
|
||||||
optional_string(&state.turn_deadline),
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(seat) = viewer_seat {
|
|
||||||
if state.phase == PHASE_PLAYING && seat == state.turn {
|
|
||||||
payload.insert("your_turn".to_string(), Value::Bool(true));
|
|
||||||
// Only the player on turn receives their legal captures, so all
|
|
||||||
// the rule logic stays server-side.
|
|
||||||
let mut legal_moves: Map<String, Value> = Map::new();
|
|
||||||
if let Some(viewer) = viewer {
|
|
||||||
for hand_card in &viewer.hand {
|
|
||||||
let options =
|
|
||||||
legal_captures(&state.table, hand_card.clone(), state.asso_pigliatutto);
|
|
||||||
if !options.is_empty() {
|
|
||||||
let list: Vec<Value> = options
|
|
||||||
.iter()
|
|
||||||
.map(|option| {
|
|
||||||
Value::Array(option.iter().map(|c| json!(c.code())).collect())
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
legal_moves.insert(hand_card.code(), Value::Array(list));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
payload.insert("legal_moves".to_string(), Value::Object(legal_moves));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Value::Object(payload)
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
//! 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"))
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
//! 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,
|
|
||||||
};
|
|
||||||
@@ -1,428 +0,0 @@
|
|||||||
//! The platform-facing adapter for scopone scientifico.
|
|
||||||
//!
|
|
||||||
//! [`ScoponeEngine`] implements [`GameEngine`] on top of the pure rules in
|
|
||||||
//! [`crate::engine`]: it translates platform calls (create, join, websocket
|
|
||||||
//! actions, deadlines) into rules-engine calls and back, keeping all scopone
|
|
||||||
//! knowledge inside this package. Nothing outside `tavolo_scopone` needs to
|
|
||||||
//! know about phases, turns or hand scoring.
|
|
||||||
|
|
||||||
use std::any::Any;
|
|
||||||
|
|
||||||
use rand::thread_rng;
|
|
||||||
use serde_json::{json, Map, Value};
|
|
||||||
use tavolo_platform::engine::{Deadline, GameEngine, GameSession, MatchResult, PlayerResult, Seat};
|
|
||||||
use tavolo_platform::errors::{GameError, Result};
|
|
||||||
|
|
||||||
use crate::engine::{self, DEFAULT_HAND_ACK_TIMEOUT_SECONDS, DEFAULT_TURN_TIMEOUT_SECONDS};
|
|
||||||
use crate::state::{
|
|
||||||
ScoponeState, DEFAULT_TARGET_SCORE, PHASE_FINISHED, PHASE_HAND_END, PHASE_LOBBY, PHASE_PLAYING,
|
|
||||||
TEAM_NAMES,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Downcast a session's opaque state to [`ScoponeState`].
|
|
||||||
fn state_ref(session: &GameSession) -> Result<&ScoponeState> {
|
|
||||||
session
|
|
||||||
.state
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|state| state.downcast_ref::<ScoponeState>())
|
|
||||||
.ok_or_else(|| GameError::illegal_move("missing scopone state").into())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Downcast a session's opaque state to [`ScoponeState`], mutably.
|
|
||||||
fn state_mut(session: &mut GameSession) -> Result<&mut ScoponeState> {
|
|
||||||
session
|
|
||||||
.state
|
|
||||||
.as_mut()
|
|
||||||
.and_then(|state| state.downcast_mut::<ScoponeState>())
|
|
||||||
.ok_or_else(|| GameError::illegal_move("missing scopone state").into())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Epoch milliseconds for an ISO-8601 deadline, `None` when absent or
|
|
||||||
/// unparseable.
|
|
||||||
fn deadline_ms(iso: Option<&str>) -> Option<i64> {
|
|
||||||
iso.and_then(tavolo_platform::datetime::from_iso)
|
|
||||||
.map(|dt| dt.timestamp_millis())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scopone scientifico as a platform game engine.
|
|
||||||
pub struct ScoponeEngine {
|
|
||||||
turn_timeout_seconds: i64,
|
|
||||||
hand_ack_timeout_seconds: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ScoponeEngine {
|
|
||||||
pub fn new(turn_timeout_seconds: i64, hand_ack_timeout_seconds: i64) -> Self {
|
|
||||||
Self {
|
|
||||||
turn_timeout_seconds,
|
|
||||||
hand_ack_timeout_seconds,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ScoponeEngine {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new(
|
|
||||||
DEFAULT_TURN_TIMEOUT_SECONDS,
|
|
||||||
DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GameEngine for ScoponeEngine {
|
|
||||||
fn id(&self) -> &'static str {
|
|
||||||
"scopone_scientifico"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"Scopone scientifico"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn description(&self) -> &'static str {
|
|
||||||
"Four players in fixed partnerships, ten cards each and an empty table. \
|
|
||||||
First team to the target score wins."
|
|
||||||
}
|
|
||||||
|
|
||||||
fn min_players(&self) -> usize {
|
|
||||||
4
|
|
||||||
}
|
|
||||||
|
|
||||||
fn max_players(&self) -> usize {
|
|
||||||
4
|
|
||||||
}
|
|
||||||
|
|
||||||
fn options_schema(&self) -> Value {
|
|
||||||
json!({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"target_score": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1,
|
|
||||||
"maximum": 100,
|
|
||||||
"default": DEFAULT_TARGET_SCORE,
|
|
||||||
"description": "Match points the winning team must reach.",
|
|
||||||
},
|
|
||||||
"napola": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": true,
|
|
||||||
"description": "Score the napola rule; a full denari sweep wins the match",
|
|
||||||
},
|
|
||||||
"asso_pigliatutto": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": false,
|
|
||||||
"description": "Score the asso pigliatutto rule; an ace sweeps the whole table (no scopa)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- lobby ------------------------------------------------------------
|
|
||||||
|
|
||||||
fn create(&self, session: &mut GameSession, options: &Map<String, Value>) -> Result<()> {
|
|
||||||
let target_score = match options.get("target_score") {
|
|
||||||
None => DEFAULT_TARGET_SCORE,
|
|
||||||
Some(Value::Bool(_)) => {
|
|
||||||
return Err(GameError::illegal_move("target_score must be an integer").into())
|
|
||||||
}
|
|
||||||
Some(value) => value
|
|
||||||
.as_i64()
|
|
||||||
.ok_or_else(|| GameError::illegal_move("target_score must be an integer"))?,
|
|
||||||
};
|
|
||||||
let napola = match options.get("napola") {
|
|
||||||
None => true,
|
|
||||||
Some(Value::Bool(value)) => *value,
|
|
||||||
Some(_) => return Err(GameError::illegal_move("napola must be a boolean").into()),
|
|
||||||
};
|
|
||||||
let asso_pigliatutto = match options.get("asso_pigliatutto") {
|
|
||||||
None => false,
|
|
||||||
Some(Value::Bool(value)) => *value,
|
|
||||||
Some(_) => {
|
|
||||||
return Err(GameError::illegal_move("asso_pigliatutto must be a boolean").into())
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let creator = session
|
|
||||||
.players
|
|
||||||
.first()
|
|
||||||
.ok_or_else(|| GameError::illegal_move("session has no creator seat"))?;
|
|
||||||
let creator_sub = creator.user_sub.clone();
|
|
||||||
let creator_name = creator.display_name.clone();
|
|
||||||
|
|
||||||
let state = engine::create_game(
|
|
||||||
&creator_sub,
|
|
||||||
&creator_name,
|
|
||||||
target_score,
|
|
||||||
self.hand_ack_timeout_seconds,
|
|
||||||
self.turn_timeout_seconds,
|
|
||||||
napola,
|
|
||||||
asso_pigliatutto,
|
|
||||||
)?;
|
|
||||||
session.state = Some(Box::new(state));
|
|
||||||
// The creator takes seat 0, i.e. team A.
|
|
||||||
if let Some(seat) = session.players.get_mut(0) {
|
|
||||||
seat.team = Some(TEAM_NAMES[0].to_string());
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn join(&self, session: &mut GameSession, user_sub: &str, display_name: &str) -> Result<()> {
|
|
||||||
let seat = session.players.len();
|
|
||||||
{
|
|
||||||
let state = state_mut(session)?;
|
|
||||||
engine::join_game(state, user_sub, display_name, &mut thread_rng())?;
|
|
||||||
}
|
|
||||||
// join_game raises before appending on any violation, so the seat list
|
|
||||||
// stays in sync with the engine's players.
|
|
||||||
session.players.push(Seat {
|
|
||||||
user_sub: user_sub.to_string(),
|
|
||||||
display_name: display_name.to_string(),
|
|
||||||
team: Some(TEAM_NAMES[seat % 2].to_string()),
|
|
||||||
});
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn in_lobby(&self, session: &GameSession) -> bool {
|
|
||||||
state_ref(session)
|
|
||||||
.map(|state| state.phase == PHASE_LOBBY)
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn lobby_view(&self, session: &GameSession) -> Value {
|
|
||||||
match state_ref(session) {
|
|
||||||
Ok(state) => json!({
|
|
||||||
"phase": state.phase,
|
|
||||||
"target_score": state.target_score,
|
|
||||||
"napola": state.napola,
|
|
||||||
"asso_pigliatutto": state.asso_pigliatutto,
|
|
||||||
}),
|
|
||||||
Err(_) => json!({}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- play -------------------------------------------------------------
|
|
||||||
|
|
||||||
fn handle_action(
|
|
||||||
&self,
|
|
||||||
session: &mut GameSession,
|
|
||||||
user_sub: &str,
|
|
||||||
action: &str,
|
|
||||||
payload: &Map<String, Value>,
|
|
||||||
) -> Result<()> {
|
|
||||||
match action {
|
|
||||||
"play" => {
|
|
||||||
let card = payload
|
|
||||||
.get("card")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.ok_or_else(|| GameError::illegal_move("'card' must be a card code string"))?;
|
|
||||||
let capture: Option<Vec<String>> = match payload.get("capture") {
|
|
||||||
None | Some(Value::Null) => None,
|
|
||||||
Some(Value::Array(items)) => {
|
|
||||||
let mut codes = Vec::with_capacity(items.len());
|
|
||||||
for item in items {
|
|
||||||
match item.as_str() {
|
|
||||||
Some(code) => codes.push(code.to_string()),
|
|
||||||
None => {
|
|
||||||
return Err(GameError::illegal_move(
|
|
||||||
"'capture' must be a list of card codes",
|
|
||||||
)
|
|
||||||
.into())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(codes)
|
|
||||||
}
|
|
||||||
Some(_) => {
|
|
||||||
return Err(GameError::illegal_move(
|
|
||||||
"'capture' must be a list of card codes",
|
|
||||||
)
|
|
||||||
.into())
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let state = state_mut(session)?;
|
|
||||||
engine::play(state, user_sub, card, capture.as_deref())
|
|
||||||
}
|
|
||||||
"ack" => {
|
|
||||||
let state = state_mut(session)?;
|
|
||||||
engine::acknowledge_hand(state, user_sub, &mut thread_rng())
|
|
||||||
}
|
|
||||||
_ => Err(GameError::illegal_move(format!(
|
|
||||||
"unknown action: {}",
|
|
||||||
tavolo_platform::http::py_repr(&serde_json::Value::String(action.to_string()))
|
|
||||||
))
|
|
||||||
.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view_for(&self, session: &GameSession, user_sub: &str) -> Value {
|
|
||||||
match state_ref(session) {
|
|
||||||
Ok(state) => engine::state_for_player(state, user_sub),
|
|
||||||
Err(_) => Value::Null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_finished(&self, session: &GameSession) -> bool {
|
|
||||||
state_ref(session)
|
|
||||||
.map(|state| state.phase == PHASE_FINISHED)
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn game_over_view(&self, session: &GameSession, _user_sub: &str) -> Value {
|
|
||||||
match state_ref(session) {
|
|
||||||
Ok(state) => json!({
|
|
||||||
"scores": {"A": state.scores[0], "B": state.scores[1]},
|
|
||||||
"winner": state.winner.map(|winner| TEAM_NAMES[winner as usize]),
|
|
||||||
}),
|
|
||||||
Err(_) => json!({}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- (de)serialization -------------------------------------------------
|
|
||||||
|
|
||||||
fn state_to_json(&self, state: &(dyn Any + Send + Sync)) -> Result<Value> {
|
|
||||||
state
|
|
||||||
.downcast_ref::<ScoponeState>()
|
|
||||||
.map(ScoponeState::to_json)
|
|
||||||
.ok_or_else(|| GameError::illegal_move("state is not a ScoponeState").into())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn state_from_json(&self, data: &Value) -> Result<Box<dyn Any + Send + Sync>> {
|
|
||||||
Ok(Box::new(ScoponeState::from_json(data)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- deadlines ----------------------------------------------------------
|
|
||||||
|
|
||||||
fn next_deadline(&self, session: &GameSession) -> Result<Option<Deadline>> {
|
|
||||||
let state = state_ref(session)?;
|
|
||||||
if state.phase == PHASE_PLAYING {
|
|
||||||
if let Some(iso) = state.turn_deadline.as_deref() {
|
|
||||||
let due_ms = match deadline_ms(Some(iso)) {
|
|
||||||
Some(due_ms) => due_ms,
|
|
||||||
None => return Ok(None),
|
|
||||||
};
|
|
||||||
return Ok(Some(Deadline {
|
|
||||||
kind: "turn".to_string(),
|
|
||||||
due_at: timestamp_millis(due_ms),
|
|
||||||
token: format!("turn:{}:{}:{}", state.hand_number, state.turn, due_ms),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if state.phase == PHASE_HAND_END {
|
|
||||||
if let Some(iso) = state.hand_end_deadline.as_deref() {
|
|
||||||
let due_ms = match deadline_ms(Some(iso)) {
|
|
||||||
Some(due_ms) => due_ms,
|
|
||||||
None => return Ok(None),
|
|
||||||
};
|
|
||||||
return Ok(Some(Deadline {
|
|
||||||
kind: "hand_end".to_string(),
|
|
||||||
due_at: timestamp_millis(due_ms),
|
|
||||||
token: format!("hand_end:{}:{}", state.hand_number, due_ms),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fire_deadline(&self, session: &mut GameSession, kind: &str, token: &str) -> Result<()> {
|
|
||||||
let current = self.next_deadline(session)?;
|
|
||||||
let stale = match ¤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<String> = state
|
|
||||||
.players
|
|
||||||
.iter()
|
|
||||||
.map(|player| player.sub.clone())
|
|
||||||
.collect();
|
|
||||||
let mut rng = thread_rng();
|
|
||||||
for sub in subs {
|
|
||||||
engine::acknowledge_hand(state, &sub, &mut rng)?;
|
|
||||||
}
|
|
||||||
tracing::info!(
|
|
||||||
hand = state.hand_number,
|
|
||||||
"hand auto-advanced after the acknowledgement timeout"
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
// next_deadline never emits other kinds.
|
|
||||||
_ => Err(GameError::illegal_move(format!("unknown deadline kind: {kind:?}")).into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- results -------------------------------------------------------------
|
|
||||||
|
|
||||||
fn result(&self, session: &GameSession) -> Result<MatchResult> {
|
|
||||||
let state = state_ref(session)?;
|
|
||||||
let winner = state
|
|
||||||
.winner
|
|
||||||
.ok_or_else(|| GameError::illegal_move("no result: the match is not finished"))?
|
|
||||||
as usize;
|
|
||||||
|
|
||||||
let teams = vec![
|
|
||||||
state
|
|
||||||
.players
|
|
||||||
.iter()
|
|
||||||
.filter(|player| player.team() == 0)
|
|
||||||
.map(|player| player.sub.clone())
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
state
|
|
||||||
.players
|
|
||||||
.iter()
|
|
||||||
.filter(|player| player.team() == 1)
|
|
||||||
.map(|player| player.sub.clone())
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
];
|
|
||||||
|
|
||||||
let players = state
|
|
||||||
.players
|
|
||||||
.iter()
|
|
||||||
.map(|player| {
|
|
||||||
let team = player.team() as usize;
|
|
||||||
PlayerResult {
|
|
||||||
user_sub: player.sub.clone(),
|
|
||||||
seat: player.seat,
|
|
||||||
won: team == winner,
|
|
||||||
team: Some(TEAM_NAMES[team].to_string()),
|
|
||||||
score: state.scores[team] as f64,
|
|
||||||
details: json!({"scope": player.scope}),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let summary = json!({
|
|
||||||
"team_a_score": state.scores[0],
|
|
||||||
"team_b_score": state.scores[1],
|
|
||||||
"winner_team": TEAM_NAMES[winner],
|
|
||||||
"target_score": state.target_score,
|
|
||||||
"hands_played": state.hand_number,
|
|
||||||
"hand_scores": state.hand_scores,
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(MatchResult::new(teams, winner, players, summary))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert epoch milliseconds back to a UTC timestamp, falling back to now
|
|
||||||
/// when the value is out of range (it never is for parsed deadlines).
|
|
||||||
fn timestamp_millis(millis: i64) -> chrono::DateTime<chrono::Utc> {
|
|
||||||
chrono::DateTime::<chrono::Utc>::from_timestamp_millis(millis)
|
|
||||||
.unwrap_or_else(tavolo_platform::datetime::now)
|
|
||||||
}
|
|
||||||
@@ -1,388 +0,0 @@
|
|||||||
//! In-memory representation of a scopone scientifico game.
|
|
||||||
//!
|
|
||||||
//! The whole mutable game lives in [`ScoponeState`], which is serialized to
|
|
||||||
//! and from plain JSON for storage inside the platform's session envelope.
|
|
||||||
//! Keeping the representation JSON-native means the store needs no custom
|
|
||||||
//! codecs and the state is inspectable.
|
|
||||||
//!
|
|
||||||
//! The state contains only game data: the platform owns the session envelope
|
|
||||||
//! (id, join code, seats, timestamps, stats persistence).
|
|
||||||
//!
|
|
||||||
//! Deck convention: a 40-card Italian deck. Suits are `D` (denari), `C`
|
|
||||||
//! (coppe), `S` (spade) and `B` (bastoni); ranks are `1`..`10`. A card is
|
|
||||||
//! rendered as `RRSUIT` (e.g. `07D` is the settebello).
|
|
||||||
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
use tavolo_platform::errors::{GameError, Result};
|
|
||||||
|
|
||||||
/// The four suits, in canonical order.
|
|
||||||
pub const SUITS: [char; 4] = ['D', 'C', 'S', 'B'];
|
|
||||||
|
|
||||||
/// Teams are derived from the seat: seats 0 and 2 form team A (index 0),
|
|
||||||
/// seats 1 and 3 form team B (index 1). Team pairs always sit opposite each
|
|
||||||
/// other, as in real scopone scientifico.
|
|
||||||
pub const TEAM_NAMES: [&str; 2] = ["A", "B"];
|
|
||||||
|
|
||||||
pub const PHASE_LOBBY: &str = "lobby";
|
|
||||||
pub const PHASE_PLAYING: &str = "playing";
|
|
||||||
/// Between hands of an unfinished match: scoring summary shown to every
|
|
||||||
/// player; the next hand is dealt once all four acknowledge (or the hand-end
|
|
||||||
/// timeout elapses).
|
|
||||||
pub const PHASE_HAND_END: &str = "hand_end";
|
|
||||||
pub const PHASE_FINISHED: &str = "finished";
|
|
||||||
|
|
||||||
pub const DEFAULT_TARGET_SCORE: i64 = 11;
|
|
||||||
|
|
||||||
/// The team index for a seat (0 or 1).
|
|
||||||
pub fn team_of(seat: i64) -> i64 {
|
|
||||||
seat.rem_euclid(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A single playing card.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
||||||
pub struct Card {
|
|
||||||
pub rank: u32,
|
|
||||||
pub suit: char,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Card {
|
|
||||||
/// Build a validated card, or `None` when the suit/rank is out of range.
|
|
||||||
pub fn new(rank: u32, suit: char) -> Option<Card> {
|
|
||||||
if SUITS.contains(&suit) && (1..=10).contains(&rank) {
|
|
||||||
Some(Card { rank, suit })
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `RRSUIT` code (e.g. `07D`).
|
|
||||||
pub fn code(&self) -> String {
|
|
||||||
format!("{:02}{}", self.rank, self.suit)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse a card code, uppercasing the input like Python's `Card.parse`.
|
|
||||||
pub fn parse(code: &str) -> Option<Card> {
|
|
||||||
let upper = code.to_ascii_uppercase();
|
|
||||||
let chars: Vec<char> = upper.chars().collect();
|
|
||||||
if chars.len() != 3 || !chars[0].is_ascii_digit() || !chars[1].is_ascii_digit() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let rank: u32 = format!("{}{}", chars[0], chars[1]).parse().ok()?;
|
|
||||||
Card::new(rank, chars[2])
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn to_json(&self) -> Value {
|
|
||||||
Value::String(self.code())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_json(value: &Value) -> Result<Card> {
|
|
||||||
value
|
|
||||||
.as_str()
|
|
||||||
.and_then(Card::parse)
|
|
||||||
.ok_or_else(|| GameError::illegal_move("invalid card code").into())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse a card code, raising an `"invalid card code"` error on malformed
|
|
||||||
/// input.
|
|
||||||
pub fn parse_card(code: &str) -> Result<Card> {
|
|
||||||
Card::parse(code).ok_or_else(|| GameError::illegal_move("invalid card code").into())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record of a single play, broadcast so every client can show who played
|
|
||||||
/// which card and what it captured.
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct Move {
|
|
||||||
pub seat: i64,
|
|
||||||
pub name: String,
|
|
||||||
pub card: String,
|
|
||||||
pub captured: Vec<String>,
|
|
||||||
pub scopa: bool,
|
|
||||||
/// The play was an asso pigliatutto capture (the whole table, or the ace
|
|
||||||
/// itself when the table was empty).
|
|
||||||
pub pigliatutto: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Move {
|
|
||||||
pub fn to_json(&self) -> Value {
|
|
||||||
json!({
|
|
||||||
"seat": self.seat,
|
|
||||||
"name": self.name,
|
|
||||||
"card": self.card,
|
|
||||||
"captured": self.captured,
|
|
||||||
"scopa": self.scopa,
|
|
||||||
"pigliatutto": self.pigliatutto,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_json(data: &Value) -> Move {
|
|
||||||
Move {
|
|
||||||
seat: data.get("seat").and_then(Value::as_i64).unwrap_or(0),
|
|
||||||
name: data
|
|
||||||
.get("name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string(),
|
|
||||||
card: data
|
|
||||||
.get("card")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string(),
|
|
||||||
captured: data
|
|
||||||
.get("captured")
|
|
||||||
.and_then(Value::as_array)
|
|
||||||
.map(|items| {
|
|
||||||
items
|
|
||||||
.iter()
|
|
||||||
.map(|c| c.as_str().unwrap_or_default().to_string())
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.unwrap_or_default(),
|
|
||||||
scopa: data.get("scopa").and_then(Value::as_bool).unwrap_or(false),
|
|
||||||
pigliatutto: data
|
|
||||||
.get("pigliatutto")
|
|
||||||
.and_then(Value::as_bool)
|
|
||||||
.unwrap_or(false),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One player's private state within a game.
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct PlayerState {
|
|
||||||
pub sub: String,
|
|
||||||
pub name: String,
|
|
||||||
pub seat: i64,
|
|
||||||
pub hand: Vec<Card>,
|
|
||||||
pub captured: Vec<Card>,
|
|
||||||
pub scope: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PlayerState {
|
|
||||||
pub fn team(&self) -> i64 {
|
|
||||||
team_of(self.seat)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn to_json(&self) -> Value {
|
|
||||||
json!({
|
|
||||||
"sub": self.sub,
|
|
||||||
"name": self.name,
|
|
||||||
"seat": self.seat,
|
|
||||||
"hand": self.hand.iter().map(Card::to_json).collect::<Vec<_>>(),
|
|
||||||
"captured": self.captured.iter().map(Card::to_json).collect::<Vec<_>>(),
|
|
||||||
"scope": self.scope,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_json(data: &Value) -> Result<PlayerState> {
|
|
||||||
let parse_cards = |value: Option<&Value>| -> Result<Vec<Card>> {
|
|
||||||
let items = value.and_then(Value::as_array).cloned().unwrap_or_default();
|
|
||||||
items.iter().map(Card::from_json).collect()
|
|
||||||
};
|
|
||||||
Ok(PlayerState {
|
|
||||||
sub: data
|
|
||||||
.get("sub")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string(),
|
|
||||||
name: data
|
|
||||||
.get("name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string(),
|
|
||||||
seat: data.get("seat").and_then(Value::as_i64).unwrap_or(0),
|
|
||||||
hand: parse_cards(data.get("hand"))?,
|
|
||||||
captured: parse_cards(data.get("captured"))?,
|
|
||||||
scope: data.get("scope").and_then(Value::as_i64).unwrap_or(0),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The whole mutable state of one scopone scientifico game.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ScoponeState {
|
|
||||||
pub target_score: i64,
|
|
||||||
/// Whether the napola rule is scored (denari run from the ace; a full suit
|
|
||||||
/// wins the match instantly). Default on.
|
|
||||||
pub napola: bool,
|
|
||||||
/// Whether the asso pigliatutto rule is scored (an ace sweeps the whole
|
|
||||||
/// table when no equal rank is on it, or captures itself when the table is
|
|
||||||
/// empty; the capture never scores a scopa). Default off, also for states
|
|
||||||
/// serialized before the option existed.
|
|
||||||
pub asso_pigliatutto: bool,
|
|
||||||
pub phase: String,
|
|
||||||
pub players: Vec<PlayerState>,
|
|
||||||
pub table: Vec<Card>,
|
|
||||||
pub dealer: i64,
|
|
||||||
pub turn: i64,
|
|
||||||
pub hand_number: i64,
|
|
||||||
pub scores: Vec<i64>,
|
|
||||||
pub winner: Option<i64>,
|
|
||||||
pub last_taker: Option<i64>,
|
|
||||||
/// Per-hand points awarded, for a compact audit trail in the API.
|
|
||||||
pub hand_scores: Vec<Value>,
|
|
||||||
/// The most recent play in the current hand, for move announcements.
|
|
||||||
pub last_move: Option<Move>,
|
|
||||||
/// While `phase == "hand_end"`: seats that acknowledged the summary, and
|
|
||||||
/// when the auto-continue timeout fires.
|
|
||||||
pub acked: Vec<i64>,
|
|
||||||
pub hand_end_deadline: Option<String>,
|
|
||||||
/// Seconds the hand-end summary waits before dealing anyway.
|
|
||||||
pub hand_ack_timeout: i64,
|
|
||||||
/// While `phase == "playing"`: when the server plays a random legal card
|
|
||||||
/// for the player on turn.
|
|
||||||
pub turn_deadline: Option<String>,
|
|
||||||
pub turn_timeout: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ScoponeState {
|
|
||||||
fn default() -> Self {
|
|
||||||
ScoponeState {
|
|
||||||
target_score: DEFAULT_TARGET_SCORE,
|
|
||||||
napola: true,
|
|
||||||
asso_pigliatutto: false,
|
|
||||||
phase: PHASE_LOBBY.to_string(),
|
|
||||||
players: Vec::new(),
|
|
||||||
table: Vec::new(),
|
|
||||||
dealer: 0,
|
|
||||||
turn: 0,
|
|
||||||
hand_number: 1,
|
|
||||||
scores: vec![0, 0],
|
|
||||||
winner: None,
|
|
||||||
last_taker: None,
|
|
||||||
hand_scores: Vec::new(),
|
|
||||||
last_move: None,
|
|
||||||
acked: Vec::new(),
|
|
||||||
hand_end_deadline: None,
|
|
||||||
hand_ack_timeout: 30,
|
|
||||||
turn_deadline: None,
|
|
||||||
turn_timeout: 30,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ScoponeState {
|
|
||||||
pub fn to_json(&self) -> Value {
|
|
||||||
json!({
|
|
||||||
"target_score": self.target_score,
|
|
||||||
"napola": self.napola,
|
|
||||||
"asso_pigliatutto": self.asso_pigliatutto,
|
|
||||||
"phase": self.phase,
|
|
||||||
"players": self.players.iter().map(PlayerState::to_json).collect::<Vec<_>>(),
|
|
||||||
"table": self.table.iter().map(Card::to_json).collect::<Vec<_>>(),
|
|
||||||
"dealer": self.dealer,
|
|
||||||
"turn": self.turn,
|
|
||||||
"hand_number": self.hand_number,
|
|
||||||
"scores": self.scores,
|
|
||||||
"winner": self.winner,
|
|
||||||
"last_taker": self.last_taker,
|
|
||||||
"hand_scores": self.hand_scores,
|
|
||||||
"last_move": self.last_move.as_ref().map(Move::to_json),
|
|
||||||
"acked": self.acked,
|
|
||||||
"hand_end_deadline": self.hand_end_deadline,
|
|
||||||
"hand_ack_timeout": self.hand_ack_timeout,
|
|
||||||
"turn_deadline": self.turn_deadline,
|
|
||||||
"turn_timeout": self.turn_timeout,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_json(data: &Value) -> Result<ScoponeState> {
|
|
||||||
let obj = data
|
|
||||||
.as_object()
|
|
||||||
.ok_or_else(|| GameError::illegal_move("state must be a JSON object"))?;
|
|
||||||
|
|
||||||
let parse_array = |value: Option<&Value>| -> Vec<Value> {
|
|
||||||
value.and_then(Value::as_array).cloned().unwrap_or_default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let players = parse_array(obj.get("players"))
|
|
||||||
.iter()
|
|
||||||
.map(PlayerState::from_json)
|
|
||||||
.collect::<Result<Vec<_>>>()?;
|
|
||||||
let table = parse_array(obj.get("table"))
|
|
||||||
.iter()
|
|
||||||
.map(Card::from_json)
|
|
||||||
.collect::<Result<Vec<_>>>()?;
|
|
||||||
|
|
||||||
let scores = obj
|
|
||||||
.get("scores")
|
|
||||||
.and_then(Value::as_array)
|
|
||||||
.map(|items| {
|
|
||||||
items
|
|
||||||
.iter()
|
|
||||||
.map(|x| x.as_i64().unwrap_or(0))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| vec![0, 0]);
|
|
||||||
|
|
||||||
let last_move = match obj.get("last_move") {
|
|
||||||
Some(Value::Object(map)) if !map.is_empty() => {
|
|
||||||
Some(Move::from_json(obj.get("last_move").unwrap()))
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(ScoponeState {
|
|
||||||
target_score: obj
|
|
||||||
.get("target_score")
|
|
||||||
.and_then(Value::as_i64)
|
|
||||||
.unwrap_or(DEFAULT_TARGET_SCORE),
|
|
||||||
napola: obj.get("napola").and_then(Value::as_bool).unwrap_or(true),
|
|
||||||
asso_pigliatutto: obj
|
|
||||||
.get("asso_pigliatutto")
|
|
||||||
.and_then(Value::as_bool)
|
|
||||||
.unwrap_or(false),
|
|
||||||
phase: obj
|
|
||||||
.get("phase")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or(PHASE_LOBBY)
|
|
||||||
.to_string(),
|
|
||||||
players,
|
|
||||||
table,
|
|
||||||
dealer: obj.get("dealer").and_then(Value::as_i64).unwrap_or(0),
|
|
||||||
turn: obj.get("turn").and_then(Value::as_i64).unwrap_or(0),
|
|
||||||
hand_number: obj.get("hand_number").and_then(Value::as_i64).unwrap_or(1),
|
|
||||||
scores,
|
|
||||||
winner: obj.get("winner").and_then(Value::as_i64),
|
|
||||||
last_taker: obj.get("last_taker").and_then(Value::as_i64),
|
|
||||||
hand_scores: parse_array(obj.get("hand_scores")),
|
|
||||||
last_move,
|
|
||||||
acked: obj
|
|
||||||
.get("acked")
|
|
||||||
.and_then(Value::as_array)
|
|
||||||
.map(|items| {
|
|
||||||
items
|
|
||||||
.iter()
|
|
||||||
.map(|x| x.as_i64().unwrap_or(0))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
})
|
|
||||||
.unwrap_or_default(),
|
|
||||||
hand_end_deadline: obj
|
|
||||||
.get("hand_end_deadline")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::to_string),
|
|
||||||
hand_ack_timeout: obj
|
|
||||||
.get("hand_ack_timeout")
|
|
||||||
.and_then(Value::as_i64)
|
|
||||||
.unwrap_or(30),
|
|
||||||
turn_deadline: obj
|
|
||||||
.get("turn_deadline")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::to_string),
|
|
||||||
turn_timeout: obj
|
|
||||||
.get("turn_timeout")
|
|
||||||
.and_then(Value::as_i64)
|
|
||||||
.unwrap_or(30),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- helpers ----------------------------------------------------------
|
|
||||||
|
|
||||||
pub fn player_for(&self, sub: &str) -> Option<&PlayerState> {
|
|
||||||
self.players.iter().find(|player| player.sub == sub)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn seated(&self, sub: &str) -> bool {
|
|
||||||
self.player_for(sub).is_some()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,483 +0,0 @@
|
|||||||
//! `ScoponeEngine` adapter tests: the platform contract over the pure rules.
|
|
||||||
//!
|
|
||||||
//! Ported from `tavolo-scopone/tests/test_plugin.py`.
|
|
||||||
|
|
||||||
use serde_json::{json, Map, Value};
|
|
||||||
use tavolo_platform::engine::GameSession;
|
|
||||||
use tavolo_platform::errors::Error;
|
|
||||||
use tavolo_platform::{GameEngine, Seat};
|
|
||||||
use tavolo_scopone::engine as rules;
|
|
||||||
use tavolo_scopone::state::{Card, ScoponeState, PHASE_FINISHED, PHASE_HAND_END, PHASE_PLAYING};
|
|
||||||
use tavolo_scopone::ScoponeEngine;
|
|
||||||
|
|
||||||
fn opts(value: Value) -> Map<String, Value> {
|
|
||||||
value.as_object().unwrap().clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn empty() -> Map<String, Value> {
|
|
||||||
Map::new()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn capitalize(name: &str) -> String {
|
|
||||||
let mut chars = name.chars();
|
|
||||||
match chars.next() {
|
|
||||||
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
|
||||||
None => String::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn base_session(engine: &ScoponeEngine) -> GameSession {
|
|
||||||
let mut session = GameSession::new("s1", engine.id(), "CODE01");
|
|
||||||
session.creator_sub = "alice".to_string();
|
|
||||||
session.players = vec![Seat {
|
|
||||||
user_sub: "alice".to_string(),
|
|
||||||
display_name: "Alice".to_string(),
|
|
||||||
team: None,
|
|
||||||
}];
|
|
||||||
session
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_session(engine: &ScoponeEngine, options: Map<String, Value>) -> Result<GameSession, Error> {
|
|
||||||
let mut session = base_session(engine);
|
|
||||||
engine.create(&mut session, &options)?;
|
|
||||||
Ok(session)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn started(engine: &ScoponeEngine, options: Map<String, Value>) -> GameSession {
|
|
||||||
let mut session = make_session(engine, options).unwrap();
|
|
||||||
for name in ["bob", "carol", "dave"] {
|
|
||||||
engine.join(&mut session, name, &capitalize(name)).unwrap();
|
|
||||||
}
|
|
||||||
session
|
|
||||||
}
|
|
||||||
|
|
||||||
fn state(session: &GameSession) -> &ScoponeState {
|
|
||||||
session
|
|
||||||
.state
|
|
||||||
.as_ref()
|
|
||||||
.unwrap()
|
|
||||||
.downcast_ref::<ScoponeState>()
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn state_mut(session: &mut GameSession) -> &mut ScoponeState {
|
|
||||||
session
|
|
||||||
.state
|
|
||||||
.as_mut()
|
|
||||||
.unwrap()
|
|
||||||
.downcast_mut::<ScoponeState>()
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn err_message(error: &Error) -> String {
|
|
||||||
error
|
|
||||||
.as_game()
|
|
||||||
.map(|game| game.message.clone())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract the error from a failed `make_session` without requiring
|
|
||||||
/// `GameSession: Debug` (which `unwrap_err` would).
|
|
||||||
fn session_error(result: Result<GameSession, Error>) -> Error {
|
|
||||||
match result {
|
|
||||||
Ok(_) => panic!("expected an error"),
|
|
||||||
Err(error) => error,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Play legal moves (and acknowledge hand ends) until the match finishes.
|
|
||||||
fn drive_to_finish(engine: &ScoponeEngine, session: &mut GameSession) -> usize {
|
|
||||||
let mut moves = 0;
|
|
||||||
while !engine.is_finished(session) && moves < 200_000 {
|
|
||||||
let (action, sub, payload): (&str, String, Map<String, Value>) = {
|
|
||||||
let st = state(session);
|
|
||||||
if st.phase == PHASE_HAND_END {
|
|
||||||
let player = st
|
|
||||||
.players
|
|
||||||
.iter()
|
|
||||||
.find(|player| !st.acked.contains(&player.seat))
|
|
||||||
.unwrap();
|
|
||||||
("ack", player.sub.clone(), Map::new())
|
|
||||||
} else {
|
|
||||||
let player = st
|
|
||||||
.players
|
|
||||||
.iter()
|
|
||||||
.find(|player| player.seat == st.turn)
|
|
||||||
.unwrap();
|
|
||||||
let played = player.hand[0].clone();
|
|
||||||
let options = rules::legal_captures(&st.table, played.clone(), st.asso_pigliatutto);
|
|
||||||
let mut payload = Map::new();
|
|
||||||
payload.insert("card".to_string(), json!(played.code()));
|
|
||||||
if let Some(option) = options.first() {
|
|
||||||
payload.insert(
|
|
||||||
"capture".to_string(),
|
|
||||||
json!(option.iter().map(Card::code).collect::<Vec<_>>()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
("play", player.sub.clone(), payload)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
engine
|
|
||||||
.handle_action(session, &sub, action, &payload)
|
|
||||||
.unwrap();
|
|
||||||
moves += 1;
|
|
||||||
}
|
|
||||||
moves
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- create ----------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn create_seats_creator_on_team_a() {
|
|
||||||
let engine = ScoponeEngine::default();
|
|
||||||
let session = make_session(&engine, empty()).unwrap();
|
|
||||||
assert_eq!(Some("A".to_string()), session.players[0].team);
|
|
||||||
let st = state(&session);
|
|
||||||
assert_eq!(11, st.target_score);
|
|
||||||
assert!(st.napola);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn create_options() {
|
|
||||||
let engine = ScoponeEngine::default();
|
|
||||||
let session =
|
|
||||||
make_session(&engine, opts(json!({"target_score": 16, "napola": false}))).unwrap();
|
|
||||||
let st = state(&session);
|
|
||||||
assert_eq!(16, st.target_score);
|
|
||||||
assert!(!st.napola);
|
|
||||||
assert!(!st.asso_pigliatutto);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn create_asso_pigliatutto_option() {
|
|
||||||
let engine = ScoponeEngine::default();
|
|
||||||
let session = make_session(&engine, opts(json!({"asso_pigliatutto": true}))).unwrap();
|
|
||||||
assert!(state(&session).asso_pigliatutto);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn create_rejects_bad_options() {
|
|
||||||
let engine = ScoponeEngine::default();
|
|
||||||
assert_eq!(
|
|
||||||
"target_score must be between 1 and 100",
|
|
||||||
err_message(&session_error(make_session(
|
|
||||||
&engine,
|
|
||||||
opts(json!({"target_score": 0}))
|
|
||||||
)))
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
"target_score must be an integer",
|
|
||||||
err_message(&session_error(make_session(
|
|
||||||
&engine,
|
|
||||||
opts(json!({"target_score": "eleven"}))
|
|
||||||
)))
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
"napola must be a boolean",
|
|
||||||
err_message(&session_error(make_session(
|
|
||||||
&engine,
|
|
||||||
opts(json!({"napola": "yes"}))
|
|
||||||
)))
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
"asso_pigliatutto must be a boolean",
|
|
||||||
err_message(&session_error(make_session(
|
|
||||||
&engine,
|
|
||||||
opts(json!({"asso_pigliatutto": "yes"}))
|
|
||||||
)))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn timeouts_come_from_the_engine() {
|
|
||||||
let engine = ScoponeEngine::new(7, 9);
|
|
||||||
let session = make_session(&engine, empty()).unwrap();
|
|
||||||
let st = state(&session);
|
|
||||||
assert_eq!(7, st.turn_timeout);
|
|
||||||
assert_eq!(9, st.hand_ack_timeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- join ------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn join_assigns_teams_and_starts() {
|
|
||||||
let engine = ScoponeEngine::default();
|
|
||||||
let mut session = make_session(&engine, empty()).unwrap();
|
|
||||||
assert!(engine.in_lobby(&session));
|
|
||||||
for (name, team) in [("bob", "B"), ("carol", "A"), ("dave", "B")] {
|
|
||||||
engine.join(&mut session, name, &capitalize(name)).unwrap();
|
|
||||||
assert_eq!(Some(team.to_string()), session.players.last().unwrap().team);
|
|
||||||
}
|
|
||||||
assert!(!engine.in_lobby(&session));
|
|
||||||
assert_eq!(PHASE_PLAYING, state(&session).phase);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn join_errors() {
|
|
||||||
let engine = ScoponeEngine::default();
|
|
||||||
let mut session = make_session(&engine, empty()).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
"already joined this game",
|
|
||||||
err_message(&engine.join(&mut session, "alice", "Alice").unwrap_err())
|
|
||||||
);
|
|
||||||
for name in ["bob", "carol", "dave"] {
|
|
||||||
engine.join(&mut session, name, &capitalize(name)).unwrap();
|
|
||||||
}
|
|
||||||
// The lobby filled up and the match started: late joins and even re-joins
|
|
||||||
// are rejected as "already started".
|
|
||||||
assert_eq!(
|
|
||||||
"game has already started",
|
|
||||||
err_message(&engine.join(&mut session, "erin", "Erin").unwrap_err())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
"game has already started",
|
|
||||||
err_message(&engine.join(&mut session, "alice", "Alice").unwrap_err())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- actions ---------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unknown_action_rejected() {
|
|
||||||
let engine = ScoponeEngine::default();
|
|
||||||
let mut session = started(&engine, empty());
|
|
||||||
assert!(engine
|
|
||||||
.handle_action(&mut session, "alice", "dance", &Map::new())
|
|
||||||
.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn play_validates_payload() {
|
|
||||||
let engine = ScoponeEngine::default();
|
|
||||||
let mut session = started(&engine, empty());
|
|
||||||
assert!(engine
|
|
||||||
.handle_action(&mut session, "bob", "play", &opts(json!({"card": 42})))
|
|
||||||
.is_err());
|
|
||||||
assert!(engine
|
|
||||||
.handle_action(&mut session, "bob", "play", &Map::new())
|
|
||||||
.is_err());
|
|
||||||
assert!(engine
|
|
||||||
.handle_action(
|
|
||||||
&mut session,
|
|
||||||
"bob",
|
|
||||||
"play",
|
|
||||||
&opts(json!({"card": "01D", "capture": "02C"}))
|
|
||||||
)
|
|
||||||
.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn invalid_card_code_is_illegal_move() {
|
|
||||||
let engine = ScoponeEngine::default();
|
|
||||||
let mut session = started(&engine, empty());
|
|
||||||
let error = engine
|
|
||||||
.handle_action(&mut session, "bob", "play", &opts(json!({"card": "nope"})))
|
|
||||||
.unwrap_err();
|
|
||||||
assert_eq!("invalid card code", err_message(&error));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn finished_match_rejects_actions() {
|
|
||||||
let engine = ScoponeEngine::default();
|
|
||||||
let mut session = started(&engine, opts(json!({"target_score": 1})));
|
|
||||||
drive_to_finish(&engine, &mut session);
|
|
||||||
assert!(engine.is_finished(&session));
|
|
||||||
let error = engine
|
|
||||||
.handle_action(&mut session, "alice", "play", &opts(json!({"card": "01D"})))
|
|
||||||
.unwrap_err();
|
|
||||||
assert_eq!("the match is over", err_message(&error));
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- views -----------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn view_for_hides_other_hands() {
|
|
||||||
let engine = ScoponeEngine::default();
|
|
||||||
let session = started(&engine, empty());
|
|
||||||
let view = engine.view_for(&session, "alice");
|
|
||||||
let players = view["players"].as_array().unwrap();
|
|
||||||
let alice = players.iter().find(|p| p["seat"] == 0).unwrap();
|
|
||||||
let bob = players.iter().find(|p| p["seat"] == 1).unwrap();
|
|
||||||
assert!(alice.get("hand").is_some());
|
|
||||||
assert!(bob.get("hand").is_none());
|
|
||||||
// The envelope is the platform's job, not the view's.
|
|
||||||
assert!(view.get("id").is_none());
|
|
||||||
assert!(view.get("join_code").is_none());
|
|
||||||
assert!(view.get("game_type").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lobby_view() {
|
|
||||||
let engine = ScoponeEngine::default();
|
|
||||||
let session = make_session(&engine, opts(json!({"target_score": 16}))).unwrap();
|
|
||||||
let lobby = engine.lobby_view(&session);
|
|
||||||
assert_eq!(json!("lobby"), lobby["phase"]);
|
|
||||||
assert_eq!(json!(16), lobby["target_score"]);
|
|
||||||
assert_eq!(json!(true), lobby["napola"]);
|
|
||||||
assert_eq!(json!(false), lobby["asso_pigliatutto"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- serialization ---------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn state_roundtrip() {
|
|
||||||
let engine = ScoponeEngine::default();
|
|
||||||
let session = started(&engine, empty());
|
|
||||||
let state_value = engine
|
|
||||||
.state_to_json(session.state.as_ref().unwrap().as_ref())
|
|
||||||
.unwrap();
|
|
||||||
let restored = engine.state_from_json(&state_value).unwrap();
|
|
||||||
let restored = restored.downcast_ref::<ScoponeState>().unwrap();
|
|
||||||
assert_eq!(state(&session).phase, restored.phase);
|
|
||||||
assert_eq!(state(&session).turn, restored.turn);
|
|
||||||
assert_eq!(
|
|
||||||
state(&session)
|
|
||||||
.players
|
|
||||||
.iter()
|
|
||||||
.map(|p| p.sub.clone())
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
restored
|
|
||||||
.players
|
|
||||||
.iter()
|
|
||||||
.map(|p| p.sub.clone())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- deadlines -------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_deadline_in_lobby() {
|
|
||||||
let engine = ScoponeEngine::default();
|
|
||||||
let session = make_session(&engine, empty()).unwrap();
|
|
||||||
assert!(engine.next_deadline(&session).unwrap().is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn turn_deadline_and_revalidation() {
|
|
||||||
let engine = ScoponeEngine::default();
|
|
||||||
let mut session = started(&engine, empty());
|
|
||||||
let deadline = engine.next_deadline(&session).unwrap().unwrap();
|
|
||||||
assert_eq!("turn", deadline.kind);
|
|
||||||
// A forged token is stale.
|
|
||||||
assert!(engine
|
|
||||||
.fire_deadline(&mut session, "turn", "turn:1:1:0")
|
|
||||||
.is_err());
|
|
||||||
let turn_before = state(&session).turn;
|
|
||||||
engine
|
|
||||||
.fire_deadline(&mut session, &deadline.kind, &deadline.token)
|
|
||||||
.unwrap();
|
|
||||||
assert_ne!(turn_before, state(&session).turn);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn stale_deadline_after_play() {
|
|
||||||
let engine = ScoponeEngine::default();
|
|
||||||
let mut session = started(&engine, empty());
|
|
||||||
let deadline = engine.next_deadline(&session).unwrap().unwrap();
|
|
||||||
// A play lands in time: the armed deadline is overtaken.
|
|
||||||
let (sub, payload) = {
|
|
||||||
let st = state(&session);
|
|
||||||
let player = st.players.iter().find(|p| p.seat == st.turn).unwrap();
|
|
||||||
let played = player.hand[0].clone();
|
|
||||||
let options = rules::legal_captures(&st.table, played.clone(), st.asso_pigliatutto);
|
|
||||||
let mut payload = Map::new();
|
|
||||||
payload.insert("card".to_string(), json!(played.code()));
|
|
||||||
if let Some(option) = options.first() {
|
|
||||||
payload.insert(
|
|
||||||
"capture".to_string(),
|
|
||||||
json!(option.iter().map(Card::code).collect::<Vec<_>>()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
(player.sub.clone(), payload)
|
|
||||||
};
|
|
||||||
engine
|
|
||||||
.handle_action(&mut session, &sub, "play", &payload)
|
|
||||||
.unwrap();
|
|
||||||
assert!(engine
|
|
||||||
.fire_deadline(&mut session, &deadline.kind, &deadline.token)
|
|
||||||
.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hand_end_deadline_advances() {
|
|
||||||
let engine = ScoponeEngine::default();
|
|
||||||
let mut session = started(&engine, empty());
|
|
||||||
{
|
|
||||||
let st = state_mut(&mut session);
|
|
||||||
st.phase = PHASE_HAND_END.to_string();
|
|
||||||
st.hand_end_deadline = Some(tavolo_platform::datetime::to_iso(
|
|
||||||
tavolo_platform::datetime::now() + chrono::Duration::seconds(60),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let deadline = engine.next_deadline(&session).unwrap().unwrap();
|
|
||||||
assert_eq!("hand_end", deadline.kind);
|
|
||||||
engine
|
|
||||||
.fire_deadline(&mut session, &deadline.kind, &deadline.token)
|
|
||||||
.unwrap();
|
|
||||||
let st = state(&session);
|
|
||||||
assert_eq!(PHASE_PLAYING, st.phase);
|
|
||||||
assert_eq!(2, st.hand_number);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- results ---------------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn result_of_finished_match() {
|
|
||||||
let engine = ScoponeEngine::default();
|
|
||||||
let mut session = started(&engine, opts(json!({"target_score": 1})));
|
|
||||||
drive_to_finish(&engine, &mut session);
|
|
||||||
let result = engine.result(&session).unwrap();
|
|
||||||
assert_eq!(2, result.teams.len());
|
|
||||||
assert!(result.winner_team == 0 || result.winner_team == 1);
|
|
||||||
assert_eq!(4, result.players.len());
|
|
||||||
let winner_name = if result.winner_team == 0 { "A" } else { "B" };
|
|
||||||
for player in &result.players {
|
|
||||||
assert_eq!(player.won, player.team.as_deref() == Some(winner_name));
|
|
||||||
}
|
|
||||||
assert!(result.summary.get("team_a_score").is_some());
|
|
||||||
assert!(result.summary.get("hands_played").is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn result_requires_finished_match() {
|
|
||||||
let engine = ScoponeEngine::default();
|
|
||||||
let session = started(&engine, empty());
|
|
||||||
assert!(engine.result(&session).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn game_over_view() {
|
|
||||||
let engine = ScoponeEngine::default();
|
|
||||||
let mut session = started(&engine, empty());
|
|
||||||
{
|
|
||||||
let st = state_mut(&mut session);
|
|
||||||
st.phase = PHASE_FINISHED.to_string();
|
|
||||||
st.winner = Some(1);
|
|
||||||
st.scores = vec![3, 11];
|
|
||||||
}
|
|
||||||
let over = engine.game_over_view(&session, "alice");
|
|
||||||
assert_eq!(json!({"A": 3, "B": 11}), over["scores"]);
|
|
||||||
assert_eq!(json!("B"), over["winner"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn registry_metadata() {
|
|
||||||
let engine = ScoponeEngine::default();
|
|
||||||
assert_eq!("scopone_scientifico", engine.id());
|
|
||||||
assert_eq!(4, engine.min_players());
|
|
||||||
assert_eq!(4, engine.max_players());
|
|
||||||
let schema = engine.options_schema();
|
|
||||||
assert!(schema["properties"]["target_score"].is_object());
|
|
||||||
assert!(schema["properties"]["napola"].is_object());
|
|
||||||
assert!(schema["properties"]["asso_pigliatutto"].is_object());
|
|
||||||
// Byte-compatible option schema: descriptions and defaults match Python.
|
|
||||||
assert_eq!(
|
|
||||||
json!("Score the napola rule; a full denari sweep wins the match"),
|
|
||||||
schema["properties"]["napola"]["description"]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
json!("Score the asso pigliatutto rule; an ace sweeps the whole table (no scopa)"),
|
|
||||||
schema["properties"]["asso_pigliatutto"]["description"]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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="
|
||||||
|
)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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/"
|
||||||
|
)
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "tavolo"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Multiplayer card-game platform backend built on the kaya framework"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"kaya-core",
|
||||||
|
"kaya-session",
|
||||||
|
"kaya-session-redis",
|
||||||
|
"kaya-oidc",
|
||||||
|
"kaya-openapi",
|
||||||
|
"kaya-rsgi",
|
||||||
|
"granian>=2.0",
|
||||||
|
"tortoise-orm",
|
||||||
|
"aerich",
|
||||||
|
"asyncpg",
|
||||||
|
"httpx",
|
||||||
|
"PyJWT[crypto]",
|
||||||
|
"pwo",
|
||||||
|
"PyYAML",
|
||||||
|
"redis",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"mypy",
|
||||||
|
"httpx-ws",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
namespaces = false
|
||||||
|
|
||||||
|
# Database migrations (aerich). 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 = []
|
||||||
|
|
||||||
|
# TortoiseORM auto-generates `<fk>_id` attributes on ForeignKeyField at
|
||||||
|
# runtime; without the (unavailable here) tortoise mypy plugin the stubs
|
||||||
|
# only declare the relation field. These are real attributes, not bugs.
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = "tavolo.models"
|
||||||
|
disable_error_code = ["attr-defined"]
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = "tavolo.routes.*"
|
||||||
|
disable_error_code = ["attr-defined"]
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = "tavolo.game.*"
|
||||||
|
disable_error_code = ["attr-defined"]
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
#
|
||||||
|
# This file is autogenerated by pip-compile with Python 3.14
|
||||||
|
# by the following command:
|
||||||
|
#
|
||||||
|
# pip-compile --allow-unsafe --extra-index-url=https://pypi.org/simple --index-url=https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --no-index --output-file=requirements.txt pyproject.toml
|
||||||
|
#
|
||||||
|
--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple
|
||||||
|
--extra-index-url https://pypi.org/simple
|
||||||
|
|
||||||
|
aerich==0.10.1
|
||||||
|
# via tavolo (pyproject.toml)
|
||||||
|
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 (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
|
||||||
|
granian==2.8.3
|
||||||
|
# via
|
||||||
|
# kaya-rsgi
|
||||||
|
# tavolo (pyproject.toml)
|
||||||
|
h11==0.16.0
|
||||||
|
# via httpcore
|
||||||
|
httpcore==1.0.9
|
||||||
|
# via httpx
|
||||||
|
httpx==0.28.1
|
||||||
|
# via
|
||||||
|
# kaya-oidc
|
||||||
|
# tavolo (pyproject.toml)
|
||||||
|
idna==3.19
|
||||||
|
# via
|
||||||
|
# anyio
|
||||||
|
# httpx
|
||||||
|
iso8601==2.1.0
|
||||||
|
# via tortoise-orm
|
||||||
|
kaya-core==0.0.3
|
||||||
|
# via
|
||||||
|
# kaya-oidc
|
||||||
|
# kaya-openapi
|
||||||
|
# kaya-rsgi
|
||||||
|
# kaya-session
|
||||||
|
# tavolo (pyproject.toml)
|
||||||
|
kaya-oidc==0.0.3
|
||||||
|
# via tavolo (pyproject.toml)
|
||||||
|
kaya-openapi==0.0.3
|
||||||
|
# via tavolo (pyproject.toml)
|
||||||
|
kaya-rsgi==0.0.3
|
||||||
|
# via tavolo (pyproject.toml)
|
||||||
|
kaya-session==0.0.3
|
||||||
|
# via
|
||||||
|
# kaya-oidc
|
||||||
|
# kaya-session-redis
|
||||||
|
# tavolo (pyproject.toml)
|
||||||
|
kaya-session-redis==0.0.3
|
||||||
|
# via tavolo (pyproject.toml)
|
||||||
|
pwo==0.1.2
|
||||||
|
# via
|
||||||
|
# kaya-core
|
||||||
|
# kaya-rsgi
|
||||||
|
# kaya-session
|
||||||
|
# tavolo (pyproject.toml)
|
||||||
|
pycparser==3.0
|
||||||
|
# via cffi
|
||||||
|
pyjwt[crypto]==2.14.0
|
||||||
|
# via
|
||||||
|
# kaya-oidc
|
||||||
|
# tavolo (pyproject.toml)
|
||||||
|
pypika-tortoise==0.6.5
|
||||||
|
# via tortoise-orm
|
||||||
|
pyyaml==6.0.3
|
||||||
|
# via tavolo (pyproject.toml)
|
||||||
|
redis==8.1.0
|
||||||
|
# via
|
||||||
|
# kaya-session-redis
|
||||||
|
# tavolo (pyproject.toml)
|
||||||
|
tortoise-orm==1.1.8
|
||||||
|
# via
|
||||||
|
# aerich
|
||||||
|
# tavolo (pyproject.toml)
|
||||||
|
typing-extensions==4.16.0
|
||||||
|
# via
|
||||||
|
# anyio
|
||||||
|
# kaya-core
|
||||||
|
# pwo
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Scopone scientifico backend built on the kaya framework."""
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""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.models", "aerich.models"],
|
||||||
|
"default_connection": "default",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Application entry point.
|
||||||
|
|
||||||
|
Assembles the :class:`~kaya.core.KayaApp` with four mixins:
|
||||||
|
|
||||||
|
- :class:`~kaya.session.SessionMixin` (sessions persisted in Redis via
|
||||||
|
:class:`~kaya.session.redis.RedisSessionStore` when ``REDIS_URL`` is set,
|
||||||
|
otherwise an in-memory store — e.g. for tests)
|
||||||
|
- :class:`~kaya.oidc.OIDCMixin` (OIDC login)
|
||||||
|
- :class:`~tavolo.tortoise_mixin.TortoiseMixin` (Postgres match statistics;
|
||||||
|
skipped for ``/api/health`` and the OpenAPI documentation endpoints)
|
||||||
|
- :class:`~kaya.openapi.OpenAPIMixin` (serves the OpenAPI document at
|
||||||
|
``/api/openapi.json`` and a Swagger UI at ``/api/docs``)
|
||||||
|
|
||||||
|
Live games are kept in :data:`game_store` (Redis when configured, in-memory
|
||||||
|
otherwise). Routes and the websocket handlers are registered by importing
|
||||||
|
their modules at the bottom; imports must happen after ``app`` is built.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib.metadata import version as _pkg_version
|
||||||
|
from logging import getLogger
|
||||||
|
|
||||||
|
from kaya.core import KayaApp
|
||||||
|
from kaya.oidc import OIDCConfig, OIDCMixin
|
||||||
|
from kaya.openapi import OpenAPIMixin
|
||||||
|
from kaya.session import InMemorySessionStore, SessionMixin, SessionStore
|
||||||
|
from kaya.session.redis import RedisSessionStore
|
||||||
|
from redis.asyncio import Redis
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .deadlines import DeadlineSchedulerMixin
|
||||||
|
from .logging_config import configure_logging
|
||||||
|
from .store import GameStore, InMemoryGameStore, RedisGameStore
|
||||||
|
from .tortoise_mixin import TortoiseMixin
|
||||||
|
|
||||||
|
configure_logging(settings.logging_config)
|
||||||
|
log = getLogger(__name__)
|
||||||
|
|
||||||
|
session_store: SessionStore
|
||||||
|
if settings.redis_url is not None:
|
||||||
|
# Lazy client: no connection is opened until a session is actually
|
||||||
|
# loaded/saved, so importing this module never requires a live Redis.
|
||||||
|
session_store = RedisSessionStore(Redis.from_url(settings.redis_url))
|
||||||
|
game_store: GameStore = RedisGameStore(
|
||||||
|
Redis.from_url(settings.redis_url, decode_responses=False),
|
||||||
|
ttl_seconds=settings.game_ttl_seconds,
|
||||||
|
)
|
||||||
|
log.info("using Redis stores (sessions + live games, game TTL %ds)", settings.game_ttl_seconds)
|
||||||
|
else:
|
||||||
|
session_store = InMemorySessionStore()
|
||||||
|
game_store = InMemoryGameStore()
|
||||||
|
log.info("REDIS_URL unset: using in-memory stores (sessions + live games)")
|
||||||
|
|
||||||
|
session_mixin = SessionMixin(session_store)
|
||||||
|
oidc_mixin = OIDCMixin(
|
||||||
|
OIDCConfig(
|
||||||
|
issuer=settings.oidc_issuer,
|
||||||
|
client_id=settings.oidc_client_id,
|
||||||
|
client_secret=settings.oidc_client_secret,
|
||||||
|
redirect_uri=settings.oidc_redirect_uri,
|
||||||
|
post_login_redirect=settings.oidc_post_login_redirect,
|
||||||
|
post_logout_redirect=settings.oidc_post_logout_redirect,
|
||||||
|
fetch_userinfo=True,
|
||||||
|
),
|
||||||
|
session=session_mixin,
|
||||||
|
)
|
||||||
|
openapi_mixin = OpenAPIMixin(
|
||||||
|
title="tavolo",
|
||||||
|
version=_pkg_version("tavolo"),
|
||||||
|
description="Scopone scientifico multiplayer API",
|
||||||
|
spec_path="/api/openapi.json",
|
||||||
|
docs_path="/api/docs",
|
||||||
|
)
|
||||||
|
tortoise_mixin = TortoiseMixin(
|
||||||
|
database_url=settings.database_url,
|
||||||
|
models_modules=["tavolo.models"],
|
||||||
|
skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
app = KayaApp(mixins=[session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin,
|
||||||
|
DeadlineSchedulerMixin(game_store)])
|
||||||
|
log.debug(
|
||||||
|
"timeouts: hand_ack=%ds turn=%ds",
|
||||||
|
settings.hand_ack_timeout_seconds,
|
||||||
|
settings.turn_timeout_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register routes by importing modules. Order does not matter; each module
|
||||||
|
# pulls ``app`` from here and decorates its handlers at import time. The
|
||||||
|
# static SPA-shell catch-all is registered last and only matches paths no
|
||||||
|
# other route claimed (asset files under /static are served by Granian
|
||||||
|
# itself and never reach the app).
|
||||||
|
from .routes import games, health, me, stats # noqa: E402,F401
|
||||||
|
from . import ws # noqa: E402,F401
|
||||||
|
from .routes import static # noqa: E402,F401
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Authentication helpers on top of the kaya-oidc mixin.
|
||||||
|
|
||||||
|
Tavolo has no application roles: every authenticated user may create and
|
||||||
|
join games. Authorization beyond login is game membership, checked against
|
||||||
|
the live game state in Redis.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable, Mapping, Optional
|
||||||
|
|
||||||
|
from kaya.core import HttpContext, WebSocket
|
||||||
|
from kaya.oidc import OIDCUser
|
||||||
|
|
||||||
|
from .app import oidc_mixin
|
||||||
|
|
||||||
|
|
||||||
|
def get_ws_user(ws: WebSocket) -> Optional[OIDCUser]:
|
||||||
|
"""Return the authenticated user of a WebSocket connection, if any.
|
||||||
|
|
||||||
|
The session mixin injects ``session`` into the websocket wrapper; the
|
||||||
|
OIDC mixin stores the userinfo there at login. Patched in tests.
|
||||||
|
"""
|
||||||
|
session = getattr(ws, "session", None)
|
||||||
|
if session is None:
|
||||||
|
return None
|
||||||
|
claims = session.get("oidc_user")
|
||||||
|
if not isinstance(claims, Mapping):
|
||||||
|
return None
|
||||||
|
return OIDCUser(claims)
|
||||||
|
|
||||||
|
|
||||||
|
def display_name(user: OIDCUser) -> str:
|
||||||
|
"""Best-effort human-readable name for a user."""
|
||||||
|
for key in ("name", "preferred_username", "email"):
|
||||||
|
value = user.get(key)
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
return value
|
||||||
|
return user.sub
|
||||||
|
|
||||||
|
|
||||||
|
def require_auth(handler: Callable[..., Any]) -> Callable[..., Any]:
|
||||||
|
"""Decorator: gate a handler on being authenticated (any OIDC user).
|
||||||
|
|
||||||
|
Responds ``401`` with a JSON error envelope when unauthenticated —
|
||||||
|
unlike kaya's built-in ``OIDCMixin.require_auth`` which redirects to
|
||||||
|
the login page (wrong for a JSON API).
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def guarded(ctx: HttpContext, *args: Any, **kwargs: Any) -> None:
|
||||||
|
if oidc_mixin.get_user(ctx) is None:
|
||||||
|
await ctx.send_bytes(
|
||||||
|
401,
|
||||||
|
b'{"error":"unauthenticated"}',
|
||||||
|
{"content-type": ("application/json",)},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
await handler(ctx, *args, **kwargs)
|
||||||
|
|
||||||
|
return guarded
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Environment-driven configuration for the tavolo application.
|
||||||
|
|
||||||
|
Mirrors kaya's own pattern: read ``os.environ`` directly into a plain
|
||||||
|
dataclass. No pydantic-settings, no settings module.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
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 _database_url_from_parts(engine: str,
|
||||||
|
user: str,
|
||||||
|
password: Optional[str],
|
||||||
|
host: str,
|
||||||
|
port: str,
|
||||||
|
name: str,
|
||||||
|
options: str) -> str:
|
||||||
|
"""Assemble a database DSN from individual components.
|
||||||
|
|
||||||
|
``user`` and ``password`` are percent-encoded so credentials containing
|
||||||
|
URL-reserved characters (``@``, ``:``, ``/``, ...) do not corrupt the
|
||||||
|
DSN. ``port`` and ``options`` are omitted when empty: a missing port
|
||||||
|
lets the driver pick its default (5432 for asyncpg). ``options`` is a
|
||||||
|
raw query string (e.g. ``ssl=require``) appended after a ``?``.
|
||||||
|
"""
|
||||||
|
netloc = quote(user, safe="")
|
||||||
|
if password:
|
||||||
|
netloc += ":" + quote(password, safe="")
|
||||||
|
netloc += "@" + host
|
||||||
|
if port:
|
||||||
|
netloc += ":" + port
|
||||||
|
url = f"{engine}://{netloc}/{name}"
|
||||||
|
options = options.lstrip("?")
|
||||||
|
if options:
|
||||||
|
url += "?" + options
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Settings:
|
||||||
|
database_url: str
|
||||||
|
oidc_issuer: str
|
||||||
|
oidc_client_id: str
|
||||||
|
oidc_client_secret: Optional[str]
|
||||||
|
oidc_redirect_uri: str
|
||||||
|
# Where the browser is sent after login/logout. In production the SPA is
|
||||||
|
# served by this app ("/"); in development point these at the trunk dev
|
||||||
|
# server (e.g. "http://localhost:8000/").
|
||||||
|
oidc_post_login_redirect: str
|
||||||
|
oidc_post_logout_redirect: str
|
||||||
|
app_host: str
|
||||||
|
app_port: int
|
||||||
|
redis_url: Optional[str]
|
||||||
|
# How long a live game (and its join-code index) survives in Redis
|
||||||
|
# without activity, in seconds. Defaults to 24h.
|
||||||
|
game_ttl_seconds: int
|
||||||
|
# Directory holding the compiled frontend (trunk's dist output). Only
|
||||||
|
# used to locate index.html for the SPA shell; the assets themselves
|
||||||
|
# are served by Granian under /static (GRANIAN_STATIC_PATH_* env vars).
|
||||||
|
static_dir: str
|
||||||
|
# Seconds the between-hands scoring summary waits for acknowledgements
|
||||||
|
# before dealing the next hand anyway.
|
||||||
|
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).
|
||||||
|
turn_timeout_seconds: int
|
||||||
|
# Upper bound on how long the deadline consumer sleeps between polls.
|
||||||
|
# Locally enqueued deadlines wake the consumer immediately; the
|
||||||
|
# heartbeat only bounds the discovery delay for deadlines enqueued by
|
||||||
|
# other workers.
|
||||||
|
deadline_heartbeat_ms: int
|
||||||
|
# Path to a YAML logging configuration file (logging.config.dictConfig
|
||||||
|
# schema). Unset uses the built-in default: DEBUG to the console.
|
||||||
|
logging_config: Optional[str]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_env() -> "Settings":
|
||||||
|
return Settings(
|
||||||
|
# DATABASE_URL, when set, is used verbatim and the DATABASE_*
|
||||||
|
# parts below are ignored (sqlite in tests, managed-DB DSNs).
|
||||||
|
database_url=os.environ.get("DATABASE_URL") or _database_url_from_parts(
|
||||||
|
engine=_env("DATABASE_ENGINE", "postgres"),
|
||||||
|
user=_env("DATABASE_USER", "tavolo"),
|
||||||
|
password=_env("DATABASE_PASSWORD", "password"),
|
||||||
|
host=_env("DATABASE_HOST", "localhost"),
|
||||||
|
# Unset: the port segment is omitted and the driver default
|
||||||
|
# (5432 for asyncpg) applies.
|
||||||
|
port=os.environ.get("DATABASE_PORT", ""),
|
||||||
|
name=_env("DATABASE_NAME", "tavolo"),
|
||||||
|
# Raw DSN query string (e.g. "ssl=require"); empty = none.
|
||||||
|
options=os.environ.get("DATABASE_OPTIONS", ""),
|
||||||
|
),
|
||||||
|
oidc_issuer=_env("OIDC_ISSUER", "http://localhost:8180/tavolo"),
|
||||||
|
oidc_client_id=_env("OIDC_CLIENT_ID", "tavolo"),
|
||||||
|
oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET"),
|
||||||
|
oidc_redirect_uri=_env("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback"),
|
||||||
|
oidc_post_login_redirect=_env("OIDC_POST_LOGIN_REDIRECT", "/"),
|
||||||
|
oidc_post_logout_redirect=_env("OIDC_POST_LOGOUT_REDIRECT", "/"),
|
||||||
|
app_host=_env("APP_HOST", "0.0.0.0"),
|
||||||
|
app_port=int(_env("APP_PORT", "8080")),
|
||||||
|
# When unset, sessions and live games fall back to in-memory
|
||||||
|
# stores (tests, ephemeral dev). Set to e.g.
|
||||||
|
# redis://localhost:6379/0 to persist both in Redis.
|
||||||
|
redis_url=os.environ.get("REDIS_URL"),
|
||||||
|
game_ttl_seconds=int(_env("GAME_TTL_SECONDS", "86400")),
|
||||||
|
static_dir=_env("STATIC_DIR", "web/dist"),
|
||||||
|
hand_ack_timeout_seconds=int(_env("HAND_ACK_TIMEOUT_SECONDS", "30")),
|
||||||
|
turn_timeout_seconds=int(_env("TURN_TIMEOUT_SECONDS", "30")),
|
||||||
|
deadline_heartbeat_ms=int(_env("DEADLINE_HEARTBEAT_MS", "1000")),
|
||||||
|
logging_config=os.environ.get("LOGGING_CONFIG"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
settings: Settings = Settings.from_env()
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
"""Deadline-driven timeouts, independent of player connections.
|
||||||
|
|
||||||
|
Both in-match timeouts — the per-turn auto-play (``turn_deadline``) and
|
||||||
|
the hand-end summary auto-continue (``hand_end_deadline``) — are driven by
|
||||||
|
the absolute deadlines persisted on the game state, never by which players
|
||||||
|
(or whether any players) are connected.
|
||||||
|
|
||||||
|
Every mutation that sets a deadline enqueues an entry in the store's
|
||||||
|
shared deadline queue (a Redis sorted set in production, see
|
||||||
|
:mod:`tavolo.store`), and a background consumer running on **every**
|
||||||
|
worker polls the queue for due entries. An entry records the phase, hand,
|
||||||
|
turn and deadline (as integer epoch milliseconds) it was enqueued for;
|
||||||
|
before acting, the consumer revalidates all of it against the live state
|
||||||
|
under the per-game lock, so entries that were overtaken by events (a play
|
||||||
|
landed in time, the hand was acknowledged, the deadline moved) are simply
|
||||||
|
discarded.
|
||||||
|
|
||||||
|
Delivery is at-least-once: an entry is removed from the queue only after
|
||||||
|
it has been processed. If a worker dies mid-processing, the entry stays in
|
||||||
|
Redis and another worker's consumer picks it up — the lock plus
|
||||||
|
revalidation make the duplicate delivery a no-op. Entries whose game has
|
||||||
|
expired are dropped the first time they fire, so the queue is
|
||||||
|
self-cleaning.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from logging import getLogger
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from kaya.core import KayaApp, KayaMixin
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .game import engine
|
||||||
|
from .game.errors import GameError
|
||||||
|
from .game.state import PHASE_HAND_END, PHASE_PLAYING, PHASE_FINISHED, GameState
|
||||||
|
from .stats import save_match_result
|
||||||
|
from .store import GameStore
|
||||||
|
|
||||||
|
log = getLogger(__name__)
|
||||||
|
|
||||||
|
# Entry kinds enqueued in the deadline queue.
|
||||||
|
KIND_TURN = "turn"
|
||||||
|
KIND_HAND_END = "hand_end"
|
||||||
|
|
||||||
|
# One consumer task and its wake-up event per event loop (tests run each
|
||||||
|
# test on a fresh loop).
|
||||||
|
_consumers: Dict[asyncio.AbstractEventLoop, asyncio.Task] = {}
|
||||||
|
_wake_events: Dict[asyncio.AbstractEventLoop, asyncio.Event] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def encode(entry: Dict[str, Any]) -> str:
|
||||||
|
"""Canonical queue-member encoding for a deadline entry."""
|
||||||
|
return json.dumps(entry, sort_keys=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _decode(member: Any) -> Optional[Dict[str, Any]]:
|
||||||
|
if isinstance(member, bytes):
|
||||||
|
member = member.decode("utf-8")
|
||||||
|
if not isinstance(member, str):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
entry = json.loads(member)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return entry if isinstance(entry, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _deadline_ms(iso: Optional[str]) -> Optional[int]:
|
||||||
|
"""Epoch milliseconds for an ISO-8601 deadline, ``None`` when absent
|
||||||
|
or unparseable. Queue entries carry this integer (never the ISO
|
||||||
|
string) as their revalidation token."""
|
||||||
|
if not iso:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(datetime.fromisoformat(iso).timestamp() * 1000)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_deadline(store: GameStore, state: GameState) -> None:
|
||||||
|
"""Enqueue the deadline the current state carries, if any.
|
||||||
|
|
||||||
|
Called after every mutation that can set a deadline (plays, acks, game
|
||||||
|
start) and as a backstop when a client connects. Enqueueing is
|
||||||
|
idempotent: an identical entry is already queued with the same due
|
||||||
|
time, so re-adding it changes nothing.
|
||||||
|
"""
|
||||||
|
entry: Optional[Dict[str, Any]] = None
|
||||||
|
due_ms: Optional[int] = None
|
||||||
|
if state.phase == PHASE_PLAYING and state.turn_deadline:
|
||||||
|
due_ms = _deadline_ms(state.turn_deadline)
|
||||||
|
entry = {
|
||||||
|
"game_id": state.id,
|
||||||
|
"kind": KIND_TURN,
|
||||||
|
"hand": state.hand_number,
|
||||||
|
"turn": state.turn,
|
||||||
|
"deadline": due_ms,
|
||||||
|
}
|
||||||
|
elif state.phase == PHASE_HAND_END and state.hand_end_deadline:
|
||||||
|
due_ms = _deadline_ms(state.hand_end_deadline)
|
||||||
|
entry = {
|
||||||
|
"game_id": state.id,
|
||||||
|
"kind": KIND_HAND_END,
|
||||||
|
"hand": state.hand_number,
|
||||||
|
"deadline": due_ms,
|
||||||
|
}
|
||||||
|
if entry is None or due_ms is None:
|
||||||
|
if entry is not None:
|
||||||
|
log.warning("game %s: unparseable deadline", state.id)
|
||||||
|
return
|
||||||
|
ensure_consumer(store)
|
||||||
|
# The score derives from the same value carried in the member, so the
|
||||||
|
# two can never disagree.
|
||||||
|
await store.add_deadline(encode(entry), due_ms / 1000)
|
||||||
|
wake = _wake_events.get(asyncio.get_running_loop())
|
||||||
|
if wake is not None:
|
||||||
|
wake.set()
|
||||||
|
|
||||||
|
|
||||||
|
async def finalize_mutation(store: GameStore, state: GameState) -> None:
|
||||||
|
"""Persist a successful mutation, notify subscribers and enqueue the
|
||||||
|
next deadline.
|
||||||
|
|
||||||
|
Callers must hold the per-game lock. Handles the terminal transition:
|
||||||
|
the match result is written to Postgres once (guarded by
|
||||||
|
``stats_saved``).
|
||||||
|
"""
|
||||||
|
if state.phase == PHASE_FINISHED:
|
||||||
|
await save_match_result(state)
|
||||||
|
log.info(
|
||||||
|
"game %s finished: team %s wins %d-%d",
|
||||||
|
state.id,
|
||||||
|
"A" if state.winner == 0 else "B",
|
||||||
|
state.scores[0],
|
||||||
|
state.scores[1],
|
||||||
|
)
|
||||||
|
await store.save(state)
|
||||||
|
await store.publish(state.id)
|
||||||
|
await sync_deadline(store, state)
|
||||||
|
|
||||||
|
|
||||||
|
async def process_due(store: GameStore, member: Any) -> None:
|
||||||
|
"""Fire a single due deadline entry.
|
||||||
|
|
||||||
|
Revalidates the entry against the live state under the per-game lock;
|
||||||
|
stale or foreign entries are discarded without effect. The entry is
|
||||||
|
removed from the queue once handled (including "nothing to do"); if
|
||||||
|
handling fails (e.g. the lock cannot be acquired), the entry is left
|
||||||
|
in the queue so another consumer retries it.
|
||||||
|
"""
|
||||||
|
entry = _decode(member)
|
||||||
|
if entry is None:
|
||||||
|
log.warning("deadline consumer: dropping malformed entry %r", member)
|
||||||
|
await store.remove_deadline(member)
|
||||||
|
return
|
||||||
|
game_id = entry.get("game_id")
|
||||||
|
kind = entry.get("kind")
|
||||||
|
if not isinstance(game_id, str):
|
||||||
|
await store.remove_deadline(member)
|
||||||
|
return
|
||||||
|
async with store.lock(game_id):
|
||||||
|
state = await store.load(game_id)
|
||||||
|
if state is not None:
|
||||||
|
if kind == KIND_TURN:
|
||||||
|
await _fire_turn(store, state, entry)
|
||||||
|
elif kind == KIND_HAND_END:
|
||||||
|
await _fire_hand_end(store, state, entry)
|
||||||
|
await store.remove_deadline(member)
|
||||||
|
|
||||||
|
|
||||||
|
async def _fire_turn(store: GameStore, state: GameState, entry: Dict[str, Any]) -> None:
|
||||||
|
if (
|
||||||
|
state.phase != PHASE_PLAYING
|
||||||
|
or state.hand_number != entry.get("hand")
|
||||||
|
or state.turn != entry.get("turn")
|
||||||
|
or _deadline_ms(state.turn_deadline) != entry.get("deadline")
|
||||||
|
):
|
||||||
|
return
|
||||||
|
seat = state.turn
|
||||||
|
try:
|
||||||
|
engine.auto_play(state)
|
||||||
|
except GameError:
|
||||||
|
return
|
||||||
|
log.info(
|
||||||
|
"game %s: auto-played for %s (turn timeout, hand %d)",
|
||||||
|
state.id,
|
||||||
|
state.players[seat].sub if seat < len(state.players) else "?",
|
||||||
|
entry.get("hand"),
|
||||||
|
)
|
||||||
|
await finalize_mutation(store, state)
|
||||||
|
|
||||||
|
|
||||||
|
async def _fire_hand_end(store: GameStore, state: GameState, entry: Dict[str, Any]) -> None:
|
||||||
|
if (
|
||||||
|
state.phase != PHASE_HAND_END
|
||||||
|
or state.hand_number != entry.get("hand")
|
||||||
|
or _deadline_ms(state.hand_end_deadline) != entry.get("deadline")
|
||||||
|
):
|
||||||
|
return
|
||||||
|
for player in state.players:
|
||||||
|
engine.acknowledge_hand(state, player.sub)
|
||||||
|
log.info(
|
||||||
|
"game %s: hand %d auto-advanced after the acknowledgement timeout",
|
||||||
|
state.id,
|
||||||
|
entry.get("hand"),
|
||||||
|
)
|
||||||
|
await finalize_mutation(store, state)
|
||||||
|
|
||||||
|
|
||||||
|
# --- consumer lifecycle -------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_consumer(
|
||||||
|
store: GameStore, loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
|
) -> None:
|
||||||
|
"""Start the deadline consumer on the given (or running) loop if not
|
||||||
|
yet running.
|
||||||
|
|
||||||
|
Called lazily whenever a deadline is enqueued (the ASGI test transport
|
||||||
|
never fires the lifespan hooks, so the mixin's ``setup`` alone is not
|
||||||
|
enough) and on application startup. The explicit ``loop`` matters at
|
||||||
|
startup: under RSGI granian calls ``setup`` before the loop runs, so
|
||||||
|
``asyncio.get_running_loop()`` would fail there.
|
||||||
|
"""
|
||||||
|
if loop is None:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
for old in list(_consumers):
|
||||||
|
if old.is_closed():
|
||||||
|
_consumers.pop(old, None)
|
||||||
|
_wake_events.pop(old, None)
|
||||||
|
task = _consumers.get(loop)
|
||||||
|
if task is None or task.done():
|
||||||
|
_wake_events[loop] = asyncio.Event()
|
||||||
|
_consumers[loop] = loop.create_task(_run(store, loop))
|
||||||
|
log.debug("deadline consumer started")
|
||||||
|
|
||||||
|
|
||||||
|
def stop_consumer(loop: asyncio.AbstractEventLoop) -> None:
|
||||||
|
task = _consumers.pop(loop, None)
|
||||||
|
_wake_events.pop(loop, None)
|
||||||
|
if task is not None:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
async def _run(store: GameStore, loop: asyncio.AbstractEventLoop) -> None:
|
||||||
|
wake = _wake_events[loop]
|
||||||
|
heartbeat = settings.deadline_heartbeat_ms / 1000
|
||||||
|
while True:
|
||||||
|
# Clear before polling so an enqueue racing the poll re-wakes us.
|
||||||
|
wake.clear()
|
||||||
|
delay = heartbeat
|
||||||
|
try:
|
||||||
|
for member in await store.due_deadlines(time.time()):
|
||||||
|
try:
|
||||||
|
await process_due(store, member)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
# Left in the queue; retried on the next pass.
|
||||||
|
log.exception("deadline consumer: failed to process %r", member)
|
||||||
|
next_due = await store.next_deadline()
|
||||||
|
if next_due is not None:
|
||||||
|
delay = max(0.0, min(heartbeat, next_due - time.time()))
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
log.exception("deadline consumer: poll failed; retrying")
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(wake.wait(), timeout=delay)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DeadlineSchedulerMixin(KayaMixin):
|
||||||
|
"""Run the deadline consumer for the whole app lifetime.
|
||||||
|
|
||||||
|
Every worker (and every pod) runs the same consumer; coordination
|
||||||
|
happens exclusively through the shared deadline queue and the per-game
|
||||||
|
locks, so any worker may fire any game's deadline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, store: GameStore) -> None:
|
||||||
|
self._store = store
|
||||||
|
|
||||||
|
def apply(self, app: KayaApp) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def setup(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||||
|
ensure_consumer(self._store, loop)
|
||||||
|
|
||||||
|
def shutdown(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||||
|
stop_consumer(loop)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Scopone scientifico domain package."""
|
||||||
@@ -0,0 +1,565 @@
|
|||||||
|
"""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.game.state.GameState` and raises
|
||||||
|
:class:`~tavolo.game.errors.GameError` subclasses on rule violations. This
|
||||||
|
makes the whole rule set unit-testable without Redis, Postgres or HTTP.
|
||||||
|
|
||||||
|
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.
|
||||||
|
* 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 .errors import (
|
||||||
|
AlreadyJoined,
|
||||||
|
CardNotInHand,
|
||||||
|
GameFinished,
|
||||||
|
GameNotStarted,
|
||||||
|
IllegalMove,
|
||||||
|
LobbyFull,
|
||||||
|
NotYourTurn,
|
||||||
|
)
|
||||||
|
from .state import (
|
||||||
|
DEFAULT_TARGET_SCORE,
|
||||||
|
PHASE_FINISHED,
|
||||||
|
PHASE_HAND_END,
|
||||||
|
PHASE_LOBBY,
|
||||||
|
PHASE_PLAYING,
|
||||||
|
SUITS,
|
||||||
|
TEAM_NAMES,
|
||||||
|
Card,
|
||||||
|
GameState,
|
||||||
|
Move,
|
||||||
|
PlayerState,
|
||||||
|
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 ``GameState.hand_ack_timeout`` (configurable via
|
||||||
|
# the HAND_ACK_TIMEOUT_SECONDS environment variable).
|
||||||
|
DEFAULT_HAND_ACK_TIMEOUT_SECONDS = 30
|
||||||
|
|
||||||
|
# Default seconds a player has to play before the server plays a random
|
||||||
|
# legal card for them. Games carry their own copy in
|
||||||
|
# ``GameState.turn_timeout`` (configurable via TURN_TIMEOUT_SECONDS).
|
||||||
|
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) -> 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 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]
|
||||||
|
|
||||||
|
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(
|
||||||
|
game_id: str,
|
||||||
|
join_code: str,
|
||||||
|
creator_sub: str,
|
||||||
|
creator_name: str,
|
||||||
|
target_score: int = DEFAULT_TARGET_SCORE,
|
||||||
|
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||||
|
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||||
|
game_type: str = "scopone_scientifico",
|
||||||
|
napola: bool = True,
|
||||||
|
) -> GameState:
|
||||||
|
"""Create a lobby game with the creator seated first."""
|
||||||
|
if target_score < 1 or target_score > 100:
|
||||||
|
raise IllegalMove("target_score must be between 1 and 100")
|
||||||
|
return GameState(
|
||||||
|
id=game_id,
|
||||||
|
join_code=join_code,
|
||||||
|
creator_sub=creator_sub,
|
||||||
|
game_type=game_type,
|
||||||
|
target_score=target_score,
|
||||||
|
napola=napola,
|
||||||
|
phase=PHASE_LOBBY,
|
||||||
|
players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)],
|
||||||
|
hand_ack_timeout=hand_ack_timeout,
|
||||||
|
turn_timeout=turn_timeout,
|
||||||
|
created_at=datetime.now(timezone.utc).isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def join_game(state: GameState, sub: str, name: str) -> None:
|
||||||
|
"""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: GameState) -> 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: GameState) -> None:
|
||||||
|
"""Arm the auto-play deadline for whoever is on turn."""
|
||||||
|
deadline = datetime.now(timezone.utc) + timedelta(seconds=state.turn_timeout)
|
||||||
|
state.turn_deadline = deadline.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _deal_hand(state: GameState) -> None:
|
||||||
|
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("game %s: hand %d dealt (dealer seat %d)", state.id, state.hand_number, state.dealer)
|
||||||
|
|
||||||
|
|
||||||
|
def _player_at(state: GameState, 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: GameState,
|
||||||
|
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.game.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 [])]
|
||||||
|
options = legal_captures(state.table, card)
|
||||||
|
|
||||||
|
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:
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
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."""
|
||||||
|
wanted = sorted(c.code for c in requested)
|
||||||
|
if not wanted:
|
||||||
|
return None
|
||||||
|
for option in options:
|
||||||
|
if sorted(c.code for c in option) == wanted:
|
||||||
|
return list(option)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def auto_play(state: GameState, 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)
|
||||||
|
capture = chooser.choice(options) if options else None
|
||||||
|
play(
|
||||||
|
state,
|
||||||
|
player.sub,
|
||||||
|
card.code,
|
||||||
|
[c.code for c in capture] if capture else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _end_hand(state: GameState) -> None:
|
||||||
|
"""Sweep the table and score the hand.
|
||||||
|
|
||||||
|
If the match continues, the game pauses in the ``hand_end`` phase so
|
||||||
|
every player can read the scoring summary; the next hand is dealt by
|
||||||
|
:func:`acknowledge_hand` once all four players have acknowledged (or by
|
||||||
|
the hand-end timeout in the websocket layer). If the match is over the
|
||||||
|
game goes to ``finished`` immediately.
|
||||||
|
"""
|
||||||
|
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(
|
||||||
|
"game %s: hand %d scored A+%d B+%d (totals %d-%d)",
|
||||||
|
state.id,
|
||||||
|
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
|
||||||
|
state.finished_at = datetime.now(timezone.utc).isoformat()
|
||||||
|
log.info("game %s: team %s swept the denari (napola) and wins", state.id, name)
|
||||||
|
return
|
||||||
|
reached = max(a, b) >= state.target_score
|
||||||
|
if reached and a != b:
|
||||||
|
state.phase = PHASE_FINISHED
|
||||||
|
state.winner = 0 if a > b else 1
|
||||||
|
state.finished_at = datetime.now(timezone.utc).isoformat()
|
||||||
|
log.debug("game %s: match ended, team %s wins", state.id, "A" if state.winner == 0 else "B")
|
||||||
|
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: GameState, 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: GameState) -> 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: GameState, sub: str) -> Dict[str, object]:
|
||||||
|
"""Serialize ``state`` hiding other players' hands.
|
||||||
|
|
||||||
|
Hands are reduced to a count, except for the requesting player's own
|
||||||
|
hand. Raises :class:`~tavolo.game.errors.GameNotFound`-style access via
|
||||||
|
the caller; this function assumes ``sub`` may or may not be seated and
|
||||||
|
simply omits the hand for non-seated viewers.
|
||||||
|
"""
|
||||||
|
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] = {
|
||||||
|
"id": state.id,
|
||||||
|
"join_code": state.join_code,
|
||||||
|
"game_type": state.game_type,
|
||||||
|
"phase": state.phase,
|
||||||
|
"target_score": state.target_score,
|
||||||
|
"napola": state.napola,
|
||||||
|
"hand_number": state.hand_number,
|
||||||
|
"dealer": state.dealer,
|
||||||
|
"turn": state.turn,
|
||||||
|
"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)
|
||||||
|
if options:
|
||||||
|
legal_moves[hand_card.code] = [
|
||||||
|
[c.code for c in option] for option in options
|
||||||
|
]
|
||||||
|
payload["legal_moves"] = legal_moves
|
||||||
|
return payload
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Typed errors raised by the scopone engine.
|
||||||
|
|
||||||
|
Route/WebSocket handlers translate these into 4xx responses or ``error``
|
||||||
|
WebSocket messages; the engine itself stays transport-agnostic.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class GameError(Exception):
|
||||||
|
"""Base class for every rule/validation failure."""
|
||||||
|
|
||||||
|
|
||||||
|
class IllegalMove(GameError):
|
||||||
|
"""The requested play violates the rules of scopone scientifico."""
|
||||||
|
|
||||||
|
|
||||||
|
class NotYourTurn(GameError):
|
||||||
|
"""A player attempted to play out of turn."""
|
||||||
|
|
||||||
|
|
||||||
|
class CardNotInHand(GameError):
|
||||||
|
"""The played card is not held by the player."""
|
||||||
|
|
||||||
|
|
||||||
|
class GameNotStarted(GameError):
|
||||||
|
"""An action was attempted before the game left the lobby."""
|
||||||
|
|
||||||
|
|
||||||
|
class GameFinished(GameError):
|
||||||
|
"""An action was attempted after the match ended."""
|
||||||
|
|
||||||
|
|
||||||
|
class LobbyFull(GameError):
|
||||||
|
"""A game already has four players."""
|
||||||
|
|
||||||
|
|
||||||
|
class AlreadyJoined(GameError):
|
||||||
|
"""A player tried to join a game they are already seated in."""
|
||||||
|
|
||||||
|
|
||||||
|
class GameNotFound(GameError):
|
||||||
|
"""No live game exists for the given id or join code."""
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"""In-memory representation of a scopone scientifico game.
|
||||||
|
|
||||||
|
The whole mutable game lives in :class:`GameState`, which is serialized to
|
||||||
|
and from plain JSON for storage in Redis (see :mod:`tavolo.store`). Keeping
|
||||||
|
the representation JSON-native means the store needs no custom codecs and
|
||||||
|
the state is inspectable with ``redis-cli``.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
def to_json(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"seat": self.seat,
|
||||||
|
"name": self.name,
|
||||||
|
"card": self.card,
|
||||||
|
"captured": list(self.captured),
|
||||||
|
"scopa": self.scopa,
|
||||||
|
}
|
||||||
|
|
||||||
|
@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)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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 GameState:
|
||||||
|
id: str
|
||||||
|
join_code: str
|
||||||
|
creator_sub: str
|
||||||
|
# Which card game this state belongs to (see tavolo.games.GAME_TYPES).
|
||||||
|
# Defaults so states serialized before game types existed still load.
|
||||||
|
game_type: str = "scopone_scientifico"
|
||||||
|
target_score: int = DEFAULT_TARGET_SCORE
|
||||||
|
# Whether the napola rule is scored (denari run from the ace; a full
|
||||||
|
# suit wins the match instantly). Default on, also for states
|
||||||
|
# serialized before the option existed.
|
||||||
|
napola: bool = True
|
||||||
|
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)
|
||||||
|
stats_saved: bool = False
|
||||||
|
# ISO-8601 timestamps, used when the match result is written to Postgres.
|
||||||
|
created_at: Optional[str] = None
|
||||||
|
finished_at: Optional[str] = None
|
||||||
|
# The most recent play in the current hand, for move announcements.
|
||||||
|
last_move: Optional[Move] = None
|
||||||
|
# While phase == "hand_end": seats that acknowledged the summary, and
|
||||||
|
# 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. Copied from settings at creation.
|
||||||
|
turn_deadline: Optional[str] = None
|
||||||
|
turn_timeout: int = 30
|
||||||
|
|
||||||
|
# -- serialization ----------------------------------------------------
|
||||||
|
|
||||||
|
def to_json(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"join_code": self.join_code,
|
||||||
|
"creator_sub": self.creator_sub,
|
||||||
|
"game_type": self.game_type,
|
||||||
|
"target_score": self.target_score,
|
||||||
|
"napola": self.napola,
|
||||||
|
"phase": self.phase,
|
||||||
|
"players": [p.to_json() for p in self.players],
|
||||||
|
"table": [c.to_json() for c in self.table],
|
||||||
|
"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),
|
||||||
|
"stats_saved": self.stats_saved,
|
||||||
|
"created_at": self.created_at,
|
||||||
|
"finished_at": self.finished_at,
|
||||||
|
"last_move": self.last_move.to_json() if self.last_move else None,
|
||||||
|
"acked": list(self.acked),
|
||||||
|
"hand_end_deadline": self.hand_end_deadline,
|
||||||
|
"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]) -> "GameState":
|
||||||
|
return GameState(
|
||||||
|
id=str(data["id"]),
|
||||||
|
join_code=str(data["join_code"]),
|
||||||
|
creator_sub=str(data.get("creator_sub", "")),
|
||||||
|
game_type=str(data.get("game_type", "scopone_scientifico")),
|
||||||
|
target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)),
|
||||||
|
napola=bool(data.get("napola", True)),
|
||||||
|
phase=str(data.get("phase", PHASE_LOBBY)),
|
||||||
|
players=[PlayerState.from_json(p) for p in data.get("players", [])],
|
||||||
|
table=[Card.from_json(c) for c in data.get("table", [])],
|
||||||
|
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", [])),
|
||||||
|
stats_saved=bool(data.get("stats_saved", False)),
|
||||||
|
created_at=data.get("created_at"),
|
||||||
|
finished_at=data.get("finished_at"),
|
||||||
|
last_move=Move.from_json(data["last_move"]) if data.get("last_move") else None,
|
||||||
|
acked=[int(s) for s in data.get("acked", [])],
|
||||||
|
hand_end_deadline=data.get("hand_end_deadline"),
|
||||||
|
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
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Registry of the card games the platform can host.
|
||||||
|
|
||||||
|
Only *scopone scientifico* is implemented for now; adding a game means a
|
||||||
|
new entry here plus its engine. The registry is the single source of truth
|
||||||
|
for the ``game_type`` carried by every live game (:mod:`tavolo.game.state`)
|
||||||
|
and persisted on each finished match (:mod:`tavolo.models`), which is what
|
||||||
|
makes match statistics game-scoped.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GameType:
|
||||||
|
"""Metadata describing one playable card game."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
SCOPONE_SCIENTIFICO = "scopone_scientifico"
|
||||||
|
|
||||||
|
GAME_TYPES: Dict[str, GameType] = {
|
||||||
|
SCOPONE_SCIENTIFICO: GameType(
|
||||||
|
id=SCOPONE_SCIENTIFICO,
|
||||||
|
name="Scopone scientifico",
|
||||||
|
description=(
|
||||||
|
"Four players in fixed partnerships, ten cards each and an empty "
|
||||||
|
"table. First team to the target score wins."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_GAME_TYPE = SCOPONE_SCIENTIFICO
|
||||||
|
|
||||||
|
|
||||||
|
def get_game_type(game_type_id: str) -> Optional[GameType]:
|
||||||
|
"""Return the registered game type with id ``game_type_id``, if any."""
|
||||||
|
return GAME_TYPES.get(game_type_id)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""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})
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Logging setup for the tavolo application.
|
||||||
|
|
||||||
|
Configured once at app import time (see :mod:`tavolo.app`). By default a
|
||||||
|
single console handler at DEBUG level is installed, formatting records as
|
||||||
|
``{asctime} [{levelname}] ({processName}/{threadName}) - {name} - {message}``.
|
||||||
|
Point the ``LOGGING_CONFIG`` environment variable at a YAML file to take
|
||||||
|
over the configuration entirely; the file follows the
|
||||||
|
:data:`logging.config.dictConfig` schema, e.g.::
|
||||||
|
|
||||||
|
version: 1
|
||||||
|
disable_existing_loggers: false
|
||||||
|
formatters:
|
||||||
|
default:
|
||||||
|
format: "{asctime} [{levelname}] ({processName:s}/{threadName:s}) - {name} - {message}"
|
||||||
|
style: "{"
|
||||||
|
handlers:
|
||||||
|
console:
|
||||||
|
class: logging.StreamHandler
|
||||||
|
formatter: default
|
||||||
|
root:
|
||||||
|
level: WARNING
|
||||||
|
handlers: [console]
|
||||||
|
loggers:
|
||||||
|
tavolo:
|
||||||
|
level: INFO
|
||||||
|
|
||||||
|
``disable_existing_loggers`` should stay ``false``: Granian configures its
|
||||||
|
own loggers before importing the application, and disabling them would
|
||||||
|
silence the server and Tortoise.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from logging.config import dictConfig
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
DEFAULT_CONFIG = {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"formatters": {
|
||||||
|
"default": {
|
||||||
|
"format": "{asctime} [{levelname}] ({processName:s}/{threadName:s}) - {name} - {message}",
|
||||||
|
"style": "{",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"handlers": {
|
||||||
|
"console": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"formatter": "default",
|
||||||
|
"level": "DEBUG",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"level": "DEBUG",
|
||||||
|
"handlers": ["console"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging(config_path: Optional[str]) -> None:
|
||||||
|
"""Apply the YAML logging configuration at ``config_path``, or the
|
||||||
|
built-in default when unset."""
|
||||||
|
if config_path is None:
|
||||||
|
dictConfig(DEFAULT_CONFIG)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with open(config_path, "rb") as handle:
|
||||||
|
config = yaml.safe_load(handle)
|
||||||
|
except OSError as exc:
|
||||||
|
raise RuntimeError(f"Cannot read LOGGING_CONFIG file: {config_path}") from exc
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
raise RuntimeError(f"LOGGING_CONFIG file is not a YAML mapping: {config_path}")
|
||||||
|
dictConfig(config)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Tortoise ORM models: match statistics persisted in Postgres.
|
||||||
|
|
||||||
|
Live game state lives in Redis (see :mod:`tavolo.store`); only completed
|
||||||
|
matches are written here. The two tables answer the question "every match
|
||||||
|
a player took part in, with the final score":
|
||||||
|
|
||||||
|
* :class:`Match` — one row per finished match with both teams' scores.
|
||||||
|
* :class:`MatchPlayer` — one row per participant, linking an OIDC
|
||||||
|
``sub`` to a seat/team and whether they won.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from tortoise import fields
|
||||||
|
from tortoise.models import Model
|
||||||
|
|
||||||
|
|
||||||
|
class Match(Model):
|
||||||
|
"""A completed match of one of the registered game types."""
|
||||||
|
|
||||||
|
id = fields.UUIDField(pk=True)
|
||||||
|
# Which card game was played (tavolo.games.GAME_TYPES); the default
|
||||||
|
# backfills matches recorded before game types existed.
|
||||||
|
game_type = fields.CharField(max_length=32, db_index=True, default="scopone_scientifico")
|
||||||
|
team_a_score = fields.SmallIntField()
|
||||||
|
team_b_score = fields.SmallIntField()
|
||||||
|
# "A" or "B".
|
||||||
|
winner_team = fields.CharField(max_length=1)
|
||||||
|
target_score = fields.SmallIntField()
|
||||||
|
hands_played = fields.SmallIntField()
|
||||||
|
started_at = fields.DatetimeField()
|
||||||
|
finished_at = fields.DatetimeField()
|
||||||
|
|
||||||
|
players: fields.ReverseRelation["MatchPlayer"]
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
table = "match"
|
||||||
|
ordering = ["-finished_at"]
|
||||||
|
|
||||||
|
|
||||||
|
class MatchPlayer(Model):
|
||||||
|
"""Participation of one user in one match."""
|
||||||
|
|
||||||
|
id = fields.UUIDField(pk=True)
|
||||||
|
match: fields.ForeignKeyRelation[Match] = fields.ForeignKeyField(
|
||||||
|
"models.Match", related_name="players", on_delete=fields.CASCADE
|
||||||
|
)
|
||||||
|
# OIDC subject of the player; no local users table.
|
||||||
|
user_sub = fields.CharField(max_length=255, db_index=True)
|
||||||
|
display_name = fields.CharField(max_length=200)
|
||||||
|
seat = fields.SmallIntField()
|
||||||
|
team = fields.CharField(max_length=1)
|
||||||
|
won = fields.BooleanField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
table = "match_player"
|
||||||
|
unique_together = (("match", "user_sub"),)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""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.",
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""Cursor-based pagination for listing endpoints.
|
||||||
|
|
||||||
|
Uses keyset pagination (not OFFSET/LIMIT): each page ends with an opaque
|
||||||
|
cursor encoding the sort key tuple of the last item on that page; the next
|
||||||
|
request passes that cursor and the query continues from the point it left
|
||||||
|
off. This is stable under concurrent inserts and cheaper than OFFSET for
|
||||||
|
large result sets.
|
||||||
|
|
||||||
|
Cursor is a base64-encoded JSON object mapping the sort-field names to the
|
||||||
|
values of the last item on the previous page. It's opaque to callers and
|
||||||
|
must be treated as a black box.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from tortoise.queryset import QuerySet
|
||||||
|
|
||||||
|
from .http import extract_query_params
|
||||||
|
|
||||||
|
DEFAULT_LIMIT = 20
|
||||||
|
MAX_LIMIT = 100
|
||||||
|
MIN_LIMIT = 1
|
||||||
|
|
||||||
|
CURSOR_PARAM = "cursor"
|
||||||
|
LIMIT_PARAM = "limit"
|
||||||
|
|
||||||
|
|
||||||
|
class CursorDecodeError(ValueError):
|
||||||
|
"""Raised when the ``cursor`` query param cannot be decoded."""
|
||||||
|
|
||||||
|
|
||||||
|
def encode_cursor(values: Dict[str, Any]) -> str:
|
||||||
|
# ``default=str`` handles datetimes (ISO) so keyset cursors can carry
|
||||||
|
# datetime-typed sort fields (e.g. finished_at for match history).
|
||||||
|
raw = json.dumps(values, separators=(",", ":"), default=str).encode("utf-8")
|
||||||
|
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def decode_cursor(s: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||||
|
if s is None or s == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
# Tolerate missing padding.
|
||||||
|
padded = s + "=" * (-len(s) % 4)
|
||||||
|
raw = base64.urlsafe_b64decode(padded.encode("ascii"))
|
||||||
|
obj = json.loads(raw.decode("utf-8"))
|
||||||
|
except (ValueError, UnicodeDecodeError) as exc:
|
||||||
|
raise CursorDecodeError("invalid cursor") from exc
|
||||||
|
if not isinstance(obj, dict):
|
||||||
|
raise CursorDecodeError("invalid cursor")
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Cursor:
|
||||||
|
limit: int
|
||||||
|
after: Optional[Dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_cursor_params(query_string: str) -> Cursor:
|
||||||
|
params = extract_query_params(query_string)
|
||||||
|
limit_raw = params.get(LIMIT_PARAM)
|
||||||
|
if limit_raw:
|
||||||
|
try:
|
||||||
|
limit = int(limit_raw[0])
|
||||||
|
except ValueError as exc:
|
||||||
|
raise CursorDecodeError("invalid limit") from exc
|
||||||
|
else:
|
||||||
|
limit = DEFAULT_LIMIT
|
||||||
|
limit = max(MIN_LIMIT, min(MAX_LIMIT, limit))
|
||||||
|
after = decode_cursor(params.get(CURSOR_PARAM, [None])[0])
|
||||||
|
return Cursor(limit=limit, after=after)
|
||||||
|
|
||||||
|
|
||||||
|
# A sort field: (model field name, "ASC" or "DESC"). The tuple is the full
|
||||||
|
# keyset; the cursor encodes exactly these fields.
|
||||||
|
Sort = List[Tuple[str, str]]
|
||||||
|
|
||||||
|
|
||||||
|
def _keyset_where(sort: Sort, after: Dict[str, Any]):
|
||||||
|
"""Build a Tortoise ``Q`` filter from a cursor."""
|
||||||
|
from tortoise.queryset import Q # local to keep import edge narrow
|
||||||
|
|
||||||
|
clauses = []
|
||||||
|
for i, (field, direction) in enumerate(sort):
|
||||||
|
key = f"{field}__{'gt' if direction == 'ASC' else 'lt'}"
|
||||||
|
value = after.get(field)
|
||||||
|
if value is None:
|
||||||
|
return Q()
|
||||||
|
clause = Q(**{key: value})
|
||||||
|
for j in range(i):
|
||||||
|
prev_field, _ = sort[j]
|
||||||
|
prev_value = after.get(prev_field)
|
||||||
|
if prev_value is None:
|
||||||
|
return Q()
|
||||||
|
clause = clause & Q(**{prev_field: prev_value})
|
||||||
|
clauses.append(clause)
|
||||||
|
result = clauses[0]
|
||||||
|
for clause in clauses[1:]:
|
||||||
|
result = result | clause
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def paginate(
|
||||||
|
queryset: QuerySet,
|
||||||
|
sort: Sort,
|
||||||
|
cursor: Cursor,
|
||||||
|
) -> Tuple[List[Any], Optional[str]]:
|
||||||
|
"""Return one page of ``queryset`` plus the opaque cursor to continue."""
|
||||||
|
order_by: List[str] = []
|
||||||
|
for field, direction in sort:
|
||||||
|
order_by.append(field if direction == "ASC" else f"-{field}")
|
||||||
|
qs = queryset.order_by(*order_by)
|
||||||
|
if cursor.after:
|
||||||
|
qs = qs.filter(_keyset_where(sort, cursor.after))
|
||||||
|
rows = await qs.limit(cursor.limit + 1)
|
||||||
|
if len(rows) <= cursor.limit:
|
||||||
|
return rows, None
|
||||||
|
page = rows[: cursor.limit]
|
||||||
|
last = page[-1]
|
||||||
|
key: Dict[str, Any] = {}
|
||||||
|
for field, _ in sort:
|
||||||
|
key[field] = getattr(last, field)
|
||||||
|
return page, encode_cursor(key)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""HTTP route modules."""
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
"""Game lobby endpoints.
|
||||||
|
|
||||||
|
A game starts as a lobby: the creator is seated first and shares the
|
||||||
|
six-character ``join_code``. When the fourth player joins, the engine deals
|
||||||
|
the first hand and the match begins. Live play then happens over the
|
||||||
|
``/ws/games/{id}`` websocket (see :mod:`tavolo.ws`); these endpoints cover
|
||||||
|
creation, joining and snapshotting state.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import uuid
|
||||||
|
from logging import getLogger
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from kaya.core import HttpContext
|
||||||
|
from kaya.openapi import operation
|
||||||
|
|
||||||
|
from .. import auth, deadlines
|
||||||
|
from ..app import app, game_store, oidc_mixin
|
||||||
|
from ..auth import require_auth
|
||||||
|
from ..config import settings
|
||||||
|
from ..game import engine
|
||||||
|
from ..game.errors import GameError
|
||||||
|
from ..game.state import DEFAULT_TARGET_SCORE, PHASE_LOBBY, GameState
|
||||||
|
from ..games import GAME_TYPES, get_game_type
|
||||||
|
from ..http import JsonRequestError, read_json, read_json_optional, send_error, send_json
|
||||||
|
|
||||||
|
log = getLogger(__name__)
|
||||||
|
|
||||||
|
# Join codes avoid characters that are easy to confuse when read aloud.
|
||||||
|
_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
_CODE_LENGTH = 6
|
||||||
|
_MAX_CODE_ATTEMPTS = 20
|
||||||
|
|
||||||
|
|
||||||
|
def _now_code() -> str:
|
||||||
|
return "".join(secrets.choice(_CODE_ALPHABET) for _ in range(_CODE_LENGTH))
|
||||||
|
|
||||||
|
|
||||||
|
async def _unique_code() -> str:
|
||||||
|
for _ in range(_MAX_CODE_ATTEMPTS):
|
||||||
|
code = _now_code()
|
||||||
|
if await game_store.find_by_code(code) is None:
|
||||||
|
return code
|
||||||
|
raise RuntimeError("could not allocate a unique join code")
|
||||||
|
|
||||||
|
|
||||||
|
def _lobby_payload(state: GameState) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": state.id,
|
||||||
|
"join_code": state.join_code,
|
||||||
|
"game_type": state.game_type,
|
||||||
|
"target_score": state.target_score,
|
||||||
|
"napola": state.napola,
|
||||||
|
"phase": state.phase,
|
||||||
|
"players": [
|
||||||
|
{"sub": p.sub, "name": p.name, "seat": p.seat, "team": "A" if p.seat % 2 == 0 else "B"}
|
||||||
|
for p in state.players
|
||||||
|
],
|
||||||
|
"seats_open": 4 - len(state.players),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.GET("/api/game-types")
|
||||||
|
@operation(summary="List available games",
|
||||||
|
description="Every card game the platform can host, for the "
|
||||||
|
"match-creation dropdown.",
|
||||||
|
tags=["games"],
|
||||||
|
responses={200: {"description": "The available game types"}})
|
||||||
|
async def list_game_types(ctx: HttpContext) -> None:
|
||||||
|
await send_json(ctx, 200, {
|
||||||
|
"results": [
|
||||||
|
{"id": g.id, "name": g.name, "description": g.description}
|
||||||
|
for g in GAME_TYPES.values()
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.POST("/api/games")
|
||||||
|
@operation(summary="Create a game",
|
||||||
|
description="Creates a lobby game and seats the caller in seat 0. "
|
||||||
|
"Share the returned join_code with three other players.",
|
||||||
|
tags=["games"],
|
||||||
|
request_body={
|
||||||
|
"required": False,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"game_type": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "scopone_scientifico",
|
||||||
|
"description": "One of the ids from GET /api/game-types",
|
||||||
|
},
|
||||||
|
"target_score": {"type": "integer", "minimum": 1, "maximum": 100},
|
||||||
|
"napola": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": True,
|
||||||
|
"description": "Score the napola rule; a full "
|
||||||
|
"denari sweep wins the match",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses={
|
||||||
|
201: {"description": "The created lobby"},
|
||||||
|
400: {"description": "Invalid game_type, target_score or body"},
|
||||||
|
401: {"description": "Authentication required"},
|
||||||
|
})
|
||||||
|
@require_auth
|
||||||
|
async def create_game(ctx: HttpContext) -> None:
|
||||||
|
body: dict = {}
|
||||||
|
try:
|
||||||
|
body = await read_json_optional(ctx)
|
||||||
|
except JsonRequestError as exc:
|
||||||
|
await send_error(ctx, 400, str(exc))
|
||||||
|
return
|
||||||
|
|
||||||
|
target_score: Any = body.get("target_score", DEFAULT_TARGET_SCORE)
|
||||||
|
if isinstance(target_score, bool) or not isinstance(target_score, int):
|
||||||
|
await send_error(ctx, 400, "target_score must be an integer")
|
||||||
|
return
|
||||||
|
|
||||||
|
game_type: Any = body.get("game_type", "scopone_scientifico")
|
||||||
|
if not isinstance(game_type, str) or get_game_type(game_type) is None:
|
||||||
|
await send_error(ctx, 400, f"unknown game_type: {game_type!r}")
|
||||||
|
return
|
||||||
|
|
||||||
|
napola: Any = body.get("napola", True)
|
||||||
|
if not isinstance(napola, bool):
|
||||||
|
await send_error(ctx, 400, "napola must be a boolean")
|
||||||
|
return
|
||||||
|
|
||||||
|
user = oidc_mixin.get_user(ctx)
|
||||||
|
assert user is not None # enforced by @require_auth
|
||||||
|
game_id = str(uuid.uuid4())
|
||||||
|
join_code = await _unique_code()
|
||||||
|
try:
|
||||||
|
state = engine.create_game(
|
||||||
|
game_id=game_id,
|
||||||
|
join_code=join_code,
|
||||||
|
creator_sub=user.sub,
|
||||||
|
creator_name=auth.display_name(user),
|
||||||
|
target_score=target_score,
|
||||||
|
hand_ack_timeout=settings.hand_ack_timeout_seconds,
|
||||||
|
turn_timeout=settings.turn_timeout_seconds,
|
||||||
|
game_type=game_type,
|
||||||
|
napola=napola,
|
||||||
|
)
|
||||||
|
except GameError as exc:
|
||||||
|
await send_error(ctx, 400, str(exc))
|
||||||
|
return
|
||||||
|
await game_store.save(state)
|
||||||
|
log.info(
|
||||||
|
"game %s created by %s (%s, target score %d)",
|
||||||
|
game_id,
|
||||||
|
user.sub,
|
||||||
|
game_type,
|
||||||
|
target_score,
|
||||||
|
)
|
||||||
|
await send_json(ctx, 201, _lobby_payload(state))
|
||||||
|
|
||||||
|
|
||||||
|
@app.POST("/api/games/join")
|
||||||
|
@operation(summary="Join a game by code",
|
||||||
|
description="Seats the caller in the next free chair. Joining as the "
|
||||||
|
"fourth player starts the match.",
|
||||||
|
tags=["games"],
|
||||||
|
request_body={
|
||||||
|
"required": True,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"code": {"type": "string"}},
|
||||||
|
"required": ["code"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses={
|
||||||
|
200: {"description": "Seated; game state (may be playing)"},
|
||||||
|
400: {"description": "Missing code"},
|
||||||
|
401: {"description": "Authentication required"},
|
||||||
|
404: {"description": "Unknown join code"},
|
||||||
|
409: {"description": "Already joined or lobby full"},
|
||||||
|
})
|
||||||
|
@require_auth
|
||||||
|
async def join_game(ctx: HttpContext) -> None:
|
||||||
|
try:
|
||||||
|
body = await read_json(ctx)
|
||||||
|
except JsonRequestError as exc:
|
||||||
|
await send_error(ctx, 400, str(exc))
|
||||||
|
return
|
||||||
|
code = body.get("code")
|
||||||
|
if not isinstance(code, str) or not code:
|
||||||
|
await send_error(ctx, 400, "code is required")
|
||||||
|
return
|
||||||
|
|
||||||
|
user = oidc_mixin.get_user(ctx)
|
||||||
|
assert user is not None
|
||||||
|
existing = await game_store.find_by_code(code)
|
||||||
|
if existing is None:
|
||||||
|
log.debug("join rejected for %s: unknown code %r", user.sub, code)
|
||||||
|
await send_error(ctx, 404, "unknown join code")
|
||||||
|
return
|
||||||
|
|
||||||
|
async with game_store.lock(existing.id):
|
||||||
|
state = await game_store.load(existing.id)
|
||||||
|
if state is None:
|
||||||
|
await send_error(ctx, 404, "unknown join code")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
engine.join_game(state, user.sub, auth.display_name(user))
|
||||||
|
except GameError as exc:
|
||||||
|
log.debug("join rejected for %s in game %s: %s", user.sub, state.id, exc)
|
||||||
|
await send_error(ctx, 409, str(exc))
|
||||||
|
return
|
||||||
|
await game_store.save(state)
|
||||||
|
await game_store.publish(state.id)
|
||||||
|
# When the fourth join started the match, the first turn deadline
|
||||||
|
# was armed; queue it so it fires even if nobody ever connects.
|
||||||
|
await deadlines.sync_deadline(game_store, state)
|
||||||
|
seat = next(p.seat for p in state.players if p.sub == user.sub)
|
||||||
|
if state.phase == PHASE_LOBBY:
|
||||||
|
log.info("%s joined game %s (seat %d, %d/4 players)", user.sub, state.id, seat, len(state.players))
|
||||||
|
else:
|
||||||
|
log.info("%s joined game %s (seat %d); match started", user.sub, state.id, seat)
|
||||||
|
await send_json(ctx, 200, engine.state_for_player(state, user.sub))
|
||||||
|
return
|
||||||
|
await send_json(ctx, 200, _lobby_payload(state))
|
||||||
|
|
||||||
|
|
||||||
|
@app.GET("/api/games/${game_id}")
|
||||||
|
@operation(summary="Get a game snapshot",
|
||||||
|
description="Only seated players may read a game; other players' "
|
||||||
|
"hands are hidden.",
|
||||||
|
tags=["games"],
|
||||||
|
responses={
|
||||||
|
200: {"description": "The personalized game state"},
|
||||||
|
401: {"description": "Authentication required"},
|
||||||
|
403: {"description": "Not a player in this game"},
|
||||||
|
404: {"description": "Game not found"},
|
||||||
|
})
|
||||||
|
@require_auth
|
||||||
|
async def get_game(ctx: HttpContext, game_id: str) -> None:
|
||||||
|
state = await game_store.load(game_id)
|
||||||
|
if state is None:
|
||||||
|
await send_error(ctx, 404, "game not found")
|
||||||
|
return
|
||||||
|
user = oidc_mixin.get_user(ctx)
|
||||||
|
assert user is not None
|
||||||
|
if not state.seated(user.sub):
|
||||||
|
await send_error(ctx, 403, "forbidden")
|
||||||
|
return
|
||||||
|
await send_json(ctx, 200, engine.state_for_player(state, user.sub))
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Liveness probe."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from kaya.core import HttpContext
|
||||||
|
from kaya.openapi import operation
|
||||||
|
|
||||||
|
from ..app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.GET("/api/health")
|
||||||
|
@operation(summary="Health check",
|
||||||
|
tags=["health"],
|
||||||
|
responses={200: {"description": "The service is up"}})
|
||||||
|
async def health(ctx: HttpContext) -> None:
|
||||||
|
await ctx.send_bytes(
|
||||||
|
200,
|
||||||
|
b'{"status":"ok"}',
|
||||||
|
{"content-type": ("application/json",)},
|
||||||
|
)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Whoami endpoint: lets the single-page app detect the login state."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from kaya.core import HttpContext
|
||||||
|
from kaya.openapi import operation
|
||||||
|
|
||||||
|
from ..app import app, oidc_mixin
|
||||||
|
from ..auth import display_name, require_auth
|
||||||
|
from ..http import send_json
|
||||||
|
|
||||||
|
|
||||||
|
@app.GET("/api/me")
|
||||||
|
@operation(summary="Current user",
|
||||||
|
description="Returns the authenticated user's identity from the "
|
||||||
|
"session; 401 when not logged in.",
|
||||||
|
tags=["auth"],
|
||||||
|
responses={
|
||||||
|
200: {"description": "The current user"},
|
||||||
|
401: {"description": "Not logged in"},
|
||||||
|
})
|
||||||
|
@require_auth
|
||||||
|
async def me(ctx: HttpContext) -> None:
|
||||||
|
user = oidc_mixin.get_user(ctx)
|
||||||
|
assert user is not None # enforced by @require_auth
|
||||||
|
await send_json(ctx, 200, {"sub": user.sub, "name": display_name(user)})
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""SPA shell hosting for the compiled single-page application.
|
||||||
|
|
||||||
|
Static assets (wasm, js, css, card images) are served by Granian itself
|
||||||
|
under the ``/static`` prefix (``GRANIAN_STATIC_PATH_*`` env vars; see the
|
||||||
|
Dockerfile) and never reach Python. This module only serves ``index.html``
|
||||||
|
from ``STATIC_DIR``: at the site root and — via the glob catch-all — for
|
||||||
|
every path no API or auth route claimed, so client-side routes
|
||||||
|
(``/game/<id>`` etc.) work on direct loads and refreshes.
|
||||||
|
|
||||||
|
kaya-openapi deliberately skips glob routes, so this handler never appears
|
||||||
|
in the API specification.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from kaya.core import HttpContext
|
||||||
|
|
||||||
|
from ..app import app
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_shell(ctx: HttpContext) -> None:
|
||||||
|
"""Serve the SPA shell, or 404 when the frontend build is missing."""
|
||||||
|
index = Path(settings.static_dir) / "index.html"
|
||||||
|
if not index.is_file():
|
||||||
|
await ctx.send_empty(404)
|
||||||
|
return
|
||||||
|
body = await asyncio.to_thread(index.read_bytes)
|
||||||
|
await ctx.send_bytes(200, body, {"content-type": ("text/html; charset=utf-8",)})
|
||||||
|
|
||||||
|
|
||||||
|
@app.GET("/")
|
||||||
|
async def index(ctx: HttpContext) -> None:
|
||||||
|
"""Serve the SPA shell at the site root (the glob below cannot match
|
||||||
|
an empty path)."""
|
||||||
|
await _send_shell(ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@app.GET("/*", recursive=True)
|
||||||
|
async def spa(ctx: HttpContext, _matched: object = None) -> None:
|
||||||
|
"""SPA fallback: any path that matched no other route renders the app
|
||||||
|
shell. Requests under ``/static`` are answered by Granian before the
|
||||||
|
app is ever called, so they never arrive here."""
|
||||||
|
await _send_shell(ctx)
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""Player statistics endpoints, served from Postgres.
|
||||||
|
|
||||||
|
Every finished match is persisted by :func:`tavolo.stats.save_match_result`.
|
||||||
|
These endpoints expose a player's own match history and a global
|
||||||
|
leaderboard aggregated from the same two tables.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from kaya.core import HttpContext
|
||||||
|
from kaya.openapi import operation
|
||||||
|
|
||||||
|
from ..app import app, oidc_mixin
|
||||||
|
from ..auth import require_auth
|
||||||
|
from ..games import get_game_type
|
||||||
|
from ..http import extract_query_params, send_error, send_json
|
||||||
|
from ..models import Match, MatchPlayer
|
||||||
|
from ..openapi import PAGINATION_PARAMETERS
|
||||||
|
from ..pagination import CursorDecodeError, paginate, parse_cursor_params
|
||||||
|
|
||||||
|
GAME_TYPE_PARAMETER: Dict[str, Any] = {
|
||||||
|
"name": "game_type",
|
||||||
|
"in": "query",
|
||||||
|
"required": False,
|
||||||
|
"schema": {"type": "string"},
|
||||||
|
"description": "Only count matches of this game (id from GET /api/game-types).",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_game_type(query_string: str) -> Tuple[Optional[str], Optional[str]]:
|
||||||
|
"""Parse the ``game_type`` query parameter.
|
||||||
|
|
||||||
|
Returns ``(value, error)``: ``(None, None)`` when absent, ``(id, None)``
|
||||||
|
when valid, ``(None, message)`` when it names no registered game."""
|
||||||
|
values = extract_query_params(query_string).get("game_type")
|
||||||
|
if not values:
|
||||||
|
return None, None
|
||||||
|
game_type = values[0]
|
||||||
|
if get_game_type(game_type) is None:
|
||||||
|
return None, f"unknown game_type: {game_type!r}"
|
||||||
|
return game_type, None
|
||||||
|
|
||||||
|
|
||||||
|
async def _serialize_match(match: Match, viewer: str) -> Dict[str, Any]:
|
||||||
|
participants = await MatchPlayer.filter(match_id=match.id).order_by("seat")
|
||||||
|
return {
|
||||||
|
"id": str(match.id),
|
||||||
|
"game_type": match.game_type,
|
||||||
|
"team_a_score": match.team_a_score,
|
||||||
|
"team_b_score": match.team_b_score,
|
||||||
|
"winner_team": match.winner_team,
|
||||||
|
"target_score": match.target_score,
|
||||||
|
"hands_played": match.hands_played,
|
||||||
|
"started_at": match.started_at.isoformat(),
|
||||||
|
"finished_at": match.finished_at.isoformat(),
|
||||||
|
"you_won": any(p.user_sub == viewer and p.won for p in participants),
|
||||||
|
"players": [
|
||||||
|
{
|
||||||
|
"user_sub": p.user_sub,
|
||||||
|
"display_name": p.display_name,
|
||||||
|
"seat": p.seat,
|
||||||
|
"team": p.team,
|
||||||
|
"won": p.won,
|
||||||
|
}
|
||||||
|
for p in participants
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.GET("/api/me/matches")
|
||||||
|
@operation(summary="List my matches",
|
||||||
|
description="Cursor-paginated history of finished matches the caller "
|
||||||
|
"played, newest first, with the final score.",
|
||||||
|
tags=["stats"],
|
||||||
|
parameters=[*PAGINATION_PARAMETERS, GAME_TYPE_PARAMETER],
|
||||||
|
responses={
|
||||||
|
200: {"description": "A page of matches"},
|
||||||
|
400: {"description": "Invalid pagination cursor or game_type"},
|
||||||
|
401: {"description": "Authentication required"},
|
||||||
|
})
|
||||||
|
@require_auth
|
||||||
|
async def my_matches(ctx: HttpContext) -> None:
|
||||||
|
try:
|
||||||
|
cursor = parse_cursor_params(ctx.query_string)
|
||||||
|
except CursorDecodeError as exc:
|
||||||
|
await send_error(ctx, 400, str(exc))
|
||||||
|
return
|
||||||
|
game_type, error = _parse_game_type(ctx.query_string)
|
||||||
|
if error is not None:
|
||||||
|
await send_error(ctx, 400, error)
|
||||||
|
return
|
||||||
|
user = oidc_mixin.get_user(ctx)
|
||||||
|
assert user is not None
|
||||||
|
queryset = Match.filter(players__user_sub=user.sub).distinct()
|
||||||
|
if game_type is not None:
|
||||||
|
queryset = queryset.filter(game_type=game_type)
|
||||||
|
matches, next_cursor = await paginate(
|
||||||
|
queryset, [("finished_at", "DESC"), ("id", "ASC")], cursor
|
||||||
|
)
|
||||||
|
results = [await _serialize_match(m, user.sub) for m in matches]
|
||||||
|
await send_json(ctx, 200, {"results": results, "next_cursor": next_cursor})
|
||||||
|
|
||||||
|
|
||||||
|
@app.GET("/api/leaderboard")
|
||||||
|
@operation(summary="Global leaderboard",
|
||||||
|
description="Aggregated wins, matches played and team points for every "
|
||||||
|
"player with at least one finished match. Sorted by wins.",
|
||||||
|
tags=["stats"],
|
||||||
|
parameters=[GAME_TYPE_PARAMETER],
|
||||||
|
responses={
|
||||||
|
200: {"description": "The leaderboard"},
|
||||||
|
400: {"description": "Unknown game_type"},
|
||||||
|
})
|
||||||
|
async def leaderboard(ctx: HttpContext) -> None:
|
||||||
|
game_type, error = _parse_game_type(ctx.query_string)
|
||||||
|
if error is not None:
|
||||||
|
await send_error(ctx, 400, error)
|
||||||
|
return
|
||||||
|
queryset = MatchPlayer.all()
|
||||||
|
if game_type is not None:
|
||||||
|
queryset = queryset.filter(match__game_type=game_type)
|
||||||
|
rows = await queryset.prefetch_related("match")
|
||||||
|
aggregate: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for row in rows:
|
||||||
|
entry = aggregate.setdefault(
|
||||||
|
row.user_sub,
|
||||||
|
{
|
||||||
|
"user_sub": row.user_sub,
|
||||||
|
"display_name": row.display_name,
|
||||||
|
"matches": 0,
|
||||||
|
"wins": 0,
|
||||||
|
"points": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
entry["matches"] += 1
|
||||||
|
entry["wins"] += 1 if row.won else 0
|
||||||
|
match = row.match
|
||||||
|
if match is not None:
|
||||||
|
entry["points"] += (
|
||||||
|
match.team_a_score if row.team == "A" else match.team_b_score
|
||||||
|
)
|
||||||
|
# Keep the most recent display name seen.
|
||||||
|
entry["display_name"] = row.display_name
|
||||||
|
|
||||||
|
ranking: List[Dict[str, Any]] = sorted(
|
||||||
|
aggregate.values(),
|
||||||
|
key=lambda e: (e["wins"], e["points"], -e["matches"]),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
await send_json(ctx, 200, {"results": ranking})
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Copy finished match results from Redis into Postgres.
|
||||||
|
|
||||||
|
Called once when a game reaches the finished phase (guarded by the
|
||||||
|
``stats_saved`` flag on the state). The write is transactional so a match
|
||||||
|
never appears with only some of its players.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from logging import getLogger
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from tortoise.transactions import in_transaction
|
||||||
|
|
||||||
|
from .game.state import PHASE_FINISHED, TEAM_NAMES, GameState
|
||||||
|
|
||||||
|
log = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_timestamp(value: Optional[str]) -> datetime:
|
||||||
|
if value:
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
async def save_match_result(state: GameState) -> None:
|
||||||
|
"""Persist ``state`` to Postgres if it is finished and not yet saved."""
|
||||||
|
if state.stats_saved or state.phase != PHASE_FINISHED or state.winner is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
from .models import Match, MatchPlayer
|
||||||
|
|
||||||
|
started_at = _parse_timestamp(state.created_at)
|
||||||
|
finished_at = _parse_timestamp(state.finished_at)
|
||||||
|
async with in_transaction():
|
||||||
|
match = await Match.create(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
game_type=state.game_type,
|
||||||
|
team_a_score=state.scores[0],
|
||||||
|
team_b_score=state.scores[1],
|
||||||
|
winner_team=TEAM_NAMES[state.winner],
|
||||||
|
target_score=state.target_score,
|
||||||
|
hands_played=state.hand_number,
|
||||||
|
started_at=started_at,
|
||||||
|
finished_at=finished_at,
|
||||||
|
)
|
||||||
|
for player in state.players:
|
||||||
|
await MatchPlayer.create(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
match=match,
|
||||||
|
user_sub=player.sub,
|
||||||
|
display_name=player.name,
|
||||||
|
seat=player.seat,
|
||||||
|
team=TEAM_NAMES[player.team],
|
||||||
|
won=player.team == state.winner,
|
||||||
|
)
|
||||||
|
state.stats_saved = True
|
||||||
|
log.info(
|
||||||
|
"match result persisted: game %s (%s), team %s won %d-%d over %d hands",
|
||||||
|
state.id,
|
||||||
|
state.game_type,
|
||||||
|
TEAM_NAMES[state.winner],
|
||||||
|
state.scores[0],
|
||||||
|
state.scores[1],
|
||||||
|
state.hand_number,
|
||||||
|
)
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"""Persistence for live games.
|
||||||
|
|
||||||
|
Game state is small, mutable and short-lived, which makes Redis a natural
|
||||||
|
fit: the whole match is a single JSON value under ``tavolo:game:<id>`` with
|
||||||
|
a sliding TTL, and a join-code index maps the short code a player shares to
|
||||||
|
that id. Completed matches are copied to Postgres (see
|
||||||
|
:mod:`tavolo.models`); Redis keeps serving the finished state until it
|
||||||
|
expires.
|
||||||
|
|
||||||
|
Two implementations satisfy the same interface:
|
||||||
|
|
||||||
|
* :class:`RedisGameStore` — production, used when ``REDIS_URL`` is set.
|
||||||
|
* :class:`InMemoryGameStore` — tests and ephemeral dev, used otherwise.
|
||||||
|
|
||||||
|
Concurrency is handled with a per-game lock so two simultaneous plays
|
||||||
|
cannot interleave. State changes are broadcast on a per-game pub/sub
|
||||||
|
channel as a simple "something changed" signal; every open websocket
|
||||||
|
reloads the state and renders the personalized view. Publishing only a
|
||||||
|
signal (never the state) means updated state reaches connections on every
|
||||||
|
worker without leaking hidden hands into the channel.
|
||||||
|
|
||||||
|
Timeouts (turn auto-play, hand-end auto-continue) are driven by a shared
|
||||||
|
delayed-deadline queue: producers enqueue an opaque ``member`` string with
|
||||||
|
a due timestamp, and a consumer on every worker polls for due entries.
|
||||||
|
Delivery is at-least-once — entries are removed only after they are
|
||||||
|
processed — so a worker dying mid-processing cannot lose a deadline;
|
||||||
|
consumers revalidate entries against the live state under the per-game
|
||||||
|
lock, which makes duplicate deliveries harmless.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from logging import getLogger
|
||||||
|
from typing import AsyncContextManager, AsyncIterator, Dict, List, Optional, Set, cast
|
||||||
|
|
||||||
|
from redis.asyncio import Redis
|
||||||
|
|
||||||
|
from .game.state import GameState
|
||||||
|
|
||||||
|
log = getLogger(__name__)
|
||||||
|
|
||||||
|
GAME_KEY_PREFIX = "tavolo:game:"
|
||||||
|
CODE_KEY_PREFIX = "tavolo:code:"
|
||||||
|
CHANNEL_PREFIX = "tavolo:game:"
|
||||||
|
DEADLINES_KEY = "tavolo:deadlines"
|
||||||
|
|
||||||
|
# Sentinel pushed into in-memory subscriber queues to signal a change.
|
||||||
|
_BUMP = b"update"
|
||||||
|
|
||||||
|
|
||||||
|
class GameStore(ABC):
|
||||||
|
"""Abstract persistence + notification layer for live games."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def load(self, game_id: str) -> Optional[GameState]:
|
||||||
|
"""Return the live state for ``game_id`` or ``None``."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def save(self, state: GameState) -> None:
|
||||||
|
"""Persist ``state``, refreshing its TTL and code index."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def find_by_code(self, code: str) -> Optional[GameState]:
|
||||||
|
"""Return the live state for a join ``code`` or ``None``."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def lock(self, game_id: str) -> AsyncContextManager[None]:
|
||||||
|
"""Async context manager serializing mutations of one game."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def subscribe(self, game_id: str) -> AsyncContextManager[AsyncIterator[None]]:
|
||||||
|
"""Async context manager yielding an async iterator of change signals."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def publish(self, game_id: str) -> None:
|
||||||
|
"""Signal that the state of ``game_id`` changed."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def add_deadline(self, member: str, due_at: float) -> None:
|
||||||
|
"""Enqueue ``member`` to fire at ``due_at`` (epoch seconds).
|
||||||
|
|
||||||
|
Idempotent for identical members: re-adding an existing member only
|
||||||
|
updates its due time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def due_deadlines(self, now: float, limit: int = 32) -> List[str]:
|
||||||
|
"""Return up to ``limit`` enqueued members due at or before ``now``."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def next_deadline(self) -> Optional[float]:
|
||||||
|
"""Return the earliest pending due time (epoch seconds), if any."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def remove_deadline(self, member: str) -> None:
|
||||||
|
"""Remove ``member`` from the queue; a no-op when absent."""
|
||||||
|
|
||||||
|
|
||||||
|
def _channel(game_id: str) -> str:
|
||||||
|
return f"{CHANNEL_PREFIX}{game_id}:events"
|
||||||
|
|
||||||
|
|
||||||
|
class RedisGameStore(GameStore):
|
||||||
|
def __init__(self, redis: Redis, ttl_seconds: int = 86400) -> None:
|
||||||
|
self._redis = redis
|
||||||
|
self._ttl = ttl_seconds
|
||||||
|
|
||||||
|
def lock(self, game_id: str):
|
||||||
|
# Lock and state use distinct key names; the lock expires on its own
|
||||||
|
# if a worker dies mid-mutation.
|
||||||
|
return self._redis.lock(f"{GAME_KEY_PREFIX}{game_id}:lock",
|
||||||
|
timeout=10, blocking_timeout=10)
|
||||||
|
|
||||||
|
async def load(self, game_id: str) -> Optional[GameState]:
|
||||||
|
|
||||||
|
raw = await self._redis.get(f"{GAME_KEY_PREFIX}{game_id}")
|
||||||
|
if raw is None:
|
||||||
|
log.debug("redis load %s: miss", game_id)
|
||||||
|
return None
|
||||||
|
if isinstance(raw, bytes):
|
||||||
|
raw = raw.decode("utf-8")
|
||||||
|
log.debug("redis load %s: hit", game_id)
|
||||||
|
return GameState.from_json(json.loads(raw))
|
||||||
|
|
||||||
|
async def save(self, state: GameState) -> None:
|
||||||
|
|
||||||
|
payload = json.dumps(state.to_json())
|
||||||
|
async with self._redis.pipeline(transaction=True) as pipe:
|
||||||
|
pipe.set(f"{GAME_KEY_PREFIX}{state.id}", payload, ex=self._ttl)
|
||||||
|
pipe.set(f"{CODE_KEY_PREFIX}{state.join_code}", state.id, ex=self._ttl)
|
||||||
|
await pipe.execute()
|
||||||
|
log.debug("redis save %s (phase %s, ttl %ds)", state.id, state.phase, self._ttl)
|
||||||
|
|
||||||
|
async def find_by_code(self, code: str) -> Optional[GameState]:
|
||||||
|
game_id = await self._redis.get(f"{CODE_KEY_PREFIX}{code.upper()}")
|
||||||
|
if game_id is None:
|
||||||
|
return None
|
||||||
|
if isinstance(game_id, bytes):
|
||||||
|
game_id = game_id.decode("utf-8")
|
||||||
|
return await self.load(str(game_id))
|
||||||
|
|
||||||
|
@contextlib.asynccontextmanager
|
||||||
|
async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]:
|
||||||
|
pubsub = self._redis.pubsub()
|
||||||
|
await pubsub.subscribe(_channel(game_id))
|
||||||
|
try:
|
||||||
|
yield _redis_events(pubsub)
|
||||||
|
finally:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await pubsub.unsubscribe(_channel(game_id))
|
||||||
|
await pubsub.aclose()
|
||||||
|
|
||||||
|
async def publish(self, game_id: str) -> None:
|
||||||
|
await self._redis.publish(_channel(game_id), "update")
|
||||||
|
log.debug("redis publish %s", game_id)
|
||||||
|
|
||||||
|
async def add_deadline(self, member: str, due_at: float) -> None:
|
||||||
|
await self._redis.zadd(DEADLINES_KEY, {member: due_at})
|
||||||
|
|
||||||
|
async def due_deadlines(self, now: float, limit: int = 32) -> List[str]:
|
||||||
|
members = cast(
|
||||||
|
list,
|
||||||
|
await self._redis.zrangebyscore(
|
||||||
|
DEADLINES_KEY, "-inf", now, start=0, num=limit
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return [m.decode("utf-8") if isinstance(m, bytes) else m for m in members]
|
||||||
|
|
||||||
|
async def next_deadline(self) -> Optional[float]:
|
||||||
|
earliest = await self._redis.zrange(DEADLINES_KEY, 0, 0, withscores=True)
|
||||||
|
return float(earliest[0][1]) if earliest else None
|
||||||
|
|
||||||
|
async def remove_deadline(self, member: str) -> None:
|
||||||
|
await self._redis.zrem(DEADLINES_KEY, member)
|
||||||
|
|
||||||
|
|
||||||
|
async def _redis_events(pubsub) -> AsyncIterator[None]:
|
||||||
|
async for message in pubsub.listen():
|
||||||
|
if message.get("type") == "message":
|
||||||
|
yield None
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryGameStore(GameStore):
|
||||||
|
"""Process-local store used by tests and when Redis is not configured."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._games: Dict[str, GameState] = {}
|
||||||
|
self._codes: Dict[str, str] = {}
|
||||||
|
self._locks: Dict[str, asyncio.Lock] = {}
|
||||||
|
self._subscribers: Dict[str, Set[asyncio.Queue]] = {}
|
||||||
|
self._deadlines: Dict[str, float] = {}
|
||||||
|
|
||||||
|
def _lock_for(self, game_id: str) -> asyncio.Lock:
|
||||||
|
lock = self._locks.get(game_id)
|
||||||
|
if lock is None:
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
self._locks[game_id] = lock
|
||||||
|
return lock
|
||||||
|
|
||||||
|
@contextlib.asynccontextmanager
|
||||||
|
async def lock(self, game_id: str) -> AsyncIterator[None]:
|
||||||
|
async with self._lock_for(game_id):
|
||||||
|
yield
|
||||||
|
|
||||||
|
async def load(self, game_id: str) -> Optional[GameState]:
|
||||||
|
state = self._games.get(game_id)
|
||||||
|
return GameState.from_json(state.to_json()) if state else None
|
||||||
|
|
||||||
|
async def save(self, state: GameState) -> None:
|
||||||
|
self._games[state.id] = GameState.from_json(state.to_json())
|
||||||
|
self._codes[state.join_code] = state.id
|
||||||
|
|
||||||
|
async def find_by_code(self, code: str) -> Optional[GameState]:
|
||||||
|
game_id = self._codes.get(code.upper())
|
||||||
|
if game_id is None:
|
||||||
|
return None
|
||||||
|
return await self.load(game_id)
|
||||||
|
|
||||||
|
@contextlib.asynccontextmanager
|
||||||
|
async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]:
|
||||||
|
queue: asyncio.Queue = asyncio.Queue()
|
||||||
|
self._subscribers.setdefault(game_id, set()).add(queue)
|
||||||
|
try:
|
||||||
|
yield _queue_events(queue)
|
||||||
|
finally:
|
||||||
|
subscribers = self._subscribers.get(game_id)
|
||||||
|
if subscribers is not None:
|
||||||
|
subscribers.discard(queue)
|
||||||
|
if not subscribers:
|
||||||
|
self._subscribers.pop(game_id, None)
|
||||||
|
|
||||||
|
async def publish(self, game_id: str) -> None:
|
||||||
|
for queue in list(self._subscribers.get(game_id, ())):
|
||||||
|
queue.put_nowait(_BUMP)
|
||||||
|
|
||||||
|
async def add_deadline(self, member: str, due_at: float) -> None:
|
||||||
|
self._deadlines[member] = due_at
|
||||||
|
|
||||||
|
async def due_deadlines(self, now: float, limit: int = 32) -> List[str]:
|
||||||
|
due = [m for m, due_at in self._deadlines.items() if due_at <= now]
|
||||||
|
due.sort(key=self._deadlines.__getitem__)
|
||||||
|
return due[:limit]
|
||||||
|
|
||||||
|
async def next_deadline(self) -> Optional[float]:
|
||||||
|
return min(self._deadlines.values(), default=None)
|
||||||
|
|
||||||
|
async def remove_deadline(self, member: str) -> None:
|
||||||
|
self._deadlines.pop(member, None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _queue_events(queue: asyncio.Queue) -> AsyncIterator[None]:
|
||||||
|
while True:
|
||||||
|
await queue.get()
|
||||||
|
yield None
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""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.
|
||||||
|
|
||||||
|
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 _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 _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
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"""WebSocket endpoint for live play.
|
||||||
|
|
||||||
|
Clients connect to ``/ws/games/{game_id}`` using their session cookie (the
|
||||||
|
OIDC login stores the user in the session, which the session mixin loads
|
||||||
|
onto the websocket). Only seated players are accepted.
|
||||||
|
|
||||||
|
Protocol
|
||||||
|
--------
|
||||||
|
Server -> client messages are JSON objects with a ``type``:
|
||||||
|
|
||||||
|
* ``state`` — the personalized game view (own hand visible, others hidden).
|
||||||
|
* ``game_over`` — sent once when the match ends, with the final scores.
|
||||||
|
* ``error`` — a rejected action or malformed message.
|
||||||
|
|
||||||
|
Client -> server messages are JSON objects::
|
||||||
|
|
||||||
|
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
|
||||||
|
{"action": "play", "card": "07D"}
|
||||||
|
{"action": "ack"}
|
||||||
|
{"action": "state"}
|
||||||
|
|
||||||
|
``capture`` lists the table cards to take and must be a legal capture when
|
||||||
|
one exists (see :func:`tavolo.game.engine.legal_captures`); it is omitted
|
||||||
|
when the played card cannot capture. ``ack`` acknowledges the hand-end
|
||||||
|
scoring summary; the next hand is dealt when all four players have
|
||||||
|
acknowledged or the timeout fires.
|
||||||
|
|
||||||
|
Mutations run under the per-game lock; after a successful move the new
|
||||||
|
state is saved to Redis and a change signal is published. Every connected
|
||||||
|
websocket is subscribed to that signal and re-renders the state, so all
|
||||||
|
players see the move immediately (and consistently across workers).
|
||||||
|
|
||||||
|
Timeouts do not depend on anyone being connected: both the per-turn
|
||||||
|
auto-play and the hand-end auto-continue are driven by the absolute
|
||||||
|
deadlines persisted on the game state, via the shared deadline queue
|
||||||
|
drained by a consumer on every worker (see :mod:`tavolo.deadlines`). A
|
||||||
|
disconnected or idle player therefore cannot stall the match, and a
|
||||||
|
worker dying cannot either.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from contextlib import suppress
|
||||||
|
from logging import getLogger
|
||||||
|
from typing import Any, Awaitable, Callable, Dict
|
||||||
|
|
||||||
|
from kaya.core import WebSocket
|
||||||
|
|
||||||
|
from . import auth, deadlines
|
||||||
|
from .app import app, game_store
|
||||||
|
from .game import engine
|
||||||
|
from .game.errors import GameError
|
||||||
|
from .game.state import PHASE_FINISHED, GameState
|
||||||
|
|
||||||
|
log = getLogger(__name__)
|
||||||
|
|
||||||
|
Send = Callable[[Dict[str, Any]], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
|
def _error(message: str, code: str = "invalid") -> Dict[str, Any]:
|
||||||
|
return {"type": "error", "code": code, "message": message}
|
||||||
|
|
||||||
|
|
||||||
|
def _state_message(state: GameState, sub: str) -> Dict[str, Any]:
|
||||||
|
return {"type": "state", "game": engine.state_for_player(state, sub)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/games/${game_id}")
|
||||||
|
async def game_socket(ws: WebSocket, game_id: str) -> None:
|
||||||
|
user = auth.get_ws_user(ws)
|
||||||
|
if user is None:
|
||||||
|
log.debug("websocket %s rejected: no authenticated user", game_id)
|
||||||
|
await ws.close(4401)
|
||||||
|
return
|
||||||
|
|
||||||
|
state = await game_store.load(game_id)
|
||||||
|
if state is None:
|
||||||
|
log.debug("websocket rejected: unknown game %s", game_id)
|
||||||
|
await ws.close(4404)
|
||||||
|
return
|
||||||
|
if not state.seated(user.sub):
|
||||||
|
log.debug("websocket %s rejected: %s is not seated", game_id, user.sub)
|
||||||
|
await ws.close(4403)
|
||||||
|
return
|
||||||
|
|
||||||
|
await ws.accept()
|
||||||
|
log.info("%s connected to game %s", user.sub, game_id)
|
||||||
|
|
||||||
|
send_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def send(payload: Dict[str, Any]) -> None:
|
||||||
|
async with send_lock:
|
||||||
|
await ws.send_text(json.dumps(payload))
|
||||||
|
|
||||||
|
await send(_state_message(state, user.sub))
|
||||||
|
# Backstop: make sure the current phase's deadline is queued even if
|
||||||
|
# its entry was lost (e.g. the queue was flushed while the game lived
|
||||||
|
# on thanks to its sliding TTL).
|
||||||
|
await deadlines.sync_deadline(game_store, state)
|
||||||
|
|
||||||
|
async with game_store.subscribe(game_id) as events:
|
||||||
|
forward = asyncio.create_task(
|
||||||
|
_forward(events, game_id, user.sub, send)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async for message in ws:
|
||||||
|
if message.kind == "close":
|
||||||
|
break
|
||||||
|
if message.kind != "text" or not isinstance(message.data, str):
|
||||||
|
await send(_error("expected a text frame with a JSON object"))
|
||||||
|
continue
|
||||||
|
await _handle_message(send, game_id, user.sub, message.data)
|
||||||
|
finally:
|
||||||
|
forward.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await forward
|
||||||
|
log.debug("%s disconnected from game %s", user.sub, game_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _forward(
|
||||||
|
events,
|
||||||
|
game_id: str,
|
||||||
|
sub: str,
|
||||||
|
send: Send,
|
||||||
|
) -> None:
|
||||||
|
async for _ in events:
|
||||||
|
state = await game_store.load(game_id)
|
||||||
|
if state is None:
|
||||||
|
return
|
||||||
|
await send(_state_message(state, sub))
|
||||||
|
if state.phase == PHASE_FINISHED:
|
||||||
|
await send(
|
||||||
|
{
|
||||||
|
"type": "game_over",
|
||||||
|
"scores": {"A": state.scores[0], "B": state.scores[1]},
|
||||||
|
"winner": "A" if state.winner == 0 else "B",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_message(send: Send, game_id: str, sub: str, raw: str) -> None:
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
log.debug("game %s: malformed message from %s (not JSON)", game_id, sub)
|
||||||
|
await send(_error("invalid JSON"))
|
||||||
|
return
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
log.debug("game %s: malformed message from %s (not an object)", game_id, sub)
|
||||||
|
await send(_error("message must be a JSON object"))
|
||||||
|
return
|
||||||
|
|
||||||
|
action = data.get("action")
|
||||||
|
if action == "play":
|
||||||
|
await _handle_play(send, game_id, sub, data)
|
||||||
|
elif action == "ack":
|
||||||
|
await _handle_ack(send, game_id, sub)
|
||||||
|
elif action in ("state", "sync"):
|
||||||
|
state = await game_store.load(game_id)
|
||||||
|
if state is not None:
|
||||||
|
await send(_state_message(state, sub))
|
||||||
|
else:
|
||||||
|
await send(_error(f"unknown action: {action!r}"))
|
||||||
|
|
||||||
|
|
||||||
|
# --- hand-end acknowledgement ------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_ack(send: Send, game_id: str, sub: str) -> None:
|
||||||
|
async with game_store.lock(game_id):
|
||||||
|
state = await game_store.load(game_id)
|
||||||
|
if state is None:
|
||||||
|
await send(_error("game not found", code="not_found"))
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
engine.acknowledge_hand(state, sub)
|
||||||
|
except GameError as exc:
|
||||||
|
await send(_error(str(exc), code="illegal_move"))
|
||||||
|
return
|
||||||
|
log.debug("game %s: %s acknowledged hand %d", game_id, sub, state.hand_number)
|
||||||
|
await game_store.save(state)
|
||||||
|
await game_store.publish(game_id)
|
||||||
|
# The fourth ack deals the next hand, which arms a new turn
|
||||||
|
# deadline; earlier acks change nothing and this is a no-op.
|
||||||
|
await deadlines.sync_deadline(game_store, state)
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_play(
|
||||||
|
send: Send, game_id: str, sub: str, data: Dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
card = data.get("card")
|
||||||
|
capture = data.get("capture")
|
||||||
|
if not isinstance(card, str):
|
||||||
|
await send(_error("'card' must be a card code string"))
|
||||||
|
return
|
||||||
|
if capture is not None and (
|
||||||
|
not isinstance(capture, list)
|
||||||
|
or any(not isinstance(item, str) for item in capture)
|
||||||
|
):
|
||||||
|
await send(_error("'capture' must be a list of card codes"))
|
||||||
|
return
|
||||||
|
|
||||||
|
async with game_store.lock(game_id):
|
||||||
|
state = await game_store.load(game_id)
|
||||||
|
if state is None:
|
||||||
|
await send(_error("game not found", code="not_found"))
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
engine.play(state, sub, card, capture)
|
||||||
|
except GameError as exc:
|
||||||
|
log.debug("game %s: illegal move by %s: %s", game_id, sub, exc)
|
||||||
|
await send(_error(str(exc), code="illegal_move"))
|
||||||
|
return
|
||||||
|
except ValueError:
|
||||||
|
log.debug("game %s: invalid card code from %s: %r", game_id, sub, card)
|
||||||
|
await send(_error("invalid card code", code="illegal_move"))
|
||||||
|
return
|
||||||
|
|
||||||
|
log.debug("game %s: %s played %s (capture: %s)", game_id, sub, card, capture or "-")
|
||||||
|
await deadlines.finalize_mutation(game_store, state)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""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_ISSUER", "http://localhost:8180/tavolo")
|
||||||
|
os.environ.setdefault("OIDC_CLIENT_ID", "tavolo")
|
||||||
|
os.environ.setdefault("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback")
|
||||||
|
# Unset REDIS_URL: sessions and live games use the in-memory fallbacks.
|
||||||
|
os.environ.pop("REDIS_URL", None)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""Test helpers package."""
|
||||||
|
from .oidc import make_user, oidc_user, ws_users
|
||||||
|
|
||||||
|
__all__ = ["make_user", "oidc_user", "ws_users"]
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Helpers for faking the OIDC authenticated user during tests.
|
||||||
|
|
||||||
|
HTTP handlers funnel through ``oidc_mixin.get_user``; websocket handlers
|
||||||
|
through :func:`tavolo.auth.get_ws_user`. Patching those two entry points
|
||||||
|
lets route and websocket tests run entirely in-process with no IdP.
|
||||||
|
"""
|
||||||
|
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.auth`` themselves. Importing ``auth`` before ``app`` would hit a
|
||||||
|
# partially initialized module (same constraint as reimpasto).
|
||||||
|
from tavolo.app import oidc_mixin
|
||||||
|
from tavolo import auth
|
||||||
|
|
||||||
|
|
||||||
|
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: patch ``oidc_mixin.get_user`` to return this user."""
|
||||||
|
user = make_user(sub, name)
|
||||||
|
patcher = _mock.patch.object(oidc_mixin, "get_user", return_value=user)
|
||||||
|
patcher.start()
|
||||||
|
try:
|
||||||
|
yield user
|
||||||
|
finally:
|
||||||
|
patcher.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def ws_users(users: Sequence[OIDCUser]) -> Iterator[None]:
|
||||||
|
"""Context manager: patch ``auth.get_ws_user`` to hand out ``users``
|
||||||
|
one per websocket connection, in order. Once exhausted it keeps
|
||||||
|
returning the last user."""
|
||||||
|
remaining = list(users)
|
||||||
|
last = remaining[-1] if remaining else None
|
||||||
|
|
||||||
|
def _next(_ws):
|
||||||
|
if remaining:
|
||||||
|
return remaining.pop(0)
|
||||||
|
return last
|
||||||
|
|
||||||
|
patcher = _mock.patch.object(auth, "get_ws_user", side_effect=_next)
|
||||||
|
patcher.start()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
patcher.stop()
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""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",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"""Deadline-queue timeout tests.
|
||||||
|
|
||||||
|
Timeouts must be driven by the persisted deadlines and the shared queue,
|
||||||
|
not by connected sockets: these tests seed games, queue their deadlines
|
||||||
|
and let the background consumer fire them without a single websocket.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pwo import async_test
|
||||||
|
|
||||||
|
from tavolo import deadlines
|
||||||
|
from tavolo.app import game_store
|
||||||
|
from tavolo.game import engine
|
||||||
|
from tavolo.game.state import GameState, PlayerState
|
||||||
|
|
||||||
|
PLAYERS = ("alice", "bob", "carol", "dave")
|
||||||
|
|
||||||
|
|
||||||
|
def _ms(iso: str) -> int:
|
||||||
|
"""Epoch milliseconds for an ISO-8601 timestamp (the queue-entry form)."""
|
||||||
|
return int(datetime.fromisoformat(iso).timestamp() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def _hand_end_state(game_id: str, deadline: str) -> GameState:
|
||||||
|
"""A game paused on the hand-end summary, waiting for acks."""
|
||||||
|
state = GameState(
|
||||||
|
id=game_id,
|
||||||
|
join_code="DLhend",
|
||||||
|
creator_sub="alice",
|
||||||
|
target_score=11,
|
||||||
|
phase="hand_end",
|
||||||
|
# Long turn timeout: the next hand's auto-play must not interfere
|
||||||
|
# with later tests sharing this store.
|
||||||
|
turn_timeout=3600,
|
||||||
|
)
|
||||||
|
state.players = [
|
||||||
|
PlayerState(sub=name, name=name.capitalize(), seat=i)
|
||||||
|
for i, name in enumerate(PLAYERS)
|
||||||
|
]
|
||||||
|
state.hand_end_deadline = deadline
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
async def _wait_for(predicate, timeout: float = 5.0) -> Optional[GameState]:
|
||||||
|
"""Poll the store until ``predicate`` holds for the loaded state."""
|
||||||
|
deadline = asyncio.get_running_loop().time() + timeout
|
||||||
|
while asyncio.get_running_loop().time() < deadline:
|
||||||
|
state = await predicate()
|
||||||
|
if state is not None:
|
||||||
|
return state
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionIndependenceTest(unittest.TestCase):
|
||||||
|
@async_test
|
||||||
|
async def test_turn_timeout_fires_with_no_connections(self) -> None:
|
||||||
|
state = engine.create_game(
|
||||||
|
"dl-turn-1", "DLT001", "alice", "Alice",
|
||||||
|
target_score=11, turn_timeout=1,
|
||||||
|
)
|
||||||
|
for name in PLAYERS[1:]:
|
||||||
|
engine.join_game(state, name, name.capitalize())
|
||||||
|
assert state.turn_deadline is not None
|
||||||
|
await game_store.save(state)
|
||||||
|
await deadlines.sync_deadline(game_store, state)
|
||||||
|
|
||||||
|
# Nobody ever connects: the consumer must still auto-play for Bob
|
||||||
|
# (seat 1, first to act).
|
||||||
|
result = await _wait_for(
|
||||||
|
lambda: _turn_is(state.id, 2),
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(result, "turn deadline never fired")
|
||||||
|
assert result is not None
|
||||||
|
self.assertEqual(1, result.last_move.seat if result.last_move else None)
|
||||||
|
|
||||||
|
# Defuse the follow-on turn deadlines so this game cannot keep
|
||||||
|
# auto-playing while later tests run.
|
||||||
|
result.turn_timeout = 3600
|
||||||
|
await game_store.save(result)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_hand_end_timeout_fires_with_no_connections(self) -> None:
|
||||||
|
deadline = (datetime.now(timezone.utc) + timedelta(seconds=1)).isoformat()
|
||||||
|
state = _hand_end_state("dl-handend-1", deadline)
|
||||||
|
await game_store.save(state)
|
||||||
|
await deadlines.sync_deadline(game_store, state)
|
||||||
|
|
||||||
|
# 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.hand_number)
|
||||||
|
self.assertEqual([], result.acked)
|
||||||
|
|
||||||
|
|
||||||
|
async def _turn_is(game_id: str, turn: int) -> Optional[GameState]:
|
||||||
|
state = await game_store.load(game_id)
|
||||||
|
return state if state is not None and state.turn == turn else None
|
||||||
|
|
||||||
|
|
||||||
|
async def _phase_is(game_id: str, phase: str) -> Optional[GameState]:
|
||||||
|
state = await game_store.load(game_id)
|
||||||
|
return state if state is not None and state.phase == phase else None
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessDueTest(unittest.TestCase):
|
||||||
|
"""Direct ``process_due`` behaviour: 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.
|
||||||
|
deadline = (datetime.now(timezone.utc) - timedelta(seconds=1)).isoformat()
|
||||||
|
state = _hand_end_state("dl-idem-1", deadline)
|
||||||
|
await game_store.save(state)
|
||||||
|
member = deadlines.encode({
|
||||||
|
"game_id": state.id,
|
||||||
|
"kind": deadlines.KIND_HAND_END,
|
||||||
|
"hand": state.hand_number,
|
||||||
|
"deadline": _ms(deadline),
|
||||||
|
})
|
||||||
|
|
||||||
|
await deadlines.process_due(game_store, member)
|
||||||
|
await deadlines.process_due(game_store, member)
|
||||||
|
|
||||||
|
result = await game_store.load(state.id)
|
||||||
|
assert result is not None
|
||||||
|
# Advanced exactly once: hand 2, not hand 3.
|
||||||
|
self.assertEqual("playing", result.phase)
|
||||||
|
self.assertEqual(2, result.hand_number)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_stale_entry_is_discarded(self) -> None:
|
||||||
|
# A turn entry enqueued before a play landed in time: the state's
|
||||||
|
# deadline has moved, so the entry must not fire.
|
||||||
|
state = engine.create_game(
|
||||||
|
"dl-stale-1", "DLS001", "alice", "Alice",
|
||||||
|
target_score=11, turn_timeout=3600,
|
||||||
|
)
|
||||||
|
for name in PLAYERS[1:]:
|
||||||
|
engine.join_game(state, name, name.capitalize())
|
||||||
|
await game_store.save(state)
|
||||||
|
member = deadlines.encode({
|
||||||
|
"game_id": state.id,
|
||||||
|
"kind": deadlines.KIND_TURN,
|
||||||
|
"hand": state.hand_number,
|
||||||
|
"turn": state.turn,
|
||||||
|
# Not the live deadline (epoch milliseconds).
|
||||||
|
"deadline": 946684800000,
|
||||||
|
})
|
||||||
|
await game_store.add_deadline(member, due_at=0.0)
|
||||||
|
|
||||||
|
await deadlines.process_due(game_store, member)
|
||||||
|
|
||||||
|
result = await game_store.load(state.id)
|
||||||
|
assert result is not None
|
||||||
|
self.assertEqual(state.turn, result.turn)
|
||||||
|
# The entry was removed after processing.
|
||||||
|
self.assertNotIn(member, await game_store.due_deadlines(float("inf")))
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_entry_for_expired_game_is_dropped(self) -> None:
|
||||||
|
member = deadlines.encode({
|
||||||
|
"game_id": "dl-gone",
|
||||||
|
"kind": deadlines.KIND_TURN,
|
||||||
|
"hand": 1,
|
||||||
|
"turn": 0,
|
||||||
|
"deadline": 946684800000,
|
||||||
|
})
|
||||||
|
await game_store.add_deadline(member, due_at=0.0)
|
||||||
|
await deadlines.process_due(game_store, member)
|
||||||
|
self.assertNotIn(member, await game_store.due_deadlines(float("inf")))
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_malformed_entry_is_dropped(self) -> None:
|
||||||
|
await game_store.add_deadline("not json", due_at=0.0)
|
||||||
|
await deadlines.process_due(game_store, "not json")
|
||||||
|
self.assertNotIn("not json", await game_store.due_deadlines(float("inf")))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
"""Rule engine tests: captures, scope, scoring and full-match simulation."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from tavolo.game import engine
|
||||||
|
from tavolo.game.errors import (
|
||||||
|
CardNotInHand,
|
||||||
|
GameFinished,
|
||||||
|
GameNotStarted,
|
||||||
|
IllegalMove,
|
||||||
|
NotYourTurn,
|
||||||
|
)
|
||||||
|
from tavolo.game.state import (
|
||||||
|
PHASE_FINISHED,
|
||||||
|
PHASE_PLAYING,
|
||||||
|
Card,
|
||||||
|
GameState,
|
||||||
|
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,
|
||||||
|
) -> GameState:
|
||||||
|
"""Build a controlled game state directly (bypassing the deal)."""
|
||||||
|
state = GameState(
|
||||||
|
id="game-1",
|
||||||
|
join_code="ABC123",
|
||||||
|
creator_sub="p0",
|
||||||
|
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(GameState.from_json(state.to_json()).napola)
|
||||||
|
# States serialized before the option existed default to enabled.
|
||||||
|
data = state.to_json()
|
||||||
|
del data["napola"]
|
||||||
|
self.assertTrue(GameState.from_json(data).napola)
|
||||||
|
|
||||||
|
def test_create_game_napola_default_and_override(self) -> None:
|
||||||
|
self.assertTrue(engine.create_game("g", "CODE42", "p0", "p0").napola)
|
||||||
|
self.assertFalse(
|
||||||
|
engine.create_game("g", "CODE42", "p0", "p0", napola=False).napola
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MatchFlowTest(unittest.TestCase):
|
||||||
|
def test_join_starts_when_full(self) -> None:
|
||||||
|
state = engine.create_game("g", "CODE42", "p0", "p0", target_score=11)
|
||||||
|
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("g", "CODE99", "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))
|
||||||
|
self.assertTrue(state.finished_at)
|
||||||
|
|
||||||
|
|
||||||
|
class HandEndAckTest(unittest.TestCase):
|
||||||
|
def _hand_end_state(self) -> GameState:
|
||||||
|
"""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("g", "CODE98", "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()
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
root = logging.getLogger()
|
||||||
|
root.handlers = self._root_handlers
|
||||||
|
root.level = self._root_level
|
||||||
|
logging.getLogger("tavolo").level = self._tavolo_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",
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"""Game lobby route tests via kaya's ASGI transport."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from pwo import async_test
|
||||||
|
|
||||||
|
from tavolo.app import app
|
||||||
|
from tests.helpers import oidc_user
|
||||||
|
|
||||||
|
|
||||||
|
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={"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={"napola": False})
|
||||||
|
self.assertEqual(201, disabled.status_code)
|
||||||
|
self.assertFalse(disabled.json()["napola"])
|
||||||
|
|
||||||
|
with oidc_user("alice"):
|
||||||
|
invalid = await client.post("/api/games", json={"napola": "yes"})
|
||||||
|
self.assertEqual(400, invalid.status_code)
|
||||||
|
|
||||||
|
@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={"target_score": 0})
|
||||||
|
text = await client.post("/api/games", json={"target_score": "eleven"})
|
||||||
|
huge = await client.post("/api/games", json={"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"])
|
||||||
|
|
||||||
|
@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()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user