Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bb6b74fdc
|
||
|
|
b5219062f2
|
||
|
|
adc3d340b1 | ||
|
|
915a294dc8 | ||
|
|
3abbbc6b68 | ||
|
|
b43e074728 | ||
|
|
e344ce2e30 | ||
|
|
e9909cb317 | ||
|
|
2910fc4600
|
||
|
|
21911caf9e
|
||
|
|
08f158ef28
|
||
|
|
834f1f26a9
|
||
|
|
bcd1371019
|
||
|
|
3cf7e2e257
|
||
|
|
2bdf247cb3
|
||
|
|
a3e8ff3206
|
||
|
|
98d4485375
|
||
|
|
5be4926885
|
||
|
|
a3566f2083
|
||
|
|
cc0b31ce6b
|
||
|
|
8f0e974842
|
||
|
|
abc55479e4
|
||
|
|
b867dd1619
|
||
|
|
12fa3f5aae
|
||
|
|
ed8a004ec8
|
||
|
|
2b738ea2fe
|
||
|
|
83ecf9bed3
|
||
|
|
4b66407fa4
|
||
|
|
7690251760
|
||
|
|
d68dffaf2f
|
||
|
|
af480bb9b7
|
||
|
|
93d041b004
|
@@ -1,13 +1,21 @@
|
|||||||
# tavolo
|
# tavolo
|
||||||
|
|
||||||
A platform for multiplayer card games. The first game is **scopone
|
A platform for multiplayer card games. Two games are included:
|
||||||
scientifico** — the four-player, fixed-partnership Italian card game:
|
**scopone scientifico** — the four-player, fixed-partnership Italian card
|
||||||
|
game — and **briscola**, in its classic two-player and four-player
|
||||||
|
partnership (*briscola a quattro*) variants:
|
||||||
|
|
||||||
- **`server/`** — backend: Python + [kaya](https://github.com/woggioni/kaya)
|
- **`server/`** — backend: Python + [kaya](https://github.com/woggioni/kaya)
|
||||||
framework, OIDC login, live games in Redis, match statistics in Postgres.
|
framework, OIDC login, live games in Redis, match statistics in Postgres.
|
||||||
See [server/README.md](server/README.md).
|
A monorepo of four distributions sharing the `tavolo` namespace: the
|
||||||
|
game-independent `tavolo-platform`, the `tavolo-scopone` and
|
||||||
|
`tavolo-briscola` games, and the `tavolo-app` composition root wiring
|
||||||
|
them together. See [server/README.md](server/README.md).
|
||||||
- **`web/`** — frontend: Rust + [Sycamore](https://sycamore.dev) compiled to
|
- **`web/`** — frontend: Rust + [Sycamore](https://sycamore.dev) compiled to
|
||||||
WebAssembly, built with [Trunk](https://trunkrs.dev). Card images are the
|
WebAssembly, built with [Trunk](https://trunkrs.dev) and styled with
|
||||||
|
[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)).
|
||||||
|
|
||||||
@@ -31,20 +39,30 @@ echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
|
|||||||
## Between hands
|
## Between hands
|
||||||
|
|
||||||
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**: scopone players see how each category was won
|
||||||
(carte, denara, settebello, primiera, scope — plus napola when enabled)
|
(carte, denara, settebello, primiera, scope — plus napola and asso
|
||||||
with the running totals and
|
pigliatutto when enabled), briscola players see the deal's points and any
|
||||||
must click "Understood" before the next hand is dealt. If someone is away
|
*patta*; both see the running totals and must click "Understood" before
|
||||||
the next hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS`
|
the next hand is dealt. If someone is away the next hand is dealt
|
||||||
(default 30s). The match-ending hand is explained on the final screen.
|
automatically after `HAND_ACK_TIMEOUT_SECONDS` (default 30s). The
|
||||||
|
match-ending hand is explained on the final screen.
|
||||||
|
|
||||||
## On your turn
|
## On your turn
|
||||||
|
|
||||||
Every turn shows a countdown (`TURN_TIMEOUT_SECONDS`, default 30s). If a
|
Every turn shows a countdown (`TURN_TIMEOUT_SECONDS`, default 30s). If a
|
||||||
player does not move — disconnected or fallen asleep — the server plays a
|
player does not move — disconnected or fallen asleep — the server plays a
|
||||||
random legal card for them (randomizing among the legal captures when the
|
random legal card for them (choosing among the legal captures when the
|
||||||
rules require a capture), so one absent player cannot stall the table.
|
rules require a capture), so one absent player cannot stall the table.
|
||||||
|
|
||||||
|
## Watching
|
||||||
|
|
||||||
|
Anyone who has the game URL (`/game/<id>`) can watch, logged in or not:
|
||||||
|
spectators see the table, the score and every player's captured cards
|
||||||
|
(and scope for scopone), but never a hand and never the join code. The
|
||||||
|
"Copy watch link" button in the waiting room and in the game HUD copies
|
||||||
|
the URL to share. A spectator who opens the URL before the match starts
|
||||||
|
sees a "game not started yet" waiting room.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Backend (from `server/`):
|
Backend (from `server/`):
|
||||||
@@ -53,9 +71,11 @@ Backend (from `server/`):
|
|||||||
cd server
|
cd server
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
.venv/bin/pip install -r requirements.txt
|
.venv/bin/pip install -r requirements.txt
|
||||||
.venv/bin/pip install -e '.[dev]'
|
.venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e ./packages/tavolo-briscola -e './packages/tavolo-app[dev]'
|
||||||
.venv/bin/python -m unittest discover -s tests -t .
|
.venv/bin/python -m unittest discover -s tests -t .
|
||||||
.venv/bin/mypy src
|
.venv/bin/python -m unittest discover -s packages/tavolo-platform/tests
|
||||||
|
.venv/bin/python -m unittest discover -s packages/tavolo-scopone/tests
|
||||||
|
.venv/bin/python -m unittest discover -s packages/tavolo-briscola/tests
|
||||||
```
|
```
|
||||||
|
|
||||||
Frontend (from `web/`), with the backend running on :8080:
|
Frontend (from `web/`), with the backend running on :8080:
|
||||||
@@ -70,8 +90,12 @@ Trunk proxies `/api`, `/auth` and `/ws` to `127.0.0.1:8080` (see
|
|||||||
run the backend with:
|
run the backend with:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
OIDC_POST_LOGIN_REDIRECT=http://localhost:8000/ \
|
OIDC_0_PROVIDER=default \
|
||||||
OIDC_POST_LOGOUT_REDIRECT=http://localhost:8000/ \
|
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/ \
|
||||||
.venv/bin/granian --host 127.0.0.1 --port 8080 tavolo.app:app
|
.venv/bin/granian --host 127.0.0.1 --port 8080 tavolo.app:app
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+27
-10
@@ -21,7 +21,7 @@
|
|||||||
# - 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_REDIRECT_URI to match whatever URL the browser uses.
|
# and set OIDC_0_REDIRECT_URI to match whatever URL the browser uses.
|
||||||
|
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
@@ -71,15 +71,32 @@ data:
|
|||||||
# CORS_ALLOW_CREDENTIALS: "false"
|
# CORS_ALLOW_CREDENTIALS: "false"
|
||||||
# CORS_EXPOSE_HEADERS: ""
|
# CORS_EXPOSE_HEADERS: ""
|
||||||
# CORS_MAX_AGE: "600"
|
# CORS_MAX_AGE: "600"
|
||||||
# OIDC (provider lives in another namespace).
|
# OpenTelemetry (kaya-otel + contrib instrumentations): traces
|
||||||
OIDC_CLIENT_ID: tavolo
|
# (HTTP/WebSocket, Postgres, Redis), metrics and logs via OTLP/HTTP,
|
||||||
OIDC_POST_LOGIN_REDIRECT: /
|
# disabled unless OTEL_ENABLED is set. The image bundles the otel extra.
|
||||||
OIDC_POST_LOGOUT_REDIRECT: /
|
# 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_ISSUER: REPLACE_ME
|
OIDC_0_ISSUER: REPLACE_ME
|
||||||
# Public callback URL of this deployment, e.g.
|
# Public callback URL of this deployment, e.g.
|
||||||
# http://<host>:<port>/auth/callback (must be allowed at the provider).
|
# http://<host>:<port>/auth/default/callback (must be allowed at the
|
||||||
OIDC_REDIRECT_URI: REPLACE_ME
|
# provider). When unset it defaults to
|
||||||
|
# http://localhost:8080/auth/<provider>/callback.
|
||||||
|
OIDC_0_REDIRECT_URI: REPLACE_ME
|
||||||
|
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
@@ -96,8 +113,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_CLIENT_ID at the provider.
|
# Client secret for OIDC_0_CLIENT_ID at the provider.
|
||||||
OIDC_CLIENT_SECRET: REPLACE_ME
|
OIDC_0_CLIENT_SECRET: REPLACE_ME
|
||||||
|
|
||||||
---
|
---
|
||||||
# Redis: sessions + live game state. Ephemeral by design (emptyDir): worst
|
# Redis: sessions + live game state. Ephemeral by design (emptyDir): worst
|
||||||
|
|||||||
+18
-6
@@ -103,13 +103,15 @@ 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 OIDC_ISSUER and
|
# name (see README /etc/hosts note); override the OIDC_0_* values to
|
||||||
# OIDC_REDIRECT_URI to use a real provider or a different host port.
|
# use a real provider or a different host port. Additional providers
|
||||||
OIDC_ISSUER: ${OIDC_ISSUER:-http://mockoauth:8180/tavolo}
|
# take OIDC_1_*, OIDC_2_*, ... blocks (indices are contiguous from 0).
|
||||||
|
OIDC_0_PROVIDER: ${OIDC_0_PROVIDER:-default}
|
||||||
|
OIDC_0_ISSUER: ${OIDC_0_ISSUER:-http://mockoauth:8180/tavolo}
|
||||||
# The mock OIDC server does not validate clients: any id/secret works.
|
# The mock OIDC server does not validate clients: any id/secret works.
|
||||||
OIDC_CLIENT_ID: tavolo
|
OIDC_0_CLIENT_ID: tavolo
|
||||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-dev-secret}
|
OIDC_0_CLIENT_SECRET: ${OIDC_0_CLIENT_SECRET:-dev-secret}
|
||||||
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:8080/auth/callback}
|
OIDC_0_REDIRECT_URI: ${OIDC_0_REDIRECT_URI:-http://localhost:8080/auth/default/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}
|
||||||
@@ -122,6 +124,16 @@ services:
|
|||||||
CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-}
|
CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-}
|
||||||
CORS_EXPOSE_HEADERS: ${CORS_EXPOSE_HEADERS:-}
|
CORS_EXPOSE_HEADERS: ${CORS_EXPOSE_HEADERS:-}
|
||||||
CORS_MAX_AGE: ${CORS_MAX_AGE:-}
|
CORS_MAX_AGE: ${CORS_MAX_AGE:-}
|
||||||
|
# OpenTelemetry (kaya-otel + contrib instrumentations): traces
|
||||||
|
# (HTTP/WebSocket, Postgres, Redis), metrics and logs via OTLP/HTTP,
|
||||||
|
# disabled unless OTEL_ENABLED is set.
|
||||||
|
# The image bundles the otel extra (see packages/tavolo-app/pyproject.toml
|
||||||
|
# and server/requirements.txt).
|
||||||
|
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"
|
||||||
|
|
||||||
|
|||||||
+40
-6
@@ -13,12 +13,29 @@ DATABASE_OPTIONS=
|
|||||||
# test suite (sqlite://:memory:) and handy for managed-DB URLs.
|
# 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 (mock-oauth2-server in dev; it does not validate clients, so any
|
# OIDC providers. Each provider is an indexed block: OIDC_<n>_PROVIDER
|
||||||
# client id/secret works. For a real IdP like Keycloak, use its values here.)
|
# (required; names the provider in the login URL /auth/<provider>/login and
|
||||||
OIDC_ISSUER=http://localhost:8180/tavolo
|
# in its session state), OIDC_<n>_ISSUER, OIDC_<n>_CLIENT_ID,
|
||||||
OIDC_CLIENT_ID=tavolo
|
# OIDC_<n>_CLIENT_SECRET (optional: leave unset for public clients),
|
||||||
OIDC_CLIENT_SECRET=dev-secret
|
# OIDC_<n>_REDIRECT_URI (defaults to
|
||||||
OIDC_REDIRECT_URI=http://localhost:8080/auth/callback
|
# http://localhost:8080/auth/<provider>/callback), OIDC_<n>_POST_LOGIN_REDIRECT
|
||||||
|
# and OIDC_<n>_POST_LOGOUT_REDIRECT (default /). Indices must be contiguous
|
||||||
|
# from 0; add a second provider with an OIDC_1_* block (and so on).
|
||||||
|
#
|
||||||
|
# The values below target the dev mock-oauth2-server (it does not validate
|
||||||
|
# clients, so any client id/secret works). For a real IdP like Keycloak,
|
||||||
|
# use its values and register the redirect URI there.
|
||||||
|
OIDC_0_PROVIDER=default
|
||||||
|
OIDC_0_ISSUER=http://localhost:8180/tavolo
|
||||||
|
OIDC_0_CLIENT_ID=tavolo
|
||||||
|
OIDC_0_CLIENT_SECRET=dev-secret
|
||||||
|
OIDC_0_REDIRECT_URI=http://localhost:8080/auth/default/callback
|
||||||
|
# A second provider, e.g. Google:
|
||||||
|
#OIDC_1_PROVIDER=google
|
||||||
|
#OIDC_1_ISSUER=https://accounts.google.com
|
||||||
|
#OIDC_1_CLIENT_ID=...
|
||||||
|
#OIDC_1_CLIENT_SECRET=...
|
||||||
|
#OIDC_1_REDIRECT_URI=http://localhost:8080/auth/google/callback
|
||||||
|
|
||||||
# Redis (session and live-game storage). Unset to fall back to in-memory
|
# 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).
|
||||||
@@ -61,6 +78,23 @@ TURN_TIMEOUT_SECONDS=30
|
|||||||
# Seconds browsers may cache the preflight response (default 600):
|
# Seconds browsers may cache the preflight response (default 600):
|
||||||
#CORS_MAX_AGE=600
|
#CORS_MAX_AGE=600
|
||||||
|
|
||||||
|
# OpenTelemetry (via kaya-otel + the contrib instrumentation packages).
|
||||||
|
# Off unless OTEL_ENABLED is truthy; exports traces (HTTP/WebSocket,
|
||||||
|
# Postgres via asyncpg, Redis), metrics and logs over OTLP/HTTP (requires
|
||||||
|
# the tavolo-app "otel" extra, bundled in the Docker image). The endpoint
|
||||||
|
# defaults to the standard OTEL_EXPORTER_OTLP_ENDPOINT (localhost:4318)
|
||||||
|
# when unset.
|
||||||
|
#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
|
||||||
APP_PORT=8080
|
APP_PORT=8080
|
||||||
|
|||||||
+14
-4
@@ -29,8 +29,11 @@ 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/Trunk.toml ./
|
COPY web/Cargo.toml web/Cargo.lock web/index.html web/style.css web/theme.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 Granian
|
# --public-url makes trunk emit asset URLs under /static, the prefix Granian
|
||||||
@@ -55,10 +58,17 @@ RUN --mount=type=cache,target=/root/.cache/pip \
|
|||||||
&& /opt/venv/bin/pip install --upgrade pip \
|
&& /opt/venv/bin/pip install --upgrade pip \
|
||||||
&& /opt/venv/bin/pip install -r requirements.txt
|
&& /opt/venv/bin/pip install -r requirements.txt
|
||||||
|
|
||||||
COPY server/pyproject.toml server/README.md ./
|
# The four sibling distributions, installed from the local tree (PEP 420
|
||||||
COPY server/src/ ./src/
|
# namespace packages sharing the `tavolo` namespace). --no-deps: every
|
||||||
|
# third-party dependency — including the tavolo-app `otel` extra — is
|
||||||
|
# already pinned in requirements.txt.
|
||||||
|
COPY server/packages/ ./packages/
|
||||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||||
/opt/venv/bin/pip install .
|
/opt/venv/bin/pip install --no-deps ./packages/tavolo-platform ./packages/tavolo-scopone ./packages/tavolo-briscola ./packages/tavolo-app
|
||||||
|
# server/pyproject.toml only carries the [tool.aerich] section (the
|
||||||
|
# db-migrate compose service runs `aerich upgrade` with working_dir=/app,
|
||||||
|
# next to ./migrations).
|
||||||
|
COPY server/pyproject.toml ./
|
||||||
# aerich migration files are a release artifact: the db-migrate compose
|
# aerich migration files are a release artifact: the db-migrate compose
|
||||||
# service runs `aerich upgrade` from this image before the app starts.
|
# service runs `aerich upgrade` from this image before the app starts.
|
||||||
COPY server/migrations/ ./migrations/
|
COPY server/migrations/ ./migrations/
|
||||||
|
|||||||
+306
-85
@@ -1,14 +1,32 @@
|
|||||||
# tavolo
|
# tavolo (backend)
|
||||||
|
|
||||||
The backend for a multiplayer card-game platform, built on the
|
The backend for a multiplayer card-game platform, built on the
|
||||||
[kaya](../kaya) framework. The first game implemented is **scopone
|
[kaya](../kaya) framework. It is a monorepo of four distributions
|
||||||
scientifico**, the four-player, fixed-partnership variant of the classic
|
sharing the `tavolo` namespace (see
|
||||||
Italian card game.
|
[`packages/tavolo-platform/README.md`](packages/tavolo-platform/README.md),
|
||||||
|
[`packages/tavolo-scopone/README.md`](packages/tavolo-scopone/README.md)
|
||||||
|
and
|
||||||
|
[`packages/tavolo-briscola/README.md`](packages/tavolo-briscola/README.md)):
|
||||||
|
|
||||||
Players authenticate with the configured **OIDC** provider. Live game state
|
- **`tavolo-platform`** (`packages/tavolo-platform/`) — everything
|
||||||
is kept in **Redis** (with real-time play over WebSocket), and completed
|
game-independent: the lobby API, the live-game store, the WebSocket
|
||||||
matches — every participant and the final score — are persisted to
|
endpoint, the deadline scheduler, match persistence and the Elo
|
||||||
**Postgres** for history and leaderboard statistics.
|
leaderboards, plus the `GameEngine` contract games implement.
|
||||||
|
- **`tavolo-scopone`** (`packages/tavolo-scopone/`) — scopone
|
||||||
|
scientifico, the four-player, fixed-partnership variant of the classic
|
||||||
|
Italian card game: pure rules plus the platform adapter.
|
||||||
|
- **`tavolo-briscola`** (`packages/tavolo-briscola/`) — briscola, the
|
||||||
|
Italian trick-taking game, in its classic two-player (`briscola`) and
|
||||||
|
four-player partnership (`briscola_a_quattro`) variants: pure rules
|
||||||
|
plus the platform adapter.
|
||||||
|
- **`tavolo-app`** (`packages/tavolo-app/`) — the
|
||||||
|
composition root wiring the platform to the games, with the
|
||||||
|
environment-driven settings and the SPA shell.
|
||||||
|
|
||||||
|
Players authenticate with the configured **OIDC** provider. Live game
|
||||||
|
state is kept in **Redis** (with real-time play over WebSocket), and
|
||||||
|
completed matches — every participant and the game-specific result — are
|
||||||
|
persisted to **Postgres** for history and leaderboard statistics.
|
||||||
|
|
||||||
## Quick start (Docker Compose)
|
## Quick start (Docker Compose)
|
||||||
|
|
||||||
@@ -31,7 +49,8 @@ Because the browser and the app both talk to the OIDC issuer at
|
|||||||
echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
|
echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
|
||||||
```
|
```
|
||||||
|
|
||||||
Then log in: `GET http://localhost:8080/auth/login`, type any username
|
Then log in: `GET http://localhost:8080/auth/login` (a provider-agnostic
|
||||||
|
alias that redirects to the first configured provider), type any username
|
||||||
(e.g. `alice`) in the interactive login, and you are redirected back with a
|
(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.
|
||||||
@@ -53,14 +72,17 @@ All configuration comes from environment variables (see `.env.example`):
|
|||||||
| `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 (used for sqlite in tests and for 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_ISSUER` | `http://localhost:8180/tavolo` | OIDC issuer URL |
|
| `OIDC_<n>_PROVIDER` | `default` ¹ | Provider name (required when OIDC is configured): used in the login URL `/auth/<provider>/login` and to namespace its session keys. Unique, `[A-Za-z0-9_-]+` |
|
||||||
| `OIDC_CLIENT_ID` | `tavolo` | OIDC client id |
|
| `OIDC_<n>_ISSUER` | `http://localhost:8180/tavolo` ¹ | OIDC issuer URL |
|
||||||
| `OIDC_CLIENT_SECRET` | unset | OIDC client secret |
|
| `OIDC_<n>_CLIENT_ID` | `tavolo` ¹ | OIDC client id |
|
||||||
| `OIDC_REDIRECT_URI` | `http://localhost:8080/auth/callback` | Login callback URL |
|
| `OIDC_<n>_CLIENT_SECRET` | unset | OIDC client secret; leave unset for public clients |
|
||||||
|
| `OIDC_<n>_REDIRECT_URI` | `http://localhost:8080/auth/<provider>/callback` | Login callback URL; must be registered at the provider |
|
||||||
|
| `OIDC_<n>_POST_LOGIN_REDIRECT` | `/` | Where the browser is sent after login |
|
||||||
|
| `OIDC_<n>_POST_LOGOUT_REDIRECT` | `/` | Where the browser is sent after logout |
|
||||||
| `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis |
|
| `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis |
|
||||||
| `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the between-hands scoring summary waits for acknowledgements |
|
| `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the between-hands scoring summary waits for acknowledgements (wired into the game engines) |
|
||||||
| `TURN_TIMEOUT_SECONDS` | `30` | Seconds a player has to play before the server plays a random legal card for them |
|
| `TURN_TIMEOUT_SECONDS` | `30` | Seconds a player has to play before the server plays a random legal card for them (wired into the game engines) |
|
||||||
| `DEADLINE_HEARTBEAT_MS` | `1000` | Upper bound on the deadline consumer's poll interval (locally enqueued deadlines fire on time regardless) |
|
| `DEADLINE_HEARTBEAT_MS` | `30000` | Backstop poll interval of the deadline consumer; enqueued deadlines are announced over pub/sub and wake consumers immediately, so this only bounds recovery from lost notifications or a dead worker |
|
||||||
| `LOGGING_CONFIG` | unset | Path to a YAML logging configuration file (see below). Unset logs DEBUG to the console |
|
| `LOGGING_CONFIG` | unset | Path to a YAML logging configuration file (see below). Unset logs DEBUG to the console |
|
||||||
| `CORS_ALLOW_ORIGINS` | unset | Comma-separated origins allowed for cross-origin requests, or `*` for any. CORS is disabled unless this or `CORS_ALLOW_ORIGIN_REGEX` is set |
|
| `CORS_ALLOW_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_ORIGIN_REGEX` | unset | Regex (fullmatch) additionally matched against request origins, e.g. `https://tavolo-[a-z0-9-]+\.vercel\.app` |
|
||||||
@@ -69,8 +91,53 @@ All configuration comes from environment variables (see `.env.example`):
|
|||||||
| `CORS_ALLOW_CREDENTIALS` | `false` | `1`/`true`/`yes`/`on` allow cookies/credentials on cross-origin requests |
|
| `CORS_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_EXPOSE_HEADERS` | unset | Comma-separated response headers exposed to the browser |
|
||||||
| `CORS_MAX_AGE` | `600` | Seconds browsers may cache the preflight response |
|
| `CORS_MAX_AGE` | `600` | Seconds browsers may cache the preflight response |
|
||||||
|
| `OTEL_ENABLED` | `false` | `1`/`true`/`yes`/`on` enable OpenTelemetry traces, metrics and logs (the Docker image bundles the `otel` extra; non-image installs need `pip install tavolo-app[otel]`) |
|
||||||
|
| `OTEL_SERVICE_NAME` | `tavolo` | `service.name` resource attribute of the exported telemetry |
|
||||||
|
| `OTEL_EXPORTER_OTLP_ENDPOINT` | unset | Base URL of an OTLP/HTTP collector (e.g. `http://localhost:4318`); unset uses the exporter default |
|
||||||
|
| `OTEL_EXPORTER_OTLP_HEADERS` | unset | Comma-separated `key=value` headers sent to the collector (e.g. authentication) |
|
||||||
|
| `OTEL_EXCLUDED_PATHS` | `/api/health` | Comma-separated paths excluded from tracing and metrics (exact matches) |
|
||||||
|
| `OTEL_METRIC_EXPORT_INTERVAL_MILLIS` | `60000` | How often the OTLP metric reader exports |
|
||||||
|
| `OTEL_METRICS_POLL_INTERVAL_SECONDS` | `15` | How often the session/game count gauges are refreshed |
|
||||||
| `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address |
|
| `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address |
|
||||||
|
|
||||||
|
¹ 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. Each provider's `sub` is a separate account,
|
||||||
|
so match history and Elo ratings are per provider identity (someone using
|
||||||
|
two providers has two accounts). Migrating from the pre-0.0.6 unindexed
|
||||||
|
variables: rename `OIDC_ISSUER`/`OIDC_CLIENT_ID`/... to `OIDC_0_...`, add
|
||||||
|
`OIDC_0_PROVIDER`, and register the new callback path
|
||||||
|
(`/auth/<provider>/callback`) at the provider. Sessions survive only when
|
||||||
|
the provider is named `default`; otherwise users log in once more.
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
The application logs through the Python stdlib `logging` module, one
|
The application logs through the Python stdlib `logging` module, one
|
||||||
@@ -106,68 +173,142 @@ loggers:
|
|||||||
level: INFO
|
level: INFO
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## OpenTelemetry
|
||||||
|
|
||||||
|
With `OTEL_ENABLED` set, the app exports over OTLP/HTTP (kaya-otel plus
|
||||||
|
`tavolo.telemetry`):
|
||||||
|
|
||||||
|
- **Traces**: HTTP server spans, one span per WebSocket connection, plus
|
||||||
|
database spans for the Postgres (asyncpg, through Tortoise) and Redis
|
||||||
|
clients. Redis command values are redacted by the instrumentation
|
||||||
|
(`GET ? ?`) and asyncpg spans carry the parameterized SQL only — no
|
||||||
|
parameter values. Each HTTP/WebSocket span is tagged with the request's
|
||||||
|
session and user, so all traces of a session can be found together:
|
||||||
|
`session.id` is a truncated SHA-256 of the session id (the cookie is a
|
||||||
|
credential, so it is never exported verbatim) and `user.id` is the OIDC
|
||||||
|
`sub`, present once logged in.
|
||||||
|
- **Metrics**: the HTTP server metrics from kaya-otel, plus
|
||||||
|
`tavolo.active_users` (sessions in the session store),
|
||||||
|
`tavolo.ongoing_games` (live matches not finished yet, maintained by the
|
||||||
|
game store), and process/CPython metrics from
|
||||||
|
`opentelemetry-instrumentation-system-metrics`: `process.cpu.time`,
|
||||||
|
`process.cpu.utilization`, `process.memory.usage`,
|
||||||
|
`process.memory.virtual`, `cpython.gc.collections`,
|
||||||
|
`cpython.gc.collected_objects` and `cpython.gc.uncollectable_objects`.
|
||||||
|
- **Logs**: forwarded from the root logger at the configured console level
|
||||||
|
(OpenTelemetry's own records are filtered out to avoid export loops).
|
||||||
|
|
||||||
|
The session/game gauges are polled every
|
||||||
|
`OTEL_METRICS_POLL_INTERVAL_SECONDS` (Redis-backed stores report
|
||||||
|
cluster-wide counts, in-memory stores the process's own). Note that with
|
||||||
|
multiple workers or replicas each process reports the same global counts:
|
||||||
|
aggregate with `max`, not `sum`.
|
||||||
|
|
||||||
## Data model
|
## Data model
|
||||||
|
|
||||||
### Redis (live games)
|
### Redis (live games)
|
||||||
|
|
||||||
- `tavolo:game:<uuid>` — the whole match as JSON: players (seat 0/2 = team A,
|
- `tavolo:game:<uuid>` — the whole session as JSON: the platform-owned
|
||||||
1/3 = team B), hands, table, captured piles, scope, current turn, dealer,
|
envelope (`id`, `game_type`, `join_code`, seats, timestamps) plus the
|
||||||
scores, phase (`lobby` → `playing` → `finished`). Sliding TTL
|
game-specific `state` blob, which only the registered engine
|
||||||
(`GAME_TTL_SECONDS`).
|
interprets. Sliding TTL (`GAME_TTL_SECONDS`).
|
||||||
- `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 state and pushes the
|
signals; every open WebSocket reloads the session 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: turn auto-plays and hand-end auto-continues. Every worker runs
|
timeouts (e.g. the games' turn auto-plays and hand-end auto-continues).
|
||||||
a consumer that fires due entries under the per-game lock, so timeouts
|
Every worker runs a consumer that fires due entries under the per-game
|
||||||
do not depend on any player being connected and survive the death of
|
lock by calling the engine's `fire_deadline`, so timeouts do not depend
|
||||||
any worker (delivery is at-least-once; entries are revalidated against
|
on any player being connected and survive the death of any worker
|
||||||
the live state before firing).
|
(delivery is at-least-once; engines revalidate the entry's token
|
||||||
|
against the live state before firing).
|
||||||
|
- `tavolo:deadlines:events` — a pub/sub channel announcing newly enqueued
|
||||||
|
deadlines; it wakes every worker's consumer immediately, while the
|
||||||
|
heartbeat poll remains as a backstop for lost signals.
|
||||||
|
|
||||||
### Postgres (statistics, via Tortoise ORM + aerich migrations)
|
### Postgres (statistics, via Tortoise ORM + aerich migrations)
|
||||||
|
|
||||||
- `match` — one row per finished match: the game played (`game_type`, one
|
- `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
|
of the ids from `GET /api/game-types`, indexed so statistics can be
|
||||||
scoped per game), both teams' final scores, winner, target score, hands
|
scoped per game), start/finish timestamps, and the game-specific outcome
|
||||||
played, start/finish timestamps.
|
as reported by the engine (`result` JSON — for scopone: both teams'
|
||||||
|
final scores, winner, target score, hands played and the per-hand
|
||||||
|
audit; for briscola: the final scores, winner, target score, deals
|
||||||
|
played and the per-deal audit).
|
||||||
- `match_player` — one row per participant: the OIDC `sub`, display name,
|
- `match_player` — one row per participant: the OIDC `sub`, display name,
|
||||||
seat, team and whether they won. Unique per `(match, user_sub)`.
|
seat, game-defined team label (nullable), whether they won, the points
|
||||||
|
they scored, the Elo change the match produced (`elo_delta`) and
|
||||||
|
game-specific extras (`details` JSON). Unique per `(match, user_sub)`.
|
||||||
|
- `player_rating` — current Elo rating per `(user_sub, game_type)`, with
|
||||||
|
the number of rated matches played.
|
||||||
|
|
||||||
When a match ends, the result is written transactionally to Postgres
|
When a match ends, the engine's result is written transactionally to
|
||||||
(once, guarded by a flag on the Redis state); the finished state stays in
|
Postgres (once, guarded by a flag on the session); the finished session
|
||||||
Redis until its TTL expires so clients can still fetch the final board.
|
stays in Redis until its TTL expires so clients can still fetch the final
|
||||||
|
board.
|
||||||
|
|
||||||
|
### Elo ratings
|
||||||
|
|
||||||
|
Players carry a chess-style Elo rating per game type
|
||||||
|
(`tavolo.platform.elo`): everyone starts at 1500, a team's rating is the
|
||||||
|
mean of its members, and the standard formula
|
||||||
|
`E = 1 / (1 + 10 ** ((R_opp - R_team) / 400))` with `K = 32` decides how
|
||||||
|
many points the match result moves — the same delta for every member of
|
||||||
|
a team, zero-sum between the two teams. Ratings update in the same
|
||||||
|
transaction as the match result. To recompute every rating from the
|
||||||
|
recorded match history (e.g. to backfill matches recorded before ratings
|
||||||
|
existed):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
.venv/bin/python -m tavolo.platform.backfill_elo \
|
||||||
|
--database-url postgres://tavolo:tavolo@localhost:5432/tavolo
|
||||||
|
```
|
||||||
|
|
||||||
## REST API
|
## REST API
|
||||||
|
|
||||||
All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
|
All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
|
||||||
`/api/game-types` and `/api/leaderboard` require authentication.
|
`/api/game-types`, `/api/leaderboard` and the game snapshot
|
||||||
|
(`GET /api/games/{id}`) require authentication.
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `GET` | `/api/game-types` | The card games the platform can host (for the creation dropdown) |
|
| `GET` | `/api/game-types` | The games the platform can host (for the creation dropdown), each with its `options_schema` |
|
||||||
| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "target_score": 11, "napola": true}`. Returns `{id, join_code}` |
|
| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "options": {"target_score": 11, "napola": true, "asso_pigliatutto": false}}`; briscola games use `{"game_type": "briscola"}` or `{"game_type": "briscola_a_quattro"}` with an optional `{"target_score": 61}`. Returns the lobby (`{id, join_code, players, seats_open, ...}`) |
|
||||||
| `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The fourth player triggers the deal |
|
| `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The last player triggers the start and gets the game view back |
|
||||||
| `GET` | `/api/games/{id}` | Personalized snapshot (only your own hand is visible) |
|
| `GET` | `/api/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/me/matches` | Cursor-paginated match history with final scores (`?limit=&cursor=&game_type=`) |
|
| `GET` | `/api/games/{id}` | Game snapshot. Seated players get the personalized view (own hand plus the join code); anyone else gets the read-only spectator view: table, scores, captured counts and scope, no hands and no join code |
|
||||||
| `GET` | `/api/leaderboard` | Aggregated wins / matches / team points per player (`?game_type=`) |
|
| `GET` | `/api/me/matches` | Cursor-paginated match history with the engine's `result` and per-player Elo deltas (`?limit=&cursor=&game_type=`) |
|
||||||
|
| `GET` | `/api/me/ratings` | The caller's Elo rating per game type |
|
||||||
|
| `GET` | `/api/leaderboard` | Elo rating, aggregated wins / matches / points per player, sorted by Elo (`?game_type=`) |
|
||||||
|
|
||||||
## WebSocket protocol
|
## WebSocket protocol
|
||||||
|
|
||||||
Connect to `/ws/games/{game_id}` with your session cookie. Only seated
|
Connect to `/ws/games/{game_id}`; the session cookie is optional. Anyone
|
||||||
players are accepted; unauthenticated, unknown-game and non-player
|
who knows the game id is accepted: seated players get their personalized
|
||||||
connections are closed with codes `4401`, `4404` and `4403` respectively.
|
view, everyone else — anonymous users and authenticated non-players — is
|
||||||
|
a read-only **spectator**. Only an unknown game id is refused before the
|
||||||
|
handshake completes, with `404`. (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
|
- `{"type": "state", "game": {...}}` — personalized game view. Your own
|
||||||
hand is included; other players expose only their card counts.
|
hand is included; other players expose only their card counts.
|
||||||
`your_turn: true` is present when it is your move.
|
Spectators get no hand at all, `spectator: true` marks the view and
|
||||||
|
`join_code` is omitted for them. `your_turn: true` is present when it
|
||||||
|
is your move. `seats_open` reports the free chairs (0 once the match
|
||||||
|
started).
|
||||||
- `{"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:
|
Client → server messages are JSON objects with an `action`. The
|
||||||
|
platform handles `state` (resend the snapshot) itself; every other action
|
||||||
|
is dispatched to the session's game engine with the rest of the message
|
||||||
|
as its payload. Spectators may only send `state`/`sync`: any other
|
||||||
|
action is answered with an `error` whose `code` is `forbidden`. For
|
||||||
|
scopone scientifico:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
|
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
|
||||||
@@ -186,7 +327,21 @@ Client → server messages:
|
|||||||
after `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.
|
For briscola (`briscola`, `briscola_a_quattro`) the game actions are
|
||||||
|
`play` (with `card` only — every card in hand is legal) and `ack`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"action": "play", "card": "04C"}
|
||||||
|
{"action": "ack"}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `card` uses the same `RRSUIT` convention.
|
||||||
|
- `ack` acknowledges the deal-end summary; the next deal is dealt once
|
||||||
|
every player has acknowledged, or automatically after
|
||||||
|
`HAND_ACK_TIMEOUT_SECONDS`.
|
||||||
|
|
||||||
|
After every accepted move the new state is broadcast to every connected
|
||||||
|
client, spectators included.
|
||||||
|
|
||||||
### Turn timeout
|
### Turn timeout
|
||||||
|
|
||||||
@@ -202,15 +357,18 @@ 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 the
|
||||||
`last_hand` (a full scoring breakdown with an `award` map naming the team
|
scoring breakdown of the hand just played (`last_hand` with an `award`
|
||||||
that won each category), the `acknowledged` seats and a
|
map naming the team that won each scopone category; `last_deal` with the
|
||||||
`hand_end_deadline`. The frontend renders this as a screen every player
|
deal's points and running totals for briscola), the `acknowledged` seats
|
||||||
must dismiss. A play attempted in this phase is rejected with an
|
and a `hand_end_deadline`. The frontend renders this as a screen every
|
||||||
|
player must dismiss. A play attempted in this phase is rejected with an
|
||||||
`illegal_move` error.
|
`illegal_move` error.
|
||||||
|
|
||||||
## Rules implemented
|
## Rules implemented
|
||||||
|
|
||||||
|
### Scopone scientifico
|
||||||
|
|
||||||
- 40-card Italian deck, ten cards per player, empty table at hand start.
|
- 40-card Italian deck, ten cards per player, empty table at hand start.
|
||||||
- A card captures a **single card of equal rank** (mandatory when present)
|
- A card captures a **single card of equal rank** (mandatory when present)
|
||||||
or a **combination of table cards whose ranks sum to its own**.
|
or a **combination of table cards whose ranks sum to its own**.
|
||||||
@@ -219,34 +377,74 @@ must dismiss. A play attempted in this phase is rejected with an
|
|||||||
- 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` flag on `POST /api/games`,
|
- Optional *napola* rule (per-game `napola` creation option, default on):
|
||||||
default on): the longest run of consecutive denari starting from the
|
the longest run of consecutive denari starting from the ace scores one
|
||||||
ace scores one point per card once it reaches three cards (A-2-3 = 3,
|
point per card once it reaches three cards (A-2-3 = 3, A-2-3-4 = 4, …).
|
||||||
A-2-3-4 = 4, …). A team that captures the whole denari suit (ace to
|
A team that captures the whole denari suit (ace to king) wins the match
|
||||||
king) wins the match instantly, regardless of the score.
|
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) with a clear lead; a tie at or above the target is
|
configurable per game via the `target_score` creation option) with a
|
||||||
broken by another hand.
|
clear lead; a tie at or above the target is broken by another hand.
|
||||||
|
|
||||||
|
### Briscola
|
||||||
|
|
||||||
|
- 40-card Italian deck, three cards per player. The card turned up after
|
||||||
|
the deal fixes the trump (*briscola*) suit for the whole deal and is the
|
||||||
|
last card drawn from the stock.
|
||||||
|
- Trick play: the highest trump wins; when no trump was played, the
|
||||||
|
highest card of the led suit wins. The winner takes the trick and leads
|
||||||
|
the next one.
|
||||||
|
- After each trick every player draws back up to three cards, starting
|
||||||
|
with the trick winner; the face-up briscola card goes to whoever's turn
|
||||||
|
to draw it is.
|
||||||
|
- Card points: ace 11, three 10, king 4, horse 3, jack 2 — 120 points per
|
||||||
|
deal. Trick strength is unrelated to points: ace > three > king > horse
|
||||||
|
> jack > 7 > 6 > 5 > 4 > 2.
|
||||||
|
- Match points accumulate across deals; a 60–60 deal is a *patta*: its
|
||||||
|
points are discarded and the deal is replayed. The match ends when a
|
||||||
|
team reaches the target score (default 61, configurable per game via
|
||||||
|
the `target_score` creation option) with a clear lead; a tie at or
|
||||||
|
above the target is broken by another deal.
|
||||||
|
- `briscola` is the classic two-player game; `briscola_a_quattro` seats
|
||||||
|
four players in two fixed partnerships (seats 0/2 vs 1/3, partners
|
||||||
|
opposite).
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
.venv/bin/pip install -r requirements.txt
|
.venv/bin/pip install -r requirements.txt
|
||||||
.venv/bin/pip install -e '.[dev]'
|
.venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e ./packages/tavolo-briscola -e './packages/tavolo-app[dev]'
|
||||||
|
# app integration suite:
|
||||||
.venv/bin/python -m unittest discover -s tests -t .
|
.venv/bin/python -m unittest discover -s tests -t .
|
||||||
.venv/bin/mypy src
|
# per-package suites (each package is self-contained):
|
||||||
|
.venv/bin/python -m unittest discover -s packages/tavolo-platform/tests
|
||||||
|
.venv/bin/python -m unittest discover -s packages/tavolo-scopone/tests
|
||||||
|
.venv/bin/python -m unittest discover -s packages/tavolo-briscola/tests
|
||||||
|
# type checks (see packages/tavolo-app/README.md for the app modules):
|
||||||
|
.venv/bin/python -m mypy -p tavolo.platform
|
||||||
|
.venv/bin/python -m mypy -p tavolo.scopone
|
||||||
|
.venv/bin/python -m mypy -p tavolo.briscola
|
||||||
```
|
```
|
||||||
|
|
||||||
Tests run fully in-process: in-memory sqlite for Postgres, in-memory stores
|
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` /
|
for Redis, a fake OIDC user patched onto the mixins, and `httpx` /
|
||||||
`httpx-ws` ASGI transports for HTTP and WebSocket coverage.
|
`httpx-ws` ASGI transports for HTTP and WebSocket coverage. The platform
|
||||||
|
suite additionally runs against a `DummyEngine` toy game, so the
|
||||||
|
game-independent machinery is covered without importing scopone.
|
||||||
|
|
||||||
### Migrations
|
### Migrations
|
||||||
|
|
||||||
The Postgres schema is owned by aerich migrations in `migrations/`. The
|
The Postgres schema is owned by aerich migrations in `migrations/`. The
|
||||||
`db-migrate` compose service runs `aerich upgrade` before the app starts.
|
`db-migrate` compose service runs `aerich upgrade` before the app starts.
|
||||||
To add a migration after changing `src/tavolo/models.py`:
|
To add a migration after changing
|
||||||
|
`packages/tavolo-platform/src/tavolo/platform/models.py`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo .venv/bin/aerich migrate
|
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo .venv/bin/aerich migrate
|
||||||
@@ -259,29 +457,52 @@ baseline.)
|
|||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
Everything lives under `server/`:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
src/tavolo/
|
packages/tavolo-platform/ # game-independent platform (own pyproject + tests)
|
||||||
├── app.py # composition root: session/OIDC/Tortoise/OpenAPI mixins
|
src/tavolo/platform/
|
||||||
├── config.py # env -> frozen Settings
|
├── __init__.py # public surface (GameEngine, GameSession, Platform, ...)
|
||||||
├── auth.py # auth helpers (HTTP + WebSocket)
|
├── engine.py # the platform<->game contract
|
||||||
├── http.py # JSON request/response helpers
|
├── registry.py # game id -> engine lookup
|
||||||
├── pagination.py # keyset (cursor) pagination
|
├── mixin.py # Platform + PlatformMixin (mounts everything on a kaya app)
|
||||||
├── openapi.py # shared OpenAPI parameter fragments
|
├── errors.py # shared GameError hierarchy
|
||||||
├── tortoise_mixin.py # TortoiseORM lifecycle (HTTP + WebSocket)
|
├── models.py # Match / MatchPlayer / PlayerRating (Postgres)
|
||||||
├── aerich_config.py # aerich CLI configuration
|
├── elo.py # chess-style Elo math (1500 start, K=32)
|
||||||
├── models.py # Match, MatchPlayer (Postgres)
|
├── stats.py # MatchResult -> Postgres persistence + Elo update
|
||||||
├── stats.py # finished match -> Postgres persistence
|
├── backfill_elo.py # recompute all ratings from the match history
|
||||||
├── store.py # Redis / in-memory live-game store (+ deadline queue)
|
├── store.py # Redis / in-memory live-session store (+ deadline queue)
|
||||||
├── deadlines.py # connection-independent timeout scheduler
|
├── deadlines.py # connection-independent timeout scheduler
|
||||||
├── ws.py # WebSocket live-play endpoint
|
├── ws.py # WebSocket live-play endpoint (generic envelope)
|
||||||
├── game/
|
├── auth.py # auth helpers (HTTP + WebSocket)
|
||||||
│ ├── state.py # GameState / PlayerState / Card, JSON (de)serialization
|
├── http.py # JSON request/response helpers
|
||||||
│ ├── engine.py # pure scopone scientifico rules
|
├── pagination.py # keyset (cursor) pagination
|
||||||
│ └── errors.py # typed rule violations
|
├── openapi.py # shared OpenAPI parameter fragments
|
||||||
└── routes/
|
├── tortoise_mixin.py # TortoiseORM lifecycle (HTTP + WebSocket)
|
||||||
├── health.py # GET /api/health
|
└── routes/
|
||||||
├── games.py # lobby: create / join / snapshot
|
├── health.py # GET /api/health
|
||||||
└── stats.py # match history + leaderboard
|
├── me.py # GET /api/me
|
||||||
|
├── games.py # lobby: game-types / create / join / snapshot
|
||||||
|
└── stats.py # match history + leaderboard + Elo ratings
|
||||||
|
packages/tavolo-scopone/ # scopone scientifico game (own pyproject + tests)
|
||||||
|
src/tavolo/scopone/
|
||||||
|
├── state.py # ScoponeState / PlayerState / Card, JSON (de)serialization
|
||||||
|
├── engine.py # pure scopone scientifico rules
|
||||||
|
├── errors.py # scopone-specific rule violations
|
||||||
|
└── plugin.py # ScoponeEngine: the GameEngine adapter
|
||||||
|
packages/tavolo-briscola/ # briscola game (own pyproject + tests)
|
||||||
|
src/tavolo/briscola/
|
||||||
|
├── state.py # BriscolaState / PlayerState / Card / Trick, JSON (de)serialization
|
||||||
|
├── engine.py # pure briscola rules (2- and 4-player)
|
||||||
|
├── errors.py # briscola-specific rule violations
|
||||||
|
└── plugin.py # BriscolaEngine + BriscolaQuattroEngine adapters
|
||||||
|
packages/tavolo-app/ # deployable app: composition root (own pyproject)
|
||||||
|
src/tavolo/
|
||||||
|
├── app.py # session/OIDC/Tortoise/OpenAPI/Platform/DeadlineScheduler mixins
|
||||||
|
├── config.py # env -> frozen Settings
|
||||||
|
├── telemetry.py # OTLP metric/log providers, app gauges, count poller
|
||||||
|
├── aerich_config.py # aerich CLI configuration
|
||||||
|
├── logging_config.py # logging setup
|
||||||
|
└── static.py # SPA shell hosting
|
||||||
|
pyproject.toml # tooling shim: [tool.aerich] + [tool.mypy] for server/ CWD
|
||||||
|
migrations/ # aerich migrations for the platform models
|
||||||
|
tests/ # app integration suite (platform + games wired together)
|
||||||
```
|
```
|
||||||
|
|||||||
+4
-3
@@ -10,7 +10,8 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "sqlite://:memory:")
|
os.environ.setdefault("DATABASE_URL", "sqlite://:memory:")
|
||||||
os.environ.setdefault("OIDC_ISSUER", "http://localhost:8180/tavolo")
|
os.environ.setdefault("OIDC_0_PROVIDER", "default")
|
||||||
os.environ.setdefault("OIDC_CLIENT_ID", "tavolo")
|
os.environ.setdefault("OIDC_0_ISSUER", "http://localhost:8180/tavolo")
|
||||||
os.environ.setdefault("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback")
|
os.environ.setdefault("OIDC_0_CLIENT_ID", "tavolo")
|
||||||
|
os.environ.setdefault("OIDC_0_REDIRECT_URI", "http://localhost:8080/auth/default/callback")
|
||||||
os.environ.pop("REDIS_URL", None)
|
os.environ.pop("REDIS_URL", None)
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from tortoise import BaseDBAsyncClient
|
||||||
|
|
||||||
|
RUN_IN_TRANSACTION = True
|
||||||
|
|
||||||
|
|
||||||
|
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
CREATE TABLE IF NOT EXISTS "player_rating" (
|
||||||
|
"id" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"user_sub" VARCHAR(255) NOT NULL,
|
||||||
|
"game_type" VARCHAR(32) NOT NULL,
|
||||||
|
"rating" INT NOT NULL,
|
||||||
|
"matches_played" INT NOT NULL,
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL,
|
||||||
|
CONSTRAINT "uid_player_rati_user_su_655b53" UNIQUE ("user_sub", "game_type")
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_player_rati_game_ty_3326d1" ON "player_rating" ("game_type", "rating");
|
||||||
|
COMMENT ON TABLE "player_rating" IS 'Current Elo rating of one player for one game type.';
|
||||||
|
ALTER TABLE "match_player" ADD "elo_delta" SMALLINT;"""
|
||||||
|
|
||||||
|
|
||||||
|
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
ALTER TABLE "match_player" DROP COLUMN "elo_delta";
|
||||||
|
DROP TABLE IF EXISTS "player_rating";"""
|
||||||
|
|
||||||
|
|
||||||
|
MODELS_STATE = (
|
||||||
|
"eJztmltv4jgUgP9KlKeuNFsBvY1Wq5WAUg07pVSF7q6mqiyTGLAa7IztbAd1+9/Xdm7EuR"
|
||||||
|
"RoYUrFyww59nHsz87xufTJnlEXefywB4UztX+znmwCZ0j+yDZ8smzo+6lYCQQceWHPpMuI"
|
||||||
|
"CwYdIYVj6HEkRS7iDsO+wJSork3LoTPfQwK5llaz6NiiBKn/xBRZDE0wF4jJ5omchyXmPu"
|
||||||
|
"KHamyXOnJwTCavGyYg+HuAgKATJDsyOdjdvRRj4qIfiMeP/gMYY+S5GSDYVQNoOVADKtnt"
|
||||||
|
"bff8QvdUUxwBh3rBjKS9/bmYUpJ0DwLsHiod1TZBBDEol7CAiwSeF2GNReGMpUCwACVTdV"
|
||||||
|
"OBi8Yw8BR0+/dxQBzF2tJvUv8c/xFNbaEbAFf9IRh0hgDYuT1SUzB4RyKHErW/mAgF6uk5"
|
||||||
|
"HDcFoqW2ekH7S/Pm4Oj0F42AcjFhulHjsp+1IhQwVNXQU8pqv0JeOdjtKWTFsDNKBnM55X"
|
||||||
|
"Vox4Iq3Nyhvjx1gDsYEYHH2KGbgi0/sh/AQ2Qi1Fd61KiA/1fzJuTf0Pyp/CLD7/Qqamno"
|
||||||
|
"JrUNKXaB4AxAuRLKCsgPZtDzukQU0zd1jQ2QS3ibDUhtSgw3xrcJ4BM1iV+PGmenn2Wrnq"
|
||||||
|
"N6OKsgP+g1Ly+7V8MitqNXsB3t2ZaxfcRELh0oTKsYDENtUyZju1QzJqK+hIWolxqIes4+"
|
||||||
|
"QCZvzDXPsKG7P8NZtlNIXA58D85RgYtRzdbU3bPNsuXy8Ml1AyjyZM8lEYFnqJhsVtPg6k"
|
||||||
|
"aqh/GPHaRcAXPY7XUGw2bvWg0/4/y7p3k1hx3V0tDSuSE9ODWsSTKI9Xd3+MVSj9a3/lXH"
|
||||||
|
"dAqTfsNvtpoTDAQFhD4C6C4yicWxKLPNY0wwn661z4bqfqPf3UariGz8sBAtKMEIOg+PkL"
|
||||||
|
"kg05KeCG0SGc+fhlakePH1BnlQs8xv+2Lwe61H2t19T6V2GMRooLRBy4jmm2aNmSmBBE70"
|
||||||
|
"ktS71ZsKkJWlE1KiLyQVwouNLZdbuJb2GjvY13saJwQCjpiFif6th8wnE1bQK8ge3KXpD6"
|
||||||
|
"UDeDCy7/cphfeUUkj2ZYUAYVFnawmFrQUHjZOTJcID2as0QNBtWVfLxVx9rkA/r4Da1PuA"
|
||||||
|
"wVijVluGd61Wzlu1Ga4tKnJ2qsOFWGcfJuRTNKuc2X3ioOSc5hIHj6F/ZfhglHoIkpLcTK"
|
||||||
|
"FHNpIqO8i2Amar37/MuN2t7tDAettrdWLashMWWpw/vsijcuqegKvag4ziWkYhusTeC+83"
|
||||||
|
"tAmhB7qai7ao85aO2vs9xS/4ZbnYzeCbh3tBGcIT8hXNNeKunAgkTpFnYFYodxNqLjyTYg"
|
||||||
|
"Yfk2ghc6bk6uWaUWgH2s1Bu3nesZ/L4+FNRnphFHcjwya9yFyol2n/VBXrhVEeYGnXF4O9"
|
||||||
|
"dsAYIsLqeNQK9eLILRzMGlOmH5P6bz7wW3OMwiBwMV5Iq5FmIHiXrVRGC77fx4f7+PBnW6"
|
||||||
|
"MtBIjvt7T/E0m/fSk/NaNZxqVOYKqwvbCwflKrbdYFbNSPz44/H50eJ35gIqlyBkv8QFRe"
|
||||||
|
"oCvlmlfcHt9dgRv4qrCyTt0mq/kByzY2Q9DtE28e3bu7W8ZJig5LVnE26bU2EcPFf+kYtV"
|
||||||
|
"R6qjDt85KLWr7Pb/w3iKX2p9AfLLA5kVP3Okdw8zH9K21Ouf/3L2IcF6Wpyr2SBZUP6JNs"
|
||||||
|
"xPtTH9UKhKPuH5BufaliQL2iGFDPFwPkGwUiBZfon4P+VTHhBRXz9sSOsP6zPMx3sSxQAV"
|
||||||
|
"fByFyRMdODXvMfE3f7st8y7z41QKsorbXNy+z5fy/uTSo="
|
||||||
|
)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
from tortoise import BaseDBAsyncClient
|
||||||
|
|
||||||
|
RUN_IN_TRANSACTION = True
|
||||||
|
|
||||||
|
|
||||||
|
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
ALTER TABLE "match" ADD "result" JSONB NOT NULL DEFAULT '{}';
|
||||||
|
UPDATE "match" SET "result" = jsonb_build_object(
|
||||||
|
'team_a_score', team_a_score,
|
||||||
|
'team_b_score', team_b_score,
|
||||||
|
'winner_team', winner_team,
|
||||||
|
'target_score', target_score,
|
||||||
|
'hands_played', hands_played);
|
||||||
|
ALTER TABLE "match" DROP COLUMN "winner_team";
|
||||||
|
ALTER TABLE "match" DROP COLUMN "team_b_score";
|
||||||
|
ALTER TABLE "match" DROP COLUMN "team_a_score";
|
||||||
|
ALTER TABLE "match" DROP COLUMN "target_score";
|
||||||
|
ALTER TABLE "match" DROP COLUMN "hands_played";
|
||||||
|
ALTER TABLE "match" ALTER COLUMN "game_type" DROP DEFAULT;
|
||||||
|
ALTER TABLE "match_player" ADD "details" JSONB NOT NULL DEFAULT '{}';
|
||||||
|
ALTER TABLE "match_player" ADD "score" DOUBLE PRECISION NOT NULL DEFAULT 0;
|
||||||
|
UPDATE "match_player" mp SET "score" = CASE WHEN mp.team = 'A'
|
||||||
|
THEN (mp_match.result->>'team_a_score')::float
|
||||||
|
ELSE (mp_match.result->>'team_b_score')::float END
|
||||||
|
FROM "match" mp_match WHERE mp_match.id = mp.match_id;
|
||||||
|
ALTER TABLE "match_player" ALTER COLUMN "team" DROP NOT NULL;
|
||||||
|
ALTER TABLE "match_player" ALTER COLUMN "team" TYPE VARCHAR(32) USING "team"::VARCHAR(32);"""
|
||||||
|
|
||||||
|
|
||||||
|
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
ALTER TABLE "match" ADD "winner_team" VARCHAR(1) NOT NULL;
|
||||||
|
ALTER TABLE "match" ADD "team_b_score" SMALLINT NOT NULL;
|
||||||
|
ALTER TABLE "match" ADD "team_a_score" SMALLINT NOT NULL;
|
||||||
|
ALTER TABLE "match" ADD "target_score" SMALLINT NOT NULL;
|
||||||
|
ALTER TABLE "match" ADD "hands_played" SMALLINT NOT NULL;
|
||||||
|
ALTER TABLE "match" DROP COLUMN "result";
|
||||||
|
ALTER TABLE "match" ALTER COLUMN "game_type" SET DEFAULT 'scopone_scientifico';
|
||||||
|
ALTER TABLE "match_player" DROP COLUMN "details";
|
||||||
|
ALTER TABLE "match_player" DROP COLUMN "score";
|
||||||
|
ALTER TABLE "match_player" ALTER COLUMN "team" SET NOT NULL;
|
||||||
|
ALTER TABLE "match_player" ALTER COLUMN "team" TYPE VARCHAR(1) USING "team"::VARCHAR(1);"""
|
||||||
|
|
||||||
|
|
||||||
|
MODELS_STATE = (
|
||||||
|
"eJztmm1v4jgQx79KlFc9aa8C+rQ6rU4KlGq5hVIB3TttVUUmMdSqsbOOc13U63c/23l2El"
|
||||||
|
"ooUKj6piVjj+P8hoz/HvNozqgLsX/YA9y5M/8wHk0CZlB8yDd8MkzgealZGjgY47Bn0mXs"
|
||||||
|
"cwYcLowTgH0oTC70HYY8jiiRXS3DoTMPQw5dQ7kZdGJQAuU/fgcNBqfI55CJ5qmYh8HnHv"
|
||||||
|
"QP5dgudcTgiExfN0xA0M8A2pxOoejIxGA3t8KMiAt/QT++9O7tCYLYzQFBrhxA2W05oLRd"
|
||||||
|
"X3fOL1RPOcWx7VAczEja25vzO0qS7kGA3EPpI9umkEAGxCNkcJEA4whrbApnLAycBTCZqp"
|
||||||
|
"saXDgBAZbQzS+TgDiStaHuJP8c/xlNLdPNti/7I3vYHtm2WYiRnILGOzI5lMj4IsIlqMen"
|
||||||
|
"cNwUiLKa8gatr9bg4Oj0N4WA+nzKVKPCZT4pR8BB6Kqgp5RlvEJeBditO8DKYeecNOZiyq"
|
||||||
|
"vQjg0LcMekNsBWvFO/bAzJlMuX8qixgPV3axDibijcVLyA4Wt5GbU0VJOknlL2OWCChQ14"
|
||||||
|
"EfO5oMTRDJajzntqrN3I9TD+sB7yaTLZAvoFqEedXns4snpXcviZ7//Eipc1asuWhrLONe"
|
||||||
|
"vBqRaWZBDj787oqyEvjR/9y7b+riT9Rj9MOScQcGoT+mADN8skNsemXJgniCD/bqU4a64f"
|
||||||
|
"gd7pQIuhJJtCjP8a9i/L45t66KFFDjf+M7BYQDcV1Mw6NQ4Q5oj4h/K2b7FUSUK5KMdp86"
|
||||||
|
"Bn/aNn1Fa339TDJwdoivQqdcPkPrOmScMYOPcPgLl2riWNm4fBHDK/GLhm5HjxbQAxUM9b"
|
||||||
|
"DFVWol2pkfb3NUyt0VKrgNIGrSJabJo1ZroFEDBVjyTvLe9UgqxK9KZEn5G+tpf2fFYBX4"
|
||||||
|
"nlEznIUzGNZWvgQ2Ygoj6rIYuSdwm/Eo17k4p06WP7wdi8/RC+uyR8k7gsoXuzPu9P9jZO"
|
||||||
|
"Tl6ge0WvSuGr2vLK10W+fF1tdb0Eat1vU7i3mns13rXaS3jXatW8ZZu204Bl2nM4Axh3CK"
|
||||||
|
"/YY8BS0SnXhj1kPJWT+P2ocXb6WbSqOcqLswWshz2r2+1cjjSWHILZMt/ZuP9K39Xovd8Z"
|
||||||
|
"jBveET+ECktTYZRiCEg53YdSTTYWLnv4LV2As9nvd3MKudkZaVyve8324KCucItOiCtz8Q"
|
||||||
|
"vsO5SVZN0LTEFVKog9NMwT6bIpzrXtQz7vXze7beNq0G51hp1oS5JsMVVjHu6gbXU1uhBT"
|
||||||
|
"MWfMwbL5Nue4UtLdtWSxvpzrQg4QLtmhVW+tMy4fe+u17K2zAQm3XMvtSbI+69yZ7G7Sfm"
|
||||||
|
"YjUihWaHxLcrTIw2hKvsG5QtwREwHEKUvN+sHRfkIt1COEmYGHZHuc+06JpxfPDMPM3LKG"
|
||||||
|
"Leu8bT5VF4A2WdoIyxYDwMOHLNQ2cu2fFhU3wrKGzdKuz1Y3WgFjkHCjjakR+sWlinAwY0"
|
||||||
|
"KZukyO5YqVjhXHKK16ZDfI6SGRXvm4yR8gRQ98+1EQ+SiIvHU22kJFZHdPXN/VBjNNo3nG"
|
||||||
|
"lao8ddheHaR+UtvU5ifS5I368dnx56PT40SYJ5ZF6rwozNX6C/2w+F6SkCu5Fh23x3df4A"
|
||||||
|
"aePNhd5dw47/kOj41NBoHbJ3gerbv7e4ycnLK98Nhyk6rVggyV/wAtalmoVEHa5zmJWh3n"
|
||||||
|
"Nf80rDL/lOrBkpwTibrXCcHNF1lemXOq9d+/kPmorCpbrUoyLu9Qk2xE/cmXagnCUfd3SL"
|
||||||
|
"f+otOv+oLTr3rx9EvckUOy1A9zMi7brx6+TbHqLX6Ds/7F7Ol/ruuC3w=="
|
||||||
|
)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# tavolo-app
|
||||||
|
|
||||||
|
The deployable tavolo application: the composition root wiring
|
||||||
|
[`tavolo-platform`](../tavolo-platform/README.md) to the
|
||||||
|
[`tavolo-scopone`](../tavolo-scopone/README.md) and
|
||||||
|
[`tavolo-briscola`](../tavolo-briscola/README.md) games.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- `app.py` — assembles the `KayaApp` (session/OIDC/Tortoise/OpenAPI
|
||||||
|
mixins plus `PlatformMixin` and the deadline scheduler), registers the
|
||||||
|
`ScoponeEngine`, `BriscolaEngine` and `BriscolaQuattroEngine` with
|
||||||
|
their configured timeouts, and exposes `tavolo.app:app` for granian.
|
||||||
|
- `config.py` — environment-driven frozen `Settings`.
|
||||||
|
- `telemetry.py` — the optional OpenTelemetry wiring beyond kaya-otel:
|
||||||
|
meter/tracer/logger providers, the
|
||||||
|
`tavolo.active_users`/`tavolo.ongoing_games` gauges (polled from the
|
||||||
|
stores), process/CPython metrics via
|
||||||
|
`opentelemetry-instrumentation-system-metrics`, Postgres/Redis query
|
||||||
|
spans via the asyncpg/redis instrumentations, and OTLP log export.
|
||||||
|
- `aerich_config.py` — Tortoise ORM configuration consumed by the aerich
|
||||||
|
CLI (migrations live in `server/migrations/`; aerich itself is
|
||||||
|
configured via the `[tool.aerich]` section of `server/pyproject.toml`).
|
||||||
|
- `logging_config.py` — logging setup.
|
||||||
|
- `static.py` — SPA shell hosting for the compiled frontend.
|
||||||
|
|
||||||
|
## Development (from `packages/tavolo-app/`)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
.venv/bin/python -m mypy --namespace-packages --explicit-package-bases \
|
||||||
|
src/tavolo/app.py \
|
||||||
|
src/tavolo/config.py \
|
||||||
|
src/tavolo/telemetry.py \
|
||||||
|
src/tavolo/static.py \
|
||||||
|
src/tavolo/aerich_config.py \
|
||||||
|
src/tavolo/logging_config.py
|
||||||
|
```
|
||||||
|
|
||||||
|
End-to-end coverage of the wired stack lives in `server/tests/`.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "tavolo-app"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Tavolo multiplayer card-game application: platform + games wiring"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"tavolo-platform",
|
||||||
|
"tavolo-scopone",
|
||||||
|
"tavolo-briscola",
|
||||||
|
"kaya-core",
|
||||||
|
"kaya-cors",
|
||||||
|
"kaya-session",
|
||||||
|
"kaya-session-redis",
|
||||||
|
"kaya-oidc",
|
||||||
|
"kaya-openapi",
|
||||||
|
"kaya-rsgi",
|
||||||
|
"granian>=2.0",
|
||||||
|
"asyncpg",
|
||||||
|
"aerich",
|
||||||
|
"httpx",
|
||||||
|
"PyJWT[crypto]",
|
||||||
|
"pwo",
|
||||||
|
"PyYAML",
|
||||||
|
"redis",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"mypy",
|
||||||
|
"httpx-ws",
|
||||||
|
]
|
||||||
|
otel = [
|
||||||
|
"kaya-otel>=0.0.7",
|
||||||
|
"opentelemetry-instrumentation-asyncpg>=0.66b0",
|
||||||
|
"opentelemetry-instrumentation-redis>=0.66b0",
|
||||||
|
"opentelemetry-instrumentation-system-metrics>=0.66b0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
namespaces = true
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.12"
|
||||||
|
ignore_missing_imports = true
|
||||||
|
plugins = []
|
||||||
+1
-1
@@ -16,7 +16,7 @@ TORTOISE_ORM = {
|
|||||||
"connections": {"default": settings.database_url},
|
"connections": {"default": settings.database_url},
|
||||||
"apps": {
|
"apps": {
|
||||||
"models": {
|
"models": {
|
||||||
"models": ["tavolo.models", "aerich.models"],
|
"models": ["tavolo.platform.models", "aerich.models"],
|
||||||
"default_connection": "default",
|
"default_connection": "default",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
"""Application entry point.
|
||||||
|
|
||||||
|
Assembles the :class:`~kaya.core.KayaApp` with the kaya mixins plus the
|
||||||
|
platform:
|
||||||
|
|
||||||
|
- :class:`~kaya.session.SessionMixin` (sessions persisted in Redis via
|
||||||
|
:class:`~kaya.session.redis.RedisSessionStore` when ``REDIS_URL`` is set,
|
||||||
|
otherwise an in-memory store — e.g. for tests)
|
||||||
|
- :class:`~kaya.oidc.OIDCProviders` (the configured OIDC providers)
|
||||||
|
- :class:`~tavolo.platform.tortoise_mixin.TortoiseMixin` (Postgres match
|
||||||
|
statistics; skipped for ``/api/health`` and the OpenAPI documentation
|
||||||
|
endpoints)
|
||||||
|
- :class:`~kaya.openapi.OpenAPIMixin` (serves the OpenAPI document at
|
||||||
|
``/api/openapi.json`` and a Swagger UI at ``/api/docs``)
|
||||||
|
- :class:`~tavolo.platform.mixin.PlatformMixin` (game lobby, match
|
||||||
|
history, leaderboards and the live-play websocket, served by the
|
||||||
|
registered game engines)
|
||||||
|
- :class:`~tavolo.platform.deadlines.DeadlineSchedulerMixin` (fires the
|
||||||
|
engines' timeouts)
|
||||||
|
|
||||||
|
A :class:`~kaya.cors.CorsMixin` is prepended when CORS is configured via the
|
||||||
|
``CORS_*`` environment variables (see :mod:`tavolo.config`). A
|
||||||
|
:class:`~kaya.otel.OTelMixin` (optional ``otel`` extra) is prepended when
|
||||||
|
``OTEL_ENABLED`` is set, adding OpenTelemetry traces and metrics.
|
||||||
|
|
||||||
|
Live games are kept in :data:`game_store` (Redis when configured,
|
||||||
|
in-memory otherwise). The SPA shell routes are registered at the bottom;
|
||||||
|
imports must happen after ``app`` is built.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib.metadata import version as _pkg_version
|
||||||
|
from logging import getLogger
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from kaya.core import KayaApp, KayaMixin
|
||||||
|
from kaya.cors import CorsMixin
|
||||||
|
from kaya.oidc import OIDCConfig, OIDCProviders
|
||||||
|
from kaya.openapi import OpenAPIMixin
|
||||||
|
from kaya.session import SessionMixin
|
||||||
|
from redis.asyncio import Redis
|
||||||
|
from tavolo.briscola import BriscolaEngine, BriscolaQuattroEngine
|
||||||
|
from tavolo.platform import GameRegistry, Platform, PlatformMixin
|
||||||
|
from tavolo.platform.deadlines import DeadlineScheduler, DeadlineSchedulerMixin
|
||||||
|
from tavolo.platform.store import GameStore, InMemoryGameStore, RedisGameStore
|
||||||
|
from tavolo.platform.tortoise_mixin import TortoiseMixin
|
||||||
|
from tavolo.scopone import ScoponeEngine
|
||||||
|
|
||||||
|
from .config import Settings, settings
|
||||||
|
from .logging_config import configure_logging
|
||||||
|
from .telemetry import (
|
||||||
|
CountableSessionStore,
|
||||||
|
CountingInMemorySessionStore,
|
||||||
|
CountingRedisSessionStore,
|
||||||
|
otel_headers,
|
||||||
|
session_tag_hook,
|
||||||
|
telemetry_from_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
configure_logging(settings.logging_config)
|
||||||
|
log = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def cors_mixin_from_settings(settings: Settings) -> Optional[CorsMixin]:
|
||||||
|
"""Build a :class:`~kaya.cors.CorsMixin` from the CORS settings.
|
||||||
|
|
||||||
|
Returns ``None`` — CORS disabled — unless at least one of
|
||||||
|
``CORS_ALLOW_ORIGINS`` / ``CORS_ALLOW_ORIGIN_REGEX`` is configured.
|
||||||
|
Settings left unset fall back to the mixin's own defaults.
|
||||||
|
"""
|
||||||
|
if settings.cors_allow_origins is None and settings.cors_allow_origin_regex is None:
|
||||||
|
return None
|
||||||
|
return CorsMixin(
|
||||||
|
allow_origins=settings.cors_allow_origins or (),
|
||||||
|
allow_origin_regex=settings.cors_allow_origin_regex,
|
||||||
|
allow_methods=settings.cors_allow_methods or ("GET",),
|
||||||
|
allow_headers=settings.cors_allow_headers or (),
|
||||||
|
allow_credentials=settings.cors_allow_credentials,
|
||||||
|
expose_headers=settings.cors_expose_headers or (),
|
||||||
|
max_age=settings.cors_max_age,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def otel_mixin_from_settings(
|
||||||
|
settings: Settings,
|
||||||
|
meter_provider: Optional[Any] = None,
|
||||||
|
tracer_provider: Optional[Any] = None,
|
||||||
|
) -> Optional[KayaMixin]:
|
||||||
|
"""Build a :class:`~kaya.otel.OTelMixin` from the OTEL_* settings.
|
||||||
|
|
||||||
|
Returns ``None`` — telemetry disabled — unless ``OTEL_ENABLED`` is
|
||||||
|
truthy. kaya-otel is an optional dependency (the ``otel`` extra), so it
|
||||||
|
is imported lazily here: default installs and the test suite never need
|
||||||
|
the OpenTelemetry packages.
|
||||||
|
|
||||||
|
``meter_provider``/``tracer_provider`` are the providers built by
|
||||||
|
:func:`tavolo.telemetry.telemetry_from_settings`; injecting them lets
|
||||||
|
the custom instruments and the database instrumentations share the same
|
||||||
|
OTLP pipelines (kaya-otel then leaves their shutdown to the telemetry
|
||||||
|
mixin).
|
||||||
|
"""
|
||||||
|
if not settings.otel_enabled:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from kaya.otel import OTelMixin
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"OTEL_ENABLED is set but kaya-otel is not installed; "
|
||||||
|
"install tavolo-app with the 'otel' extra"
|
||||||
|
) from exc
|
||||||
|
kwargs: Dict[str, Any] = {
|
||||||
|
"service_name": settings.otel_service_name,
|
||||||
|
"endpoint": settings.otel_exporter_endpoint,
|
||||||
|
"headers": otel_headers(settings),
|
||||||
|
"excluded_paths": settings.otel_excluded_paths,
|
||||||
|
# Runs after the session mixin's after hook, so the session id has
|
||||||
|
# been persisted/minted and the OIDC claims are on the session.
|
||||||
|
"server_response_hook": session_tag_hook,
|
||||||
|
"websocket_close_hook": session_tag_hook,
|
||||||
|
}
|
||||||
|
if meter_provider is not None:
|
||||||
|
kwargs["meter_provider"] = meter_provider
|
||||||
|
if tracer_provider is not None:
|
||||||
|
kwargs["tracer_provider"] = tracer_provider
|
||||||
|
return OTelMixin(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
registry = GameRegistry()
|
||||||
|
registry.register(
|
||||||
|
ScoponeEngine(
|
||||||
|
turn_timeout_seconds=settings.turn_timeout_seconds,
|
||||||
|
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
registry.register(
|
||||||
|
BriscolaEngine(
|
||||||
|
turn_timeout_seconds=settings.turn_timeout_seconds,
|
||||||
|
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
registry.register(
|
||||||
|
BriscolaQuattroEngine(
|
||||||
|
turn_timeout_seconds=settings.turn_timeout_seconds,
|
||||||
|
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
log.info("registered games: %s", ", ".join(e.id for e in registry.all()))
|
||||||
|
log.debug(
|
||||||
|
"game timeouts: hand_ack=%ds turn=%ds",
|
||||||
|
settings.hand_ack_timeout_seconds,
|
||||||
|
settings.turn_timeout_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
session_store: CountableSessionStore
|
||||||
|
game_store: GameStore
|
||||||
|
if settings.redis_url is not None:
|
||||||
|
# Lazy client: no connection is opened until a session is actually
|
||||||
|
# loaded/saved, so importing this module never requires a live Redis.
|
||||||
|
session_store = CountingRedisSessionStore(Redis.from_url(settings.redis_url))
|
||||||
|
game_store = RedisGameStore(
|
||||||
|
Redis.from_url(settings.redis_url, decode_responses=False),
|
||||||
|
registry,
|
||||||
|
ttl_seconds=settings.game_ttl_seconds,
|
||||||
|
)
|
||||||
|
log.info("using Redis stores (sessions + live games, game TTL %ds)", settings.game_ttl_seconds)
|
||||||
|
else:
|
||||||
|
session_store = CountingInMemorySessionStore()
|
||||||
|
game_store = InMemoryGameStore(registry)
|
||||||
|
log.info("REDIS_URL unset: using in-memory stores (sessions + live games)")
|
||||||
|
|
||||||
|
session_mixin = SessionMixin(session_store)
|
||||||
|
oidc = OIDCProviders(
|
||||||
|
[
|
||||||
|
OIDCConfig(
|
||||||
|
provider=provider.provider,
|
||||||
|
issuer=provider.issuer,
|
||||||
|
client_id=provider.client_id,
|
||||||
|
client_secret=provider.client_secret,
|
||||||
|
redirect_uri=provider.redirect_uri,
|
||||||
|
post_login_redirect=provider.post_login_redirect,
|
||||||
|
post_logout_redirect=provider.post_logout_redirect,
|
||||||
|
fetch_userinfo=True,
|
||||||
|
)
|
||||||
|
for provider in settings.oidc_providers
|
||||||
|
],
|
||||||
|
session=session_mixin,
|
||||||
|
)
|
||||||
|
log.info(
|
||||||
|
"OIDC providers: %s",
|
||||||
|
", ".join(provider.provider for provider in settings.oidc_providers),
|
||||||
|
)
|
||||||
|
openapi_mixin = OpenAPIMixin(
|
||||||
|
title="tavolo",
|
||||||
|
version=_pkg_version("tavolo-app"),
|
||||||
|
description="Multiplayer card-game platform API",
|
||||||
|
spec_path="/api/openapi.json",
|
||||||
|
docs_path="/api/docs",
|
||||||
|
)
|
||||||
|
tortoise_mixin = TortoiseMixin(
|
||||||
|
database_url=settings.database_url,
|
||||||
|
models_modules=["tavolo.platform.models"],
|
||||||
|
skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}),
|
||||||
|
)
|
||||||
|
scheduler = DeadlineScheduler(
|
||||||
|
game_store,
|
||||||
|
registry,
|
||||||
|
heartbeat_ms=settings.deadline_heartbeat_ms,
|
||||||
|
# The consumer task has no request context; bind the database context
|
||||||
|
# before it persists a match that a timeout finished.
|
||||||
|
context_binder=tortoise_mixin.ensure_context,
|
||||||
|
)
|
||||||
|
platform = Platform(
|
||||||
|
registry=registry,
|
||||||
|
game_store=game_store,
|
||||||
|
scheduler=scheduler,
|
||||||
|
oidc=oidc,
|
||||||
|
)
|
||||||
|
|
||||||
|
mixins: list[KayaMixin] = [session_mixin, oidc, tortoise_mixin, openapi_mixin,
|
||||||
|
PlatformMixin(platform), DeadlineSchedulerMixin(scheduler)]
|
||||||
|
telemetry = telemetry_from_settings(settings, session_store, game_store)
|
||||||
|
if telemetry is not None:
|
||||||
|
otel_mixin = otel_mixin_from_settings(
|
||||||
|
settings,
|
||||||
|
meter_provider=telemetry.meter_provider,
|
||||||
|
tracer_provider=telemetry.tracer_provider,
|
||||||
|
)
|
||||||
|
assert otel_mixin is not None # OTEL_ENABLED was checked above
|
||||||
|
# kaya-otel first: before hooks run in registration order (after hooks
|
||||||
|
# in reverse), so the span covers session loading, OIDC handling and the
|
||||||
|
# handler itself. CORS, when enabled, is still prepended before it so
|
||||||
|
# preflight short-circuits stay untraced. The telemetry mixin carries no
|
||||||
|
# hooks but owns the metric/logger providers' polling and shutdown, so
|
||||||
|
# it must be in the list too.
|
||||||
|
mixins.insert(0, telemetry.mixin)
|
||||||
|
mixins.insert(0, otel_mixin)
|
||||||
|
log.info(
|
||||||
|
"OpenTelemetry enabled: service=%s endpoint=%s (traces, metrics, logs)",
|
||||||
|
settings.otel_service_name,
|
||||||
|
settings.otel_exporter_endpoint or "(OTLP default)",
|
||||||
|
)
|
||||||
|
|
||||||
|
cors_mixin = cors_mixin_from_settings(settings)
|
||||||
|
if cors_mixin is not None:
|
||||||
|
# First in the list: preflight requests are answered before the session
|
||||||
|
# and OIDC hooks run.
|
||||||
|
mixins.insert(0, cors_mixin)
|
||||||
|
log.info(
|
||||||
|
"CORS enabled: origins=%s origin_regex=%s credentials=%s",
|
||||||
|
settings.cors_allow_origins,
|
||||||
|
settings.cors_allow_origin_regex,
|
||||||
|
settings.cors_allow_credentials,
|
||||||
|
)
|
||||||
|
|
||||||
|
app = KayaApp(mixins=mixins)
|
||||||
|
|
||||||
|
# Register the SPA shell. The catch-all only matches paths no other route
|
||||||
|
# claimed (asset files under /static are served by Granian itself and never
|
||||||
|
# reach the app).
|
||||||
|
from . import static # noqa: E402,F401
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
"""Environment-driven configuration for the tavolo application.
|
||||||
|
|
||||||
|
Mirrors kaya's own pattern: read ``os.environ`` directly into a plain
|
||||||
|
dataclass. No pydantic-settings, no settings module.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
|
||||||
|
def _env(name: str, default: Optional[str] = None) -> str:
|
||||||
|
value = os.environ.get(name)
|
||||||
|
if value is None or value == "":
|
||||||
|
if default is None:
|
||||||
|
raise RuntimeError(f"Missing required environment variable: {name}")
|
||||||
|
return default
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _env_list(name: str) -> Optional[Tuple[str, ...]]:
|
||||||
|
"""Parse a comma-separated environment variable into a tuple of values.
|
||||||
|
|
||||||
|
Items are stripped and empty items dropped. Unset or empty variables
|
||||||
|
yield ``None``.
|
||||||
|
"""
|
||||||
|
value = os.environ.get(name)
|
||||||
|
if value is None or value.strip() == "":
|
||||||
|
return None
|
||||||
|
return tuple(part.strip() for part in value.split(",") if part.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _env_bool(name: str, default: bool = False) -> bool:
|
||||||
|
value = os.environ.get(name)
|
||||||
|
if value is None or value == "":
|
||||||
|
return default
|
||||||
|
return value.strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
|
# Per-provider OIDC settings are indexed with a 0-based suffix:
|
||||||
|
# OIDC_<n>_PROVIDER, OIDC_<n>_ISSUER, ... Indices must be contiguous from 0.
|
||||||
|
_OIDC_VARIABLES = (
|
||||||
|
"PROVIDER",
|
||||||
|
"ISSUER",
|
||||||
|
"CLIENT_ID",
|
||||||
|
"CLIENT_SECRET",
|
||||||
|
"REDIRECT_URI",
|
||||||
|
"POST_LOGIN_REDIRECT",
|
||||||
|
"POST_LOGOUT_REDIRECT",
|
||||||
|
)
|
||||||
|
_OIDC_ENV = re.compile(r"^OIDC_(\d+)_([A-Z0-9_]+)$")
|
||||||
|
_PROVIDER_NAME = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||||
|
|
||||||
|
# The pre-0.0.6 unindexed names, detected only to point the migration out.
|
||||||
|
_LEGACY_OIDC_VARIABLES = (
|
||||||
|
"OIDC_ISSUER",
|
||||||
|
"OIDC_CLIENT_ID",
|
||||||
|
"OIDC_CLIENT_SECRET",
|
||||||
|
"OIDC_REDIRECT_URI",
|
||||||
|
"OIDC_POST_LOGIN_REDIRECT",
|
||||||
|
"OIDC_POST_LOGOUT_REDIRECT",
|
||||||
|
)
|
||||||
|
|
||||||
|
_DEFAULT_OIDC_ISSUER = "http://localhost:8180/tavolo"
|
||||||
|
_DEFAULT_OIDC_CLIENT_ID = "tavolo"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OidcProviderSettings:
|
||||||
|
"""One OIDC provider, parsed from the ``OIDC_<n>_*`` variables.
|
||||||
|
|
||||||
|
``provider`` names the provider in URLs (``/auth/<provider>/login``)
|
||||||
|
and namespaces its session state (``oidc:<provider>:...``); it must
|
||||||
|
match kaya-oidc's ``[A-Za-z0-9_-]+`` pattern.
|
||||||
|
"""
|
||||||
|
|
||||||
|
provider: str
|
||||||
|
issuer: str
|
||||||
|
client_id: str
|
||||||
|
client_secret: Optional[str]
|
||||||
|
redirect_uri: str
|
||||||
|
post_login_redirect: str
|
||||||
|
post_logout_redirect: str
|
||||||
|
|
||||||
|
|
||||||
|
def _oidc_providers_from_env() -> Tuple[OidcProviderSettings, ...]:
|
||||||
|
"""Parse the indexed ``OIDC_<n>_*`` provider configuration.
|
||||||
|
|
||||||
|
With no indexed variables at all, fall back to a single provider
|
||||||
|
named ``default`` pointing at the dev mock IdP, mirroring the
|
||||||
|
pre-0.0.6 defaults. The old unindexed variables are rejected with a
|
||||||
|
migration hint: they would otherwise be silently ignored.
|
||||||
|
"""
|
||||||
|
legacy = [name for name in _LEGACY_OIDC_VARIABLES if os.environ.get(name)]
|
||||||
|
if legacy:
|
||||||
|
renamed = ", ".join(
|
||||||
|
f"{name} -> OIDC_0_{name[len('OIDC_'):]}" for name in legacy
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
"the OIDC variables are now indexed per provider "
|
||||||
|
f"({renamed}; add OIDC_0_PROVIDER)"
|
||||||
|
)
|
||||||
|
|
||||||
|
indices: set = set()
|
||||||
|
for name in os.environ:
|
||||||
|
match = _OIDC_ENV.match(name)
|
||||||
|
if match is None:
|
||||||
|
continue
|
||||||
|
index, variable = match.groups()
|
||||||
|
if variable not in _OIDC_VARIABLES:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"unknown OIDC environment variable {name!r}; expected one of "
|
||||||
|
+ ", ".join(f"OIDC_<n>_{v}" for v in _OIDC_VARIABLES)
|
||||||
|
)
|
||||||
|
indices.add(int(index))
|
||||||
|
|
||||||
|
if not indices:
|
||||||
|
return (
|
||||||
|
OidcProviderSettings(
|
||||||
|
provider="default",
|
||||||
|
issuer=_DEFAULT_OIDC_ISSUER,
|
||||||
|
client_id=_DEFAULT_OIDC_CLIENT_ID,
|
||||||
|
client_secret=None,
|
||||||
|
redirect_uri="http://localhost:8080/auth/default/callback",
|
||||||
|
post_login_redirect="/",
|
||||||
|
post_logout_redirect="/",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
missing = sorted(set(range(max(indices) + 1)) - indices)
|
||||||
|
if missing:
|
||||||
|
raise RuntimeError(
|
||||||
|
"OIDC provider indices must be contiguous from 0; missing "
|
||||||
|
+ ", ".join(f"OIDC_{index}_*" for index in missing)
|
||||||
|
)
|
||||||
|
|
||||||
|
providers = []
|
||||||
|
seen = set()
|
||||||
|
for index in sorted(indices):
|
||||||
|
prefix = f"OIDC_{index}_"
|
||||||
|
provider = _env(prefix + "PROVIDER")
|
||||||
|
if _PROVIDER_NAME.match(provider) is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{prefix}PROVIDER must be a non-empty string of letters, "
|
||||||
|
f"digits, underscores or hyphens (got {provider!r})"
|
||||||
|
)
|
||||||
|
if provider in seen:
|
||||||
|
raise RuntimeError(f"duplicate OIDC provider name {provider!r}")
|
||||||
|
seen.add(provider)
|
||||||
|
providers.append(
|
||||||
|
OidcProviderSettings(
|
||||||
|
provider=provider,
|
||||||
|
issuer=_env(prefix + "ISSUER"),
|
||||||
|
client_id=_env(prefix + "CLIENT_ID"),
|
||||||
|
client_secret=os.environ.get(prefix + "CLIENT_SECRET") or None,
|
||||||
|
# kaya-oidc's OIDCProviders derives the callback route from
|
||||||
|
# the provider name; keep the default in sync with it.
|
||||||
|
redirect_uri=_env(
|
||||||
|
prefix + "REDIRECT_URI",
|
||||||
|
f"http://localhost:8080/auth/{provider}/callback",
|
||||||
|
),
|
||||||
|
post_login_redirect=_env(prefix + "POST_LOGIN_REDIRECT", "/"),
|
||||||
|
post_logout_redirect=_env(prefix + "POST_LOGOUT_REDIRECT", "/"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(providers)
|
||||||
|
|
||||||
|
|
||||||
|
def _database_url_from_parts(engine: str,
|
||||||
|
user: str,
|
||||||
|
password: Optional[str],
|
||||||
|
host: str,
|
||||||
|
port: str,
|
||||||
|
name: str,
|
||||||
|
options: str) -> str:
|
||||||
|
"""Assemble a database DSN from individual components.
|
||||||
|
|
||||||
|
``user`` and ``password`` are percent-encoded so credentials containing
|
||||||
|
URL-reserved characters (``@``, ``:``, ``/``, ...) do not corrupt the
|
||||||
|
DSN. ``port`` and ``options`` are omitted when empty: a missing port
|
||||||
|
lets the driver pick its default (5432 for asyncpg). ``options`` is a
|
||||||
|
raw query string (e.g. ``ssl=require``) appended after a ``?``.
|
||||||
|
"""
|
||||||
|
netloc = quote(user, safe="")
|
||||||
|
if password:
|
||||||
|
netloc += ":" + quote(password, safe="")
|
||||||
|
netloc += "@" + host
|
||||||
|
if port:
|
||||||
|
netloc += ":" + port
|
||||||
|
url = f"{engine}://{netloc}/{name}"
|
||||||
|
options = options.lstrip("?")
|
||||||
|
if options:
|
||||||
|
url += "?" + options
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Settings:
|
||||||
|
database_url: str
|
||||||
|
# The configured OIDC providers, in index order. Each provider
|
||||||
|
# authenticates users independently and namespaces its session state;
|
||||||
|
# see OidcProviderSettings above.
|
||||||
|
oidc_providers: Tuple[OidcProviderSettings, ...]
|
||||||
|
app_host: str
|
||||||
|
app_port: int
|
||||||
|
redis_url: Optional[str]
|
||||||
|
# How long a live game (and its join-code index) survives in Redis
|
||||||
|
# without activity, in seconds. Defaults to 24h.
|
||||||
|
game_ttl_seconds: int
|
||||||
|
# Directory holding the compiled frontend (trunk's dist output). Only
|
||||||
|
# used to locate index.html for the SPA shell; the assets themselves
|
||||||
|
# are served by Granian under /static (GRANIAN_STATIC_PATH_* env vars).
|
||||||
|
static_dir: str
|
||||||
|
# Seconds the between-hands scoring summary waits for acknowledgements
|
||||||
|
# before dealing the next hand anyway (wired into the game engines;
|
||||||
|
# see tavolo.app).
|
||||||
|
hand_ack_timeout_seconds: int
|
||||||
|
# Seconds a player has to play before the server plays a random legal
|
||||||
|
# card for them, covering disconnects and idle players (wired into the
|
||||||
|
# game engines; see tavolo.app).
|
||||||
|
turn_timeout_seconds: int
|
||||||
|
# Upper bound on how long the deadline consumer sleeps between polls
|
||||||
|
# when nothing else wakes it. Enqueued deadlines are announced over a
|
||||||
|
# pub/sub channel, so this is only the backstop recovering lost
|
||||||
|
# notifications and deadlines orphaned by a dead worker.
|
||||||
|
deadline_heartbeat_ms: int
|
||||||
|
# Path to a YAML logging configuration file (logging.config.dictConfig
|
||||||
|
# schema). Unset uses the built-in default: DEBUG to the console.
|
||||||
|
logging_config: Optional[str]
|
||||||
|
# CORS (kaya-cors' CorsMixin). Disabled unless CORS_ALLOW_ORIGINS or
|
||||||
|
# CORS_ALLOW_ORIGIN_REGEX is set; the app serves the SPA and the API
|
||||||
|
# from the same origin, so no CORS headers are needed by default.
|
||||||
|
cors_allow_origins: Optional[Tuple[str, ...]]
|
||||||
|
cors_allow_origin_regex: Optional[str]
|
||||||
|
cors_allow_methods: Optional[Tuple[str, ...]]
|
||||||
|
cors_allow_headers: Optional[Tuple[str, ...]]
|
||||||
|
cors_allow_credentials: bool
|
||||||
|
cors_expose_headers: Optional[Tuple[str, ...]]
|
||||||
|
cors_max_age: int
|
||||||
|
# OpenTelemetry (kaya-otel's OTelMixin). Disabled unless OTEL_ENABLED is
|
||||||
|
# truthy; the exporter endpoint falls back to the OTLP/HTTP default
|
||||||
|
# (localhost:4318) when OTEL_EXPORTER_OTLP_ENDPOINT is unset.
|
||||||
|
otel_enabled: bool
|
||||||
|
otel_service_name: str
|
||||||
|
otel_exporter_endpoint: Optional[str]
|
||||||
|
otel_exporter_headers: Optional[Tuple[str, ...]]
|
||||||
|
# Paths excluded from tracing and metrics (exact matches). Defaults to
|
||||||
|
# the health endpoint, which k8s probes would otherwise spam.
|
||||||
|
otel_excluded_paths: Tuple[str, ...]
|
||||||
|
# How often the OTLP metric reader exports (milliseconds) and how often
|
||||||
|
# the telemetry gauges refresh the session/game counts (seconds).
|
||||||
|
otel_metric_export_interval_millis: int
|
||||||
|
otel_metrics_poll_interval_seconds: int
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_env() -> "Settings":
|
||||||
|
return Settings(
|
||||||
|
# DATABASE_URL, when set, is used verbatim and the DATABASE_*
|
||||||
|
# parts below are ignored (sqlite in tests, managed-DB DSNs).
|
||||||
|
database_url=os.environ.get("DATABASE_URL") or _database_url_from_parts(
|
||||||
|
engine=_env("DATABASE_ENGINE", "postgres"),
|
||||||
|
user=_env("DATABASE_USER", "tavolo"),
|
||||||
|
password=_env("DATABASE_PASSWORD", "password"),
|
||||||
|
host=_env("DATABASE_HOST", "localhost"),
|
||||||
|
# Unset: the port segment is omitted and the driver default
|
||||||
|
# (5432 for asyncpg) applies.
|
||||||
|
port=os.environ.get("DATABASE_PORT", ""),
|
||||||
|
name=_env("DATABASE_NAME", "tavolo"),
|
||||||
|
# Raw DSN query string (e.g. "ssl=require"); empty = none.
|
||||||
|
options=os.environ.get("DATABASE_OPTIONS", ""),
|
||||||
|
),
|
||||||
|
oidc_providers=_oidc_providers_from_env(),
|
||||||
|
app_host=_env("APP_HOST", "0.0.0.0"),
|
||||||
|
app_port=int(_env("APP_PORT", "8080")),
|
||||||
|
# When unset, sessions and live games fall back to in-memory
|
||||||
|
# stores (tests, ephemeral dev). Set to e.g.
|
||||||
|
# redis://localhost:6379/0 to persist both in Redis.
|
||||||
|
redis_url=os.environ.get("REDIS_URL"),
|
||||||
|
game_ttl_seconds=int(_env("GAME_TTL_SECONDS", "86400")),
|
||||||
|
static_dir=_env("STATIC_DIR", "web/dist"),
|
||||||
|
hand_ack_timeout_seconds=int(_env("HAND_ACK_TIMEOUT_SECONDS", "30")),
|
||||||
|
turn_timeout_seconds=int(_env("TURN_TIMEOUT_SECONDS", "30")),
|
||||||
|
deadline_heartbeat_ms=int(_env("DEADLINE_HEARTBEAT_MS", "30000")),
|
||||||
|
logging_config=os.environ.get("LOGGING_CONFIG"),
|
||||||
|
# CORS is disabled unless CORS_ALLOW_ORIGINS (a comma-separated
|
||||||
|
# list of origins, or "*" for any) or CORS_ALLOW_ORIGIN_REGEX
|
||||||
|
# is set.
|
||||||
|
cors_allow_origins=_env_list("CORS_ALLOW_ORIGINS"),
|
||||||
|
cors_allow_origin_regex=os.environ.get("CORS_ALLOW_ORIGIN_REGEX") or None,
|
||||||
|
cors_allow_methods=_env_list("CORS_ALLOW_METHODS"),
|
||||||
|
cors_allow_headers=_env_list("CORS_ALLOW_HEADERS"),
|
||||||
|
cors_allow_credentials=_env_bool("CORS_ALLOW_CREDENTIALS"),
|
||||||
|
cors_expose_headers=_env_list("CORS_EXPOSE_HEADERS"),
|
||||||
|
cors_max_age=int(_env("CORS_MAX_AGE", "600")),
|
||||||
|
# OpenTelemetry is opt-in: set OTEL_ENABLED=1 to export traces,
|
||||||
|
# metrics and logs via OTLP/HTTP (requires the ``otel`` extra).
|
||||||
|
otel_enabled=_env_bool("OTEL_ENABLED"),
|
||||||
|
otel_service_name=_env("OTEL_SERVICE_NAME", "tavolo"),
|
||||||
|
otel_exporter_endpoint=os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") or None,
|
||||||
|
# Comma-separated key=value pairs, e.g. "Authorization=Bearer x".
|
||||||
|
otel_exporter_headers=_env_list("OTEL_EXPORTER_OTLP_HEADERS"),
|
||||||
|
otel_excluded_paths=_env_list("OTEL_EXCLUDED_PATHS") or ("/api/health",),
|
||||||
|
otel_metric_export_interval_millis=int(
|
||||||
|
_env("OTEL_METRIC_EXPORT_INTERVAL_MILLIS", "60000")
|
||||||
|
),
|
||||||
|
otel_metrics_poll_interval_seconds=int(
|
||||||
|
_env("OTEL_METRICS_POLL_INTERVAL_SECONDS", "15")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
settings: Settings = Settings.from_env()
|
||||||
+10
-1
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
Configured once at app import time (see :mod:`tavolo.app`). By default a
|
Configured once at app import time (see :mod:`tavolo.app`). By default a
|
||||||
single console handler at DEBUG level is installed, formatting records as
|
single console handler at DEBUG level is installed, formatting records as
|
||||||
``{asctime} [{levelname}] ({processName}/{threadName}) - {name} - {message}``.
|
``{asctime} [{levelname}] ({processName}/{threadName}) - {name} - {message}``;
|
||||||
|
``urllib3.connectionpool`` is capped at INFO so its per-request DEBUG
|
||||||
|
chatter does not drown the log.
|
||||||
Point the ``LOGGING_CONFIG`` environment variable at a YAML file to take
|
Point the ``LOGGING_CONFIG`` environment variable at a YAML file to take
|
||||||
over the configuration entirely; the file follows the
|
over the configuration entirely; the file follows the
|
||||||
:data:`logging.config.dictConfig` schema, e.g.::
|
:data:`logging.config.dictConfig` schema, e.g.::
|
||||||
@@ -55,6 +57,13 @@ DEFAULT_CONFIG = {
|
|||||||
"level": "DEBUG",
|
"level": "DEBUG",
|
||||||
"handlers": ["console"],
|
"handlers": ["console"],
|
||||||
},
|
},
|
||||||
|
"loggers": {
|
||||||
|
# Every pooled HTTP request is a DEBUG record; keep the logger's
|
||||||
|
# INFO+ output, drop the per-connection noise.
|
||||||
|
"urllib3.connectionpool": {
|
||||||
|
"level": "INFO",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
+2
-2
@@ -17,8 +17,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
from kaya.core import HttpContext
|
from kaya.core import HttpContext
|
||||||
|
|
||||||
from ..app import app
|
from .app import app
|
||||||
from ..config import settings
|
from .config import settings
|
||||||
|
|
||||||
|
|
||||||
async def _send_shell(ctx: HttpContext) -> None:
|
async def _send_shell(ctx: HttpContext) -> None:
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
"""OpenTelemetry metrics and log export beyond kaya-otel.
|
||||||
|
|
||||||
|
kaya-otel instruments HTTP requests and websockets with traces and HTTP
|
||||||
|
metrics; this module adds what it does not cover, all behind the optional
|
||||||
|
``otel`` extra (OpenTelemetry imports are lazy, so default installs and
|
||||||
|
the test suite never need them):
|
||||||
|
|
||||||
|
* application gauges: ``tavolo.active_users`` (sessions held in the
|
||||||
|
session store) and ``tavolo.ongoing_games`` (live matches that have not
|
||||||
|
finished yet);
|
||||||
|
* process metrics through ``opentelemetry-instrumentation-system-metrics``:
|
||||||
|
CPU time/utilization, memory usage and CPython garbage-collection
|
||||||
|
counters;
|
||||||
|
* OTLP log export through a stdlib :class:`logging.Handler` attached to
|
||||||
|
the root logger, mirroring what the console sees;
|
||||||
|
* session/user tagging of request and websocket spans through
|
||||||
|
:func:`session_tag_hook` (kaya-otel's response/close hooks), so traces
|
||||||
|
can be correlated with a session (hashed id) and an OIDC user.
|
||||||
|
|
||||||
|
The store counts are asynchronous while OpenTelemetry instrument
|
||||||
|
callbacks are synchronous, so :class:`TelemetryMixin` polls the stores on
|
||||||
|
a background task and its callbacks report the cached values.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from logging import getLogger
|
||||||
|
from typing import TYPE_CHECKING, Any, Dict, Optional, Union
|
||||||
|
|
||||||
|
from kaya.core import KayaApp, KayaMixin
|
||||||
|
from kaya.session import InMemorySessionStore, SessionStore
|
||||||
|
from kaya.session.redis import RedisSessionStore
|
||||||
|
from redis.asyncio import Redis
|
||||||
|
from tavolo.platform.auth import session_user
|
||||||
|
from tavolo.platform.store import GameStore
|
||||||
|
|
||||||
|
from .config import Settings
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from opentelemetry.sdk._logs import LoggerProvider
|
||||||
|
from opentelemetry.sdk.metrics import MeterProvider
|
||||||
|
from opentelemetry.sdk.trace import TracerProvider
|
||||||
|
|
||||||
|
log = getLogger(__name__)
|
||||||
|
|
||||||
|
# Session keys live under kaya's default prefix. Keeping it explicit here
|
||||||
|
# makes the Redis count scan exact and preserves existing sessions.
|
||||||
|
SESSION_KEY_PREFIX = "kaya:session:"
|
||||||
|
|
||||||
|
# The interpreter metrics to export. The instrumentation package can also
|
||||||
|
# emit host-level ``system.*`` metrics; those are deliberately left off:
|
||||||
|
# the application cares about its own process, and per-CPU host metrics
|
||||||
|
# add cardinality without context.
|
||||||
|
#
|
||||||
|
# ``process.cpu.utilization`` ignores its configuration value upstream
|
||||||
|
# (the callback always emits one aggregate observation), so its ``None``
|
||||||
|
# is only for readability.
|
||||||
|
PROCESS_METRICS_CONFIG: Dict[str, Optional[list]] = {
|
||||||
|
"process.cpu.time": ["user", "system"],
|
||||||
|
"process.cpu.utilization": None,
|
||||||
|
"process.memory.usage": None,
|
||||||
|
"process.memory.virtual": None,
|
||||||
|
"cpython.gc.collections": None,
|
||||||
|
"cpython.gc.collected_objects": None,
|
||||||
|
"cpython.gc.uncollectable_objects": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def otel_headers(settings: Settings) -> Optional[Dict[str, str]]:
|
||||||
|
"""Parse ``OTEL_EXPORTER_OTLP_HEADERS`` into a mapping (or ``None``)."""
|
||||||
|
headers = dict(
|
||||||
|
pair.split("=", 1)
|
||||||
|
for pair in (settings.otel_exporter_headers or ())
|
||||||
|
if "=" in pair
|
||||||
|
)
|
||||||
|
return headers or None
|
||||||
|
|
||||||
|
|
||||||
|
def _signal_endpoint(base: Optional[str], signal: str) -> Optional[str]:
|
||||||
|
"""``{base}/v1/<signal>``, or ``None`` for the exporter's own default."""
|
||||||
|
return f"{base}/{signal}" if base else None
|
||||||
|
|
||||||
|
|
||||||
|
def session_tag_hook(span: Any, carrier: Any) -> None:
|
||||||
|
"""Tag an HTTP request/websocket span with the request's session/user.
|
||||||
|
|
||||||
|
Wired into kaya-otel as its ``server_response_hook`` and
|
||||||
|
``websocket_close_hook``: those run after the session mixin's after
|
||||||
|
hook has persisted (and, at login, minted) the session, so the final
|
||||||
|
session id is visible here.
|
||||||
|
|
||||||
|
The session id is the session cookie, i.e. a credential, so only a
|
||||||
|
truncated SHA-256 of it is exported; session ids are
|
||||||
|
``secrets.token_urlsafe(32)`` (256 bits), which makes the digest
|
||||||
|
irreversible in practice. The OIDC ``sub`` is a pseudonymous
|
||||||
|
identifier already persisted in Postgres and served by the API, so it
|
||||||
|
is exported verbatim as ``user.id`` (OpenTelemetry's current end-user
|
||||||
|
attribute; ``enduser.id`` is PII-flagged and being superseded).
|
||||||
|
"""
|
||||||
|
session = getattr(carrier, "session", None)
|
||||||
|
if session is None:
|
||||||
|
return
|
||||||
|
session_id = getattr(session, "id", None)
|
||||||
|
if session_id:
|
||||||
|
span.set_attribute(
|
||||||
|
"session.id",
|
||||||
|
hashlib.sha256(session_id.encode()).hexdigest()[:16],
|
||||||
|
)
|
||||||
|
user = session_user(session)
|
||||||
|
if user is not None:
|
||||||
|
sub = user.get("sub")
|
||||||
|
if sub:
|
||||||
|
span.set_attribute("user.id", str(sub))
|
||||||
|
|
||||||
|
|
||||||
|
class _NoOtelLogs(logging.Filter):
|
||||||
|
"""Drop OpenTelemetry's own log records.
|
||||||
|
|
||||||
|
Export failures are logged by the SDK; forwarding those through the
|
||||||
|
same exporter would feed an endless retry loop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
|
return not record.name.startswith("opentelemetry")
|
||||||
|
|
||||||
|
|
||||||
|
class CountingInMemorySessionStore(InMemorySessionStore):
|
||||||
|
"""In-memory session store that can report its live session count."""
|
||||||
|
|
||||||
|
async def count(self) -> int:
|
||||||
|
now = self._clock()
|
||||||
|
return sum(
|
||||||
|
1
|
||||||
|
for session_id in self._data
|
||||||
|
if session_id not in self._expires
|
||||||
|
or self._expires[session_id] > now
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CountingRedisSessionStore(RedisSessionStore):
|
||||||
|
"""Redis session store that can report its live session count."""
|
||||||
|
|
||||||
|
def __init__(self, client: Redis, prefix: str = SESSION_KEY_PREFIX) -> None:
|
||||||
|
super().__init__(client, prefix=prefix)
|
||||||
|
|
||||||
|
async def count(self) -> int:
|
||||||
|
total = 0
|
||||||
|
async for _ in self._client.scan_iter(match=f"{self._prefix}*", count=500):
|
||||||
|
total += 1
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
# The session stores the application builds: both expose ``count()`` on top
|
||||||
|
# of kaya's ``SessionStore`` interface. Game stores need no alias — the
|
||||||
|
# platform's ``GameStore`` declares ``count_ongoing()`` itself.
|
||||||
|
CountableSessionStore = Union[CountingInMemorySessionStore, CountingRedisSessionStore]
|
||||||
|
|
||||||
|
|
||||||
|
class TelemetryMixin(KayaMixin):
|
||||||
|
"""Application gauges, DB instrumentation and log export.
|
||||||
|
|
||||||
|
The meter, tracer and logger providers are built by
|
||||||
|
:func:`telemetry_from_settings` and injected: kaya-otel only shuts
|
||||||
|
down providers it created itself, so this mixin owns their shutdown
|
||||||
|
(and uninstruments the database clients it instrumented).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
meter_provider: "MeterProvider",
|
||||||
|
tracer_provider: "TracerProvider",
|
||||||
|
logger_provider: Optional["LoggerProvider"],
|
||||||
|
log_handler: Optional[logging.Handler],
|
||||||
|
session_store: CountableSessionStore,
|
||||||
|
game_store: GameStore,
|
||||||
|
poll_interval_seconds: float,
|
||||||
|
) -> None:
|
||||||
|
self._meter_provider = meter_provider
|
||||||
|
self._tracer_provider = tracer_provider
|
||||||
|
self._logger_provider = logger_provider
|
||||||
|
self._log_handler = log_handler
|
||||||
|
self._session_store = session_store
|
||||||
|
self._game_store = game_store
|
||||||
|
self._poll_interval = poll_interval_seconds
|
||||||
|
self._active_users: Optional[int] = None
|
||||||
|
self._ongoing_games: Optional[int] = None
|
||||||
|
self._poll_task: Optional[asyncio.Task] = None
|
||||||
|
self._instrumentor: Optional[Any] = None
|
||||||
|
self._db_instrumentors: list = []
|
||||||
|
|
||||||
|
def apply(self, app: KayaApp) -> None:
|
||||||
|
from opentelemetry.instrumentation.asyncpg import AsyncPGInstrumentor
|
||||||
|
from opentelemetry.instrumentation.redis import RedisInstrumentor
|
||||||
|
from opentelemetry.instrumentation.system_metrics import (
|
||||||
|
SystemMetricsInstrumentor,
|
||||||
|
)
|
||||||
|
from opentelemetry.metrics import CallbackOptions, Observation
|
||||||
|
|
||||||
|
instrumentor = SystemMetricsInstrumentor(config=PROCESS_METRICS_CONFIG)
|
||||||
|
self._instrumentor = instrumentor
|
||||||
|
instrumentor.instrument(meter_provider=self._meter_provider)
|
||||||
|
|
||||||
|
# Query spans for the Postgres (asyncpg, used by Tortoise) and Redis
|
||||||
|
# clients. Redis command values are redacted by the instrumentation
|
||||||
|
# (``SET ? ?``); asyncpg spans carry the parameterized SQL only.
|
||||||
|
self._db_instrumentors = [
|
||||||
|
AsyncPGInstrumentor(capture_parameters=False),
|
||||||
|
RedisInstrumentor(),
|
||||||
|
]
|
||||||
|
for db_instrumentor in self._db_instrumentors:
|
||||||
|
db_instrumentor.instrument(tracer_provider=self._tracer_provider)
|
||||||
|
|
||||||
|
meter = self._meter_provider.get_meter(__name__)
|
||||||
|
|
||||||
|
def active_users(options: CallbackOptions):
|
||||||
|
if self._active_users is not None:
|
||||||
|
yield Observation(self._active_users)
|
||||||
|
|
||||||
|
def ongoing_games(options: CallbackOptions):
|
||||||
|
if self._ongoing_games is not None:
|
||||||
|
yield Observation(self._ongoing_games)
|
||||||
|
|
||||||
|
meter.create_observable_gauge(
|
||||||
|
"tavolo.active_users",
|
||||||
|
callbacks=[active_users],
|
||||||
|
unit="{user}",
|
||||||
|
description="Sessions currently held in the session store.",
|
||||||
|
)
|
||||||
|
meter.create_observable_gauge(
|
||||||
|
"tavolo.ongoing_games",
|
||||||
|
callbacks=[ongoing_games],
|
||||||
|
unit="{game}",
|
||||||
|
description="Live games that have not finished yet.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- lifecycle ----------------------------------------------------------
|
||||||
|
|
||||||
|
def setup(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||||
|
self._poll_task = loop.create_task(self._poll())
|
||||||
|
|
||||||
|
def shutdown(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||||
|
if self._poll_task is not None:
|
||||||
|
self._poll_task.cancel()
|
||||||
|
self._poll_task = None
|
||||||
|
for db_instrumentor in self._db_instrumentors:
|
||||||
|
db_instrumentor.uninstrument()
|
||||||
|
self._db_instrumentors = []
|
||||||
|
if self._log_handler is not None:
|
||||||
|
logging.getLogger().removeHandler(self._log_handler)
|
||||||
|
self._log_handler.close()
|
||||||
|
self._log_handler = None
|
||||||
|
if self._logger_provider is not None:
|
||||||
|
self._logger_provider.shutdown()
|
||||||
|
self._tracer_provider.shutdown()
|
||||||
|
self._meter_provider.shutdown()
|
||||||
|
|
||||||
|
async def _poll(self) -> None:
|
||||||
|
while True:
|
||||||
|
await self.refresh_counts()
|
||||||
|
await asyncio.sleep(self._poll_interval)
|
||||||
|
|
||||||
|
async def refresh_counts(self) -> None:
|
||||||
|
"""Refresh the cached store counts (the poll loop calls this)."""
|
||||||
|
try:
|
||||||
|
self._active_users = await self._session_store.count()
|
||||||
|
except Exception:
|
||||||
|
log.warning("active_users refresh failed", exc_info=True)
|
||||||
|
try:
|
||||||
|
self._ongoing_games = await self._game_store.count_ongoing()
|
||||||
|
except Exception:
|
||||||
|
log.warning("ongoing_games refresh failed", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Telemetry:
|
||||||
|
"""The providers and mixin built from the ``OTEL_*`` settings."""
|
||||||
|
|
||||||
|
meter_provider: "MeterProvider"
|
||||||
|
tracer_provider: "TracerProvider"
|
||||||
|
logger_provider: Optional["LoggerProvider"]
|
||||||
|
log_handler: Optional[logging.Handler]
|
||||||
|
mixin: TelemetryMixin
|
||||||
|
|
||||||
|
|
||||||
|
def telemetry_from_settings(
|
||||||
|
settings: Settings,
|
||||||
|
session_store: CountableSessionStore,
|
||||||
|
game_store: GameStore,
|
||||||
|
) -> Optional[Telemetry]:
|
||||||
|
"""Build the metric/trace/log providers, or ``None`` when telemetry is off.
|
||||||
|
|
||||||
|
Only called when ``OTEL_ENABLED`` is set; the OpenTelemetry imports
|
||||||
|
are lazy so the default install does not need the ``otel`` extra.
|
||||||
|
"""
|
||||||
|
if not settings.otel_enabled:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from opentelemetry.exporter.otlp.proto.http._log_exporter import (
|
||||||
|
OTLPLogExporter,
|
||||||
|
)
|
||||||
|
from opentelemetry.exporter.otlp.proto.http.metric_exporter import (
|
||||||
|
OTLPMetricExporter,
|
||||||
|
)
|
||||||
|
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
|
||||||
|
OTLPSpanExporter,
|
||||||
|
)
|
||||||
|
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
|
||||||
|
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
|
||||||
|
from opentelemetry.sdk.metrics import MeterProvider
|
||||||
|
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
|
||||||
|
from opentelemetry.sdk.resources import Resource
|
||||||
|
from opentelemetry.sdk.trace import TracerProvider
|
||||||
|
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"OTEL_ENABLED is set but the OpenTelemetry dependencies are not "
|
||||||
|
"installed; install tavolo-app with the 'otel' extra"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
endpoint = settings.otel_exporter_endpoint
|
||||||
|
headers = otel_headers(settings)
|
||||||
|
resource = Resource.create({"service.name": settings.otel_service_name})
|
||||||
|
tracer_provider = TracerProvider(resource=resource)
|
||||||
|
tracer_provider.add_span_processor(
|
||||||
|
BatchSpanProcessor(
|
||||||
|
OTLPSpanExporter(
|
||||||
|
endpoint=_signal_endpoint(endpoint, "v1/traces"),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
meter_provider = MeterProvider(
|
||||||
|
resource=resource,
|
||||||
|
metric_readers=[
|
||||||
|
PeriodicExportingMetricReader(
|
||||||
|
OTLPMetricExporter(
|
||||||
|
endpoint=_signal_endpoint(endpoint, "v1/metrics"),
|
||||||
|
headers=headers,
|
||||||
|
),
|
||||||
|
export_interval_millis=settings.otel_metric_export_interval_millis,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
logger_provider = LoggerProvider(resource=resource)
|
||||||
|
logger_provider.add_log_record_processor(
|
||||||
|
BatchLogRecordProcessor(
|
||||||
|
OTLPLogExporter(
|
||||||
|
endpoint=_signal_endpoint(endpoint, "v1/logs"),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
handler = LoggingHandler(level=logging.NOTSET, logger_provider=logger_provider)
|
||||||
|
handler.addFilter(_NoOtelLogs())
|
||||||
|
logging.getLogger().addHandler(handler)
|
||||||
|
mixin = TelemetryMixin(
|
||||||
|
meter_provider=meter_provider,
|
||||||
|
tracer_provider=tracer_provider,
|
||||||
|
logger_provider=logger_provider,
|
||||||
|
log_handler=handler,
|
||||||
|
session_store=session_store,
|
||||||
|
game_store=game_store,
|
||||||
|
poll_interval_seconds=settings.otel_metrics_poll_interval_seconds,
|
||||||
|
)
|
||||||
|
return Telemetry(
|
||||||
|
meter_provider=meter_provider,
|
||||||
|
tracer_provider=tracer_provider,
|
||||||
|
logger_provider=logger_provider,
|
||||||
|
log_handler=handler,
|
||||||
|
mixin=mixin,
|
||||||
|
)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# tavolo-briscola
|
||||||
|
|
||||||
|
Briscola — the Italian trick-taking game — as a
|
||||||
|
[`tavolo-platform`](../tavolo-platform/README.md) game implementation.
|
||||||
|
Two variants share one rules core:
|
||||||
|
|
||||||
|
- `briscola` — the classic two-player game;
|
||||||
|
- `briscola_a_quattro` — four-player fixed-partnership briscola.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- `state.py` — `BriscolaState` (pure game data: phases, players, hands,
|
||||||
|
stock, face-up briscola, tricks, scores, deadlines), `PlayerState`,
|
||||||
|
`Card`, `TrickCard`, `Trick`, with JSON (de)serialization. No session
|
||||||
|
envelope, no transport, no I/O.
|
||||||
|
- `engine.py` — the pure rules engine: deck, deals, trick resolution
|
||||||
|
(trump first, then the led suit; strength ace > three > king > horse >
|
||||||
|
jack > 7 > 6 > 5 > 4 > 2), the draw order (trick winner first, the
|
||||||
|
face-up briscola last), card points (ace 11, three 10, king 4, horse 3,
|
||||||
|
jack 2), patta re-deals and the target-score match end, deal-end
|
||||||
|
acknowledgements and auto-play. Deterministic and I/O-free apart from
|
||||||
|
logging, so the whole rule set is unit-testable.
|
||||||
|
- `errors.py` — briscola-specific errors (`CardNotInHand`); every other
|
||||||
|
failure mode is a shared `tavolo.platform.errors` subclass.
|
||||||
|
- `plugin.py` — `BriscolaEngine` and `BriscolaQuattroEngine`
|
||||||
|
(`GameEngine`): the platform-facing adapters. They validate the
|
||||||
|
`target_score` creation option (61–120, default 61) and extract the
|
||||||
|
`MatchResult` (teams, winner, per-player scores and the match summary
|
||||||
|
persisted as the match's JSON `result`).
|
||||||
|
|
||||||
|
Timeouts are constructor arguments (`turn_timeout_seconds`,
|
||||||
|
`hand_ack_timeout_seconds`), wired from the environment by the
|
||||||
|
application composition root.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Three cards per player; the next card is turned face up: its suit is
|
||||||
|
the trump (briscola) for the deal and it is the last card drawn.
|
||||||
|
- The winner of a trick is the highest trump, or the highest card of the
|
||||||
|
led suit when no trump was played. The winner takes the trick and leads
|
||||||
|
the next one.
|
||||||
|
- After each trick everyone draws back up to three cards, starting with
|
||||||
|
the trick winner.
|
||||||
|
- Card points total 120 per deal; a 60–60 deal is a *patta*, its points
|
||||||
|
are discarded and the deal is replayed. The first team to the target
|
||||||
|
score wins; a tie at or above the target is broken by another deal.
|
||||||
|
- Between deals of an unfinished match every player acknowledges the
|
||||||
|
summary (or the hand-end timeout deals on), exactly like scopone.
|
||||||
|
|
||||||
|
## Development (from `server/`)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
.venv/bin/python -m unittest discover -s packages/tavolo-briscola/tests
|
||||||
|
.venv/bin/python -m mypy -p tavolo.briscola
|
||||||
|
```
|
||||||
|
|
||||||
|
`test_engine.py` covers the pure rules; `test_plugin.py` covers the
|
||||||
|
platform contract (actions, deadlines, serialization, results).
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "tavolo-briscola"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Briscola game implementation for the tavolo platform"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"tavolo-platform",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
namespaces = true
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.12"
|
||||||
|
ignore_missing_imports = true
|
||||||
|
plugins = []
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""Briscola: tavolo's trick-taking game implementation.
|
||||||
|
|
||||||
|
Two variants share the same rules core and platform adapter:
|
||||||
|
|
||||||
|
- :class:`~tavolo.briscola.plugin.BriscolaEngine` — classic two-player
|
||||||
|
briscola (``briscola``);
|
||||||
|
- :class:`~tavolo.briscola.plugin.BriscolaQuattroEngine` — four-player
|
||||||
|
fixed-partnership briscola a quattro (``briscola_a_quattro``).
|
||||||
|
|
||||||
|
The pure rules (:mod:`tavolo.briscola.engine`,
|
||||||
|
:mod:`tavolo.briscola.state`) know nothing about HTTP, Redis or Postgres;
|
||||||
|
:mod:`tavolo.briscola.plugin` adapts them to the platform's
|
||||||
|
:class:`~tavolo.platform.engine.GameEngine` contract so the
|
||||||
|
game-independent platform can host them.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .plugin import BriscolaEngine, BriscolaQuattroEngine
|
||||||
|
|
||||||
|
__all__ = ["BriscolaEngine", "BriscolaQuattroEngine"]
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
"""Pure rules engine for briscola.
|
||||||
|
|
||||||
|
Every function here is deterministic and I/O-free (the only side effect is
|
||||||
|
logging): it mutates (or reads)
|
||||||
|
:class:`~tavolo.briscola.state.BriscolaState` and raises
|
||||||
|
:class:`~tavolo.platform.errors.GameError` subclasses on rule violations.
|
||||||
|
This makes the whole rule set unit-testable without Redis, Postgres or
|
||||||
|
HTTP. The platform-facing adapter is
|
||||||
|
:class:`~tavolo.briscola.plugin.BriscolaEngine`.
|
||||||
|
|
||||||
|
Rules implemented
|
||||||
|
-----------------
|
||||||
|
* 40-card Italian deck (4 suits x ranks 1-10), three cards per player.
|
||||||
|
* The next card after the deal is turned face up: its suit is the
|
||||||
|
**briscola** (trump) for the whole deal, and it is the last card drawn
|
||||||
|
from the stock (the stock is drawn from the bottom up).
|
||||||
|
* Trick play: the player to the dealer's left leads; each other player
|
||||||
|
plays one card. The highest trump wins; when no trump was played, the
|
||||||
|
highest card of the led suit wins. The winner takes the trick and leads
|
||||||
|
the next one.
|
||||||
|
* After each trick every player draws back up to three cards, starting
|
||||||
|
with the trick winner and continuing in turn order; the face-up
|
||||||
|
briscola card is drawn last, so whoever's turn to draw when only it
|
||||||
|
remains takes it.
|
||||||
|
* Card points: ace 11, three 10, king 4, horse 3, jack 2, everything else
|
||||||
|
0 — 120 points per deal. Trick strength is unrelated to points:
|
||||||
|
ace > three > king > horse > jack > 7 > 6 > 5 > 4 > 2.
|
||||||
|
* Match points accumulate across deals. A 60-60 deal is a **patta**: its
|
||||||
|
points are discarded and the deal is replayed (the dealer rotates).
|
||||||
|
The first team to reach the target score (default 61) wins; a tie at or
|
||||||
|
above the target is broken by another deal.
|
||||||
|
* Between deals of an unfinished match the game pauses in the
|
||||||
|
``hand_end`` phase for the deal summary, exactly like scopone: every
|
||||||
|
player acknowledges (or the hand-end timeout elapses) before the next
|
||||||
|
deal.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from logging import getLogger
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from tavolo.platform.errors import (
|
||||||
|
AlreadyJoined,
|
||||||
|
GameFinished,
|
||||||
|
GameNotStarted,
|
||||||
|
IllegalMove,
|
||||||
|
LobbyFull,
|
||||||
|
NotYourTurn,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .errors import CardNotInHand
|
||||||
|
|
||||||
|
from .state import (
|
||||||
|
DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||||
|
DEFAULT_TARGET_SCORE,
|
||||||
|
DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||||
|
HAND_SIZE,
|
||||||
|
MAX_TARGET_SCORE,
|
||||||
|
MIN_TARGET_SCORE,
|
||||||
|
PHASE_FINISHED,
|
||||||
|
PHASE_HAND_END,
|
||||||
|
PHASE_LOBBY,
|
||||||
|
PHASE_PLAYING,
|
||||||
|
PLAYER_COUNTS,
|
||||||
|
SUITS,
|
||||||
|
TEAM_NAMES,
|
||||||
|
BriscolaState,
|
||||||
|
Card,
|
||||||
|
PlayerState,
|
||||||
|
Trick,
|
||||||
|
TrickCard,
|
||||||
|
parse_card,
|
||||||
|
)
|
||||||
|
|
||||||
|
log = getLogger(__name__)
|
||||||
|
|
||||||
|
# Trick strength: higher wins. Ace > three > king > horse > jack, then the
|
||||||
|
# remaining ranks in descending order. All ranks are distinct within a
|
||||||
|
# suit, so a trick never ties.
|
||||||
|
TRICK_STRENGTH: Dict[int, int] = {
|
||||||
|
1: 9,
|
||||||
|
3: 8,
|
||||||
|
10: 7,
|
||||||
|
9: 6,
|
||||||
|
8: 5,
|
||||||
|
7: 4,
|
||||||
|
6: 3,
|
||||||
|
5: 2,
|
||||||
|
4: 1,
|
||||||
|
2: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Card points: 120 per deal in total.
|
||||||
|
CARD_POINTS: Dict[int, int] = {
|
||||||
|
1: 11,
|
||||||
|
3: 10,
|
||||||
|
10: 4,
|
||||||
|
9: 3,
|
||||||
|
8: 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
_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 card_points(card: Card) -> int:
|
||||||
|
"""The point value of a single card."""
|
||||||
|
return CARD_POINTS.get(card.rank, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def create_game(
|
||||||
|
creator_sub: str,
|
||||||
|
creator_name: str,
|
||||||
|
player_count: int = 2,
|
||||||
|
target_score: int = DEFAULT_TARGET_SCORE,
|
||||||
|
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||||
|
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||||
|
) -> BriscolaState:
|
||||||
|
"""Create a lobby game with the creator seated first."""
|
||||||
|
if player_count not in PLAYER_COUNTS:
|
||||||
|
raise IllegalMove(f"player_count must be one of {PLAYER_COUNTS}")
|
||||||
|
if (
|
||||||
|
isinstance(target_score, bool)
|
||||||
|
or not isinstance(target_score, int)
|
||||||
|
or target_score < MIN_TARGET_SCORE
|
||||||
|
or target_score > MAX_TARGET_SCORE
|
||||||
|
):
|
||||||
|
raise IllegalMove(
|
||||||
|
f"target_score must be between {MIN_TARGET_SCORE} and {MAX_TARGET_SCORE}"
|
||||||
|
)
|
||||||
|
return BriscolaState(
|
||||||
|
player_count=player_count,
|
||||||
|
target_score=target_score,
|
||||||
|
phase=PHASE_LOBBY,
|
||||||
|
players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)],
|
||||||
|
hand_ack_timeout=hand_ack_timeout,
|
||||||
|
turn_timeout=turn_timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def join_game(state: BriscolaState, 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) >= state.player_count:
|
||||||
|
raise LobbyFull("game is full")
|
||||||
|
seat = len(state.players)
|
||||||
|
state.players.append(PlayerState(sub=sub, name=name, seat=seat))
|
||||||
|
if len(state.players) == state.player_count:
|
||||||
|
start_game(state)
|
||||||
|
|
||||||
|
|
||||||
|
def start_game(state: BriscolaState) -> None:
|
||||||
|
"""Deal the first hand and switch the game to playing."""
|
||||||
|
if len(state.players) != state.player_count:
|
||||||
|
raise GameNotStarted(
|
||||||
|
f"need exactly {state.player_count} players to start"
|
||||||
|
)
|
||||||
|
state.phase = PHASE_PLAYING
|
||||||
|
_deal(state)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_turn_deadline(state: BriscolaState) -> 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(state: BriscolaState) -> None:
|
||||||
|
deck = shuffled_deck()
|
||||||
|
for player in state.players:
|
||||||
|
player.hand = []
|
||||||
|
player.captured = []
|
||||||
|
state.trick = []
|
||||||
|
state.last_trick = None
|
||||||
|
state.briscola_card = None
|
||||||
|
# Dealer rotates each deal; the first card is played by the player to
|
||||||
|
# the dealer's left.
|
||||||
|
state.turn = (state.dealer + 1) % state.player_count
|
||||||
|
state.trick_leader = state.turn
|
||||||
|
for _ in range(HAND_SIZE):
|
||||||
|
for offset in range(state.player_count):
|
||||||
|
player = _player_at(
|
||||||
|
state, (state.dealer + 1 + offset) % state.player_count
|
||||||
|
)
|
||||||
|
player.hand.append(deck.pop())
|
||||||
|
# The turned-up card is the last card drawn: keep it separate from the
|
||||||
|
# face-down stock and hand it out once the stock is exhausted.
|
||||||
|
state.briscola_card = deck.pop()
|
||||||
|
state.briscola_suit = state.briscola_card.suit
|
||||||
|
state.stock = deck
|
||||||
|
_set_turn_deadline(state)
|
||||||
|
log.debug(
|
||||||
|
"deal %d dealt (dealer seat %d, briscola %s)",
|
||||||
|
state.hand_number,
|
||||||
|
state.dealer,
|
||||||
|
state.briscola_card.code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _player_at(state: BriscolaState, 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: BriscolaState, sub: str, card_code: str) -> None:
|
||||||
|
"""Apply one move by the player identified by ``sub``.
|
||||||
|
|
||||||
|
Any card in hand is a legal play; only turn order and phase are
|
||||||
|
validated. Raises a :class:`~tavolo.platform.errors.GameError`
|
||||||
|
subclass on any violation.
|
||||||
|
"""
|
||||||
|
if state.phase == PHASE_FINISHED:
|
||||||
|
raise GameFinished("the match is over")
|
||||||
|
if state.phase == PHASE_HAND_END:
|
||||||
|
raise IllegalMove("the deal 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")
|
||||||
|
|
||||||
|
player.hand.remove(card)
|
||||||
|
state.trick.append(TrickCard(seat=player.seat, card=card.code))
|
||||||
|
log.debug(
|
||||||
|
"deal %d: seat %d played %s (%d/%d in the trick)",
|
||||||
|
state.hand_number,
|
||||||
|
player.seat,
|
||||||
|
card.code,
|
||||||
|
len(state.trick),
|
||||||
|
state.player_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(state.trick) == state.player_count:
|
||||||
|
_resolve_trick(state)
|
||||||
|
else:
|
||||||
|
state.turn = (state.turn + 1) % state.player_count
|
||||||
|
_set_turn_deadline(state)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_trick(state: BriscolaState) -> None:
|
||||||
|
"""Score the completed trick, draw and hand the lead to the winner."""
|
||||||
|
winner = _trick_winner(state)
|
||||||
|
taken = [parse_card(t.card) for t in state.trick]
|
||||||
|
_player_at(state, winner).captured.extend(taken)
|
||||||
|
state.last_trick = Trick(winner=winner, cards=list(state.trick))
|
||||||
|
state.trick = []
|
||||||
|
state.trick_leader = winner
|
||||||
|
state.turn = winner
|
||||||
|
log.debug(
|
||||||
|
"deal %d: seat %d takes the trick with %s",
|
||||||
|
state.hand_number,
|
||||||
|
winner,
|
||||||
|
[t.card for t in state.last_trick.cards],
|
||||||
|
)
|
||||||
|
|
||||||
|
if all(not player.hand for player in state.players):
|
||||||
|
_end_deal(state)
|
||||||
|
return
|
||||||
|
_draw(state, winner)
|
||||||
|
_set_turn_deadline(state)
|
||||||
|
|
||||||
|
|
||||||
|
def _trick_winner(state: BriscolaState) -> int:
|
||||||
|
"""The seat winning the current (complete) trick."""
|
||||||
|
played = [(t.seat, parse_card(t.card)) for t in state.trick]
|
||||||
|
leader_suit = played[0][1].suit
|
||||||
|
trumps = [(seat, card) for seat, card in played if card.suit == state.briscola_suit]
|
||||||
|
pool = trumps or [
|
||||||
|
(seat, card) for seat, card in played if card.suit == leader_suit
|
||||||
|
]
|
||||||
|
return max(pool, key=lambda item: TRICK_STRENGTH[item[1].rank])[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _draw(state: BriscolaState, winner: int) -> None:
|
||||||
|
"""Draw back up to ``HAND_SIZE`` cards, winner first, then turn order."""
|
||||||
|
for offset in range(state.player_count):
|
||||||
|
player = _player_at(state, (winner + offset) % state.player_count)
|
||||||
|
if state.stock:
|
||||||
|
player.hand.append(state.stock.pop())
|
||||||
|
elif state.briscola_card is not None:
|
||||||
|
# The face-up briscola is the last card of the stock: the first
|
||||||
|
# player who needs a card when only it remains takes it.
|
||||||
|
player.hand.append(state.briscola_card)
|
||||||
|
state.briscola_card = None
|
||||||
|
|
||||||
|
|
||||||
|
def auto_play(state: BriscolaState, rng: Optional[random.Random] = None) -> None:
|
||||||
|
"""Play a random card for the player currently on turn.
|
||||||
|
|
||||||
|
Every card in hand is legal, so one is drawn at random. Delegates to
|
||||||
|
:func:`play`, so the move is fully validated and can end the deal 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)
|
||||||
|
play(state, player.sub, card.code)
|
||||||
|
|
||||||
|
|
||||||
|
def _end_deal(state: BriscolaState) -> None:
|
||||||
|
"""Score the deal and either finish the match or pause for the summary.
|
||||||
|
|
||||||
|
A 60-60 deal is a patta: its points do not count and the deal is
|
||||||
|
replayed. If the match continues, the game pauses in the ``hand_end``
|
||||||
|
phase so every player can read the summary; the next deal is dealt by
|
||||||
|
:func:`acknowledge_hand` once all players have acknowledged (or by the
|
||||||
|
hand-end timeout fired by the platform's deadline scheduler).
|
||||||
|
"""
|
||||||
|
state.turn_deadline = None
|
||||||
|
points, details = deal_points(state)
|
||||||
|
patta = points[0] == points[1]
|
||||||
|
if not patta:
|
||||||
|
state.scores[0] += points[0]
|
||||||
|
state.scores[1] += points[1]
|
||||||
|
entry: Dict[str, Any] = {
|
||||||
|
"deal": state.hand_number,
|
||||||
|
"patta": patta,
|
||||||
|
"points": details["points"],
|
||||||
|
"cards": details["cards"],
|
||||||
|
"totals": {"A": state.scores[0], "B": state.scores[1]},
|
||||||
|
}
|
||||||
|
state.hand_scores.append(entry)
|
||||||
|
|
||||||
|
a, b = state.scores
|
||||||
|
log.debug(
|
||||||
|
"deal %d scored A+%d B+%d%s (totals %d-%d)",
|
||||||
|
state.hand_number,
|
||||||
|
points[0],
|
||||||
|
points[1],
|
||||||
|
" patta" if patta else "",
|
||||||
|
a,
|
||||||
|
b,
|
||||||
|
)
|
||||||
|
if not patta and max(a, b) >= state.target_score and a != b:
|
||||||
|
state.phase = PHASE_FINISHED
|
||||||
|
state.winner = 0 if a > b else 1
|
||||||
|
log.info("match ended, team %s wins", TEAM_NAMES[state.winner])
|
||||||
|
return
|
||||||
|
|
||||||
|
# Pause for the deal 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: BriscolaState, sub: str) -> None:
|
||||||
|
"""Record that ``sub`` has read the deal summary.
|
||||||
|
|
||||||
|
When every player has acknowledged, the next deal 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 deal 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) < state.player_count:
|
||||||
|
return
|
||||||
|
|
||||||
|
state.hand_number += 1
|
||||||
|
state.dealer = (state.dealer + 1) % state.player_count
|
||||||
|
state.acked = []
|
||||||
|
state.hand_end_deadline = None
|
||||||
|
state.phase = PHASE_PLAYING
|
||||||
|
_deal(state)
|
||||||
|
|
||||||
|
|
||||||
|
def deal_points(state: BriscolaState) -> Tuple[List[int], Dict[str, object]]:
|
||||||
|
"""Compute the deal points for both teams (index 0 = team A)."""
|
||||||
|
piles: List[List[Card]] = [[], []]
|
||||||
|
for player in state.players:
|
||||||
|
piles[player.team].extend(player.captured)
|
||||||
|
points = [
|
||||||
|
sum(card_points(card) for card in pile) for pile in piles
|
||||||
|
]
|
||||||
|
details: Dict[str, object] = {
|
||||||
|
"points": {"A": points[0], "B": points[1]},
|
||||||
|
"cards": {"A": len(piles[0]), "B": len(piles[1])},
|
||||||
|
}
|
||||||
|
return points, details
|
||||||
|
|
||||||
|
|
||||||
|
def state_for_player(state: BriscolaState, sub: Optional[str]) -> Dict[str, object]:
|
||||||
|
"""Serialize ``state`` hiding other players' hands.
|
||||||
|
|
||||||
|
Hands are reduced to a count, except for the requesting player's own
|
||||||
|
hand. Non-seated viewers — including spectators, for whom ``sub`` is
|
||||||
|
``None`` — simply see no hand at all. The platform merges the session
|
||||||
|
envelope (``id``, ``join_code`` for seated players, ``game_type``,
|
||||||
|
``spectator``) into the view itself.
|
||||||
|
"""
|
||||||
|
viewer = state.player_for(sub)
|
||||||
|
players: List[Dict[str, object]] = []
|
||||||
|
for player in state.players:
|
||||||
|
view: Dict[str, object] = {
|
||||||
|
"sub": player.sub,
|
||||||
|
"name": player.name,
|
||||||
|
"seat": player.seat,
|
||||||
|
"team": TEAM_NAMES[player.team],
|
||||||
|
"cards_left": len(player.hand),
|
||||||
|
"captured_count": len(player.captured),
|
||||||
|
"points": sum(card_points(card) for card in player.captured),
|
||||||
|
}
|
||||||
|
if viewer is not None and viewer.seat == player.seat:
|
||||||
|
view["hand"] = [c.code for c in player.hand]
|
||||||
|
players.append(view)
|
||||||
|
|
||||||
|
payload: Dict[str, object] = {
|
||||||
|
"phase": state.phase,
|
||||||
|
"target_score": state.target_score,
|
||||||
|
"player_count": state.player_count,
|
||||||
|
"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],
|
||||||
|
"briscola_suit": state.briscola_suit,
|
||||||
|
"briscola_card": (
|
||||||
|
state.briscola_card.code if state.briscola_card is not None else None
|
||||||
|
),
|
||||||
|
"stock_count": len(state.stock),
|
||||||
|
"trick": [t.to_json() for t in state.trick],
|
||||||
|
"trick_leader": state.trick_leader,
|
||||||
|
"last_trick": state.last_trick.to_json() if state.last_trick else None,
|
||||||
|
"players": players,
|
||||||
|
"last_deal": state.hand_scores[-1] if state.hand_scores 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
|
||||||
|
return payload
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Briscola errors.
|
||||||
|
|
||||||
|
Most failure modes are game-independent and live in
|
||||||
|
:mod:`tavolo.platform.errors`; only genuinely briscola-specific errors
|
||||||
|
are defined here.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from tavolo.platform.errors import (
|
||||||
|
AlreadyJoined,
|
||||||
|
GameError,
|
||||||
|
GameFinished,
|
||||||
|
GameNotFound,
|
||||||
|
GameNotStarted,
|
||||||
|
IllegalMove,
|
||||||
|
LobbyFull,
|
||||||
|
NotYourTurn,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AlreadyJoined",
|
||||||
|
"CardNotInHand",
|
||||||
|
"GameError",
|
||||||
|
"GameFinished",
|
||||||
|
"GameNotFound",
|
||||||
|
"GameNotStarted",
|
||||||
|
"IllegalMove",
|
||||||
|
"LobbyFull",
|
||||||
|
"NotYourTurn",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class CardNotInHand(GameError):
|
||||||
|
"""The played card is not held by the player."""
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
"""The platform-facing adapter for briscola.
|
||||||
|
|
||||||
|
Two :class:`~tavolo.platform.engine.GameEngine` implementations share one
|
||||||
|
rules core (:mod:`tavolo.briscola.engine`): classic two-player
|
||||||
|
:class:`BriscolaEngine` and four-player fixed-partnership
|
||||||
|
:class:`BriscolaQuattroEngine`. They translate platform calls (create,
|
||||||
|
join, websocket actions, deadlines) into rules-engine calls and back,
|
||||||
|
keeping all briscola knowledge inside this package. Nothing outside
|
||||||
|
``tavolo.briscola`` needs to know about phases, tricks or deal scoring.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from logging import getLogger
|
||||||
|
from typing import Any, Dict, Mapping, Optional
|
||||||
|
|
||||||
|
from tavolo.platform.engine import (
|
||||||
|
Deadline,
|
||||||
|
GameEngine,
|
||||||
|
GameSession,
|
||||||
|
MatchResult,
|
||||||
|
PlayerResult,
|
||||||
|
Seat,
|
||||||
|
)
|
||||||
|
from tavolo.platform.errors import GameError, IllegalMove
|
||||||
|
|
||||||
|
from . import engine
|
||||||
|
from .state import (
|
||||||
|
DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||||
|
DEFAULT_TARGET_SCORE,
|
||||||
|
DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||||
|
MAX_TARGET_SCORE,
|
||||||
|
MIN_TARGET_SCORE,
|
||||||
|
PHASE_FINISHED,
|
||||||
|
PHASE_HAND_END,
|
||||||
|
PHASE_LOBBY,
|
||||||
|
PHASE_PLAYING,
|
||||||
|
TEAM_NAMES,
|
||||||
|
BriscolaState,
|
||||||
|
)
|
||||||
|
|
||||||
|
log = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _deadline_ms(iso: Optional[str]) -> Optional[int]:
|
||||||
|
"""Epoch milliseconds for an ISO-8601 deadline, ``None`` when absent
|
||||||
|
or unparseable."""
|
||||||
|
if not iso:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(datetime.fromisoformat(iso).timestamp() * 1000)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _BriscolaBase(GameEngine):
|
||||||
|
"""Shared platform adapter for the two briscola table sizes."""
|
||||||
|
|
||||||
|
#: 2 (classic) or 4 (fixed partnerships); set by the subclasses.
|
||||||
|
player_count: int
|
||||||
|
|
||||||
|
options_schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"target_score": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": MIN_TARGET_SCORE,
|
||||||
|
"maximum": MAX_TARGET_SCORE,
|
||||||
|
"default": DEFAULT_TARGET_SCORE,
|
||||||
|
"description": "Match points the winning team must reach.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
turn_timeout_seconds: int = DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||||
|
hand_ack_timeout_seconds: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||||
|
) -> None:
|
||||||
|
self._turn_timeout = turn_timeout_seconds
|
||||||
|
self._hand_ack_timeout = hand_ack_timeout_seconds
|
||||||
|
|
||||||
|
# -- lobby ------------------------------------------------------------
|
||||||
|
|
||||||
|
def create(self, session: GameSession, options: Mapping[str, Any]) -> None:
|
||||||
|
target_score = options.get("target_score", DEFAULT_TARGET_SCORE)
|
||||||
|
if isinstance(target_score, bool) or not isinstance(target_score, int):
|
||||||
|
raise IllegalMove("target_score must be an integer")
|
||||||
|
creator = session.players[0]
|
||||||
|
session.state = engine.create_game(
|
||||||
|
creator_sub=creator.user_sub,
|
||||||
|
creator_name=creator.display_name,
|
||||||
|
player_count=self.player_count,
|
||||||
|
target_score=target_score,
|
||||||
|
hand_ack_timeout=self._hand_ack_timeout,
|
||||||
|
turn_timeout=self._turn_timeout,
|
||||||
|
)
|
||||||
|
# The creator takes seat 0, i.e. team A.
|
||||||
|
session.players[0] = Seat(
|
||||||
|
user_sub=creator.user_sub,
|
||||||
|
display_name=creator.display_name,
|
||||||
|
team=TEAM_NAMES[0],
|
||||||
|
)
|
||||||
|
|
||||||
|
def join(self, session: GameSession, user_sub: str, display_name: str) -> None:
|
||||||
|
seat = len(session.players)
|
||||||
|
engine.join_game(session.state, user_sub, display_name)
|
||||||
|
# join_game raises before appending on any violation, so the seat
|
||||||
|
# list stays in sync with the engine's players.
|
||||||
|
session.players.append(
|
||||||
|
Seat(
|
||||||
|
user_sub=user_sub,
|
||||||
|
display_name=display_name,
|
||||||
|
team=TEAM_NAMES[seat % 2],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def in_lobby(self, session: GameSession) -> bool:
|
||||||
|
return session.state.phase == PHASE_LOBBY
|
||||||
|
|
||||||
|
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
|
||||||
|
state: BriscolaState = session.state
|
||||||
|
return {
|
||||||
|
"phase": state.phase,
|
||||||
|
"target_score": state.target_score,
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- play -------------------------------------------------------------
|
||||||
|
|
||||||
|
def handle_action(
|
||||||
|
self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any]
|
||||||
|
) -> None:
|
||||||
|
state: BriscolaState = session.state
|
||||||
|
if action == "play":
|
||||||
|
card = payload.get("card")
|
||||||
|
if not isinstance(card, str):
|
||||||
|
raise IllegalMove("'card' must be a card code string")
|
||||||
|
try:
|
||||||
|
engine.play(state, user_sub, card)
|
||||||
|
except ValueError:
|
||||||
|
raise IllegalMove("invalid card code")
|
||||||
|
elif action == "ack":
|
||||||
|
engine.acknowledge_hand(state, user_sub)
|
||||||
|
else:
|
||||||
|
raise IllegalMove(f"unknown action: {action!r}")
|
||||||
|
|
||||||
|
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
|
||||||
|
return engine.state_for_player(session.state, user_sub)
|
||||||
|
|
||||||
|
def is_finished(self, session: GameSession) -> bool:
|
||||||
|
return session.state.phase == PHASE_FINISHED
|
||||||
|
|
||||||
|
def game_over_view(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
|
||||||
|
state: BriscolaState = session.state
|
||||||
|
return {
|
||||||
|
"scores": {"A": state.scores[0], "B": state.scores[1]},
|
||||||
|
"winner": None if state.winner is None else TEAM_NAMES[state.winner],
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- (de)serialization -------------------------------------------------
|
||||||
|
|
||||||
|
def state_to_json(self, state: Any) -> Dict[str, Any]:
|
||||||
|
assert isinstance(state, BriscolaState)
|
||||||
|
return state.to_json()
|
||||||
|
|
||||||
|
def state_from_json(self, data: Mapping[str, Any]) -> Any:
|
||||||
|
return BriscolaState.from_json(dict(data))
|
||||||
|
|
||||||
|
# -- deadlines ----------------------------------------------------------
|
||||||
|
|
||||||
|
def next_deadline(self, session: GameSession) -> Optional[Deadline]:
|
||||||
|
state: BriscolaState = session.state
|
||||||
|
if state.phase == PHASE_PLAYING and state.turn_deadline:
|
||||||
|
due_ms = _deadline_ms(state.turn_deadline)
|
||||||
|
if due_ms is None:
|
||||||
|
return None
|
||||||
|
return Deadline(
|
||||||
|
kind="turn",
|
||||||
|
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
|
||||||
|
token=f"turn:{state.hand_number}:{state.turn}:{due_ms}",
|
||||||
|
)
|
||||||
|
if state.phase == PHASE_HAND_END and state.hand_end_deadline:
|
||||||
|
due_ms = _deadline_ms(state.hand_end_deadline)
|
||||||
|
if due_ms is None:
|
||||||
|
return None
|
||||||
|
return Deadline(
|
||||||
|
kind="hand_end",
|
||||||
|
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
|
||||||
|
token=f"hand_end:{state.hand_number}:{due_ms}",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fire_deadline(self, session: GameSession, kind: str, token: str) -> None:
|
||||||
|
current = self.next_deadline(session)
|
||||||
|
if current is None or current.kind != kind or current.token != token:
|
||||||
|
# Overtaken by events (a play landed in time, the deal was
|
||||||
|
# acknowledged, the deadline moved): nothing to do.
|
||||||
|
raise GameError("stale deadline")
|
||||||
|
state: BriscolaState = session.state
|
||||||
|
if kind == "turn":
|
||||||
|
seat = state.turn
|
||||||
|
engine.auto_play(state)
|
||||||
|
log.info(
|
||||||
|
"auto-played for seat %s (turn timeout, deal %d)",
|
||||||
|
seat,
|
||||||
|
state.hand_number,
|
||||||
|
)
|
||||||
|
elif kind == "hand_end":
|
||||||
|
for player in state.players:
|
||||||
|
engine.acknowledge_hand(state, player.sub)
|
||||||
|
log.info(
|
||||||
|
"deal %d auto-advanced after the acknowledgement timeout",
|
||||||
|
state.hand_number,
|
||||||
|
)
|
||||||
|
else: # pragma: no cover - next_deadline never emits other kinds
|
||||||
|
raise GameError(f"unknown deadline kind: {kind!r}")
|
||||||
|
|
||||||
|
# -- results -------------------------------------------------------------
|
||||||
|
|
||||||
|
def result(self, session: GameSession) -> MatchResult:
|
||||||
|
state: BriscolaState = session.state
|
||||||
|
if state.winner is None:
|
||||||
|
raise GameError("no result: the match is not finished")
|
||||||
|
teams = [
|
||||||
|
[p.sub for p in state.players if p.team == 0],
|
||||||
|
[p.sub for p in state.players if p.team == 1],
|
||||||
|
]
|
||||||
|
return MatchResult(
|
||||||
|
teams=teams,
|
||||||
|
winner_team=state.winner,
|
||||||
|
players=[
|
||||||
|
PlayerResult(
|
||||||
|
user_sub=player.sub,
|
||||||
|
seat=player.seat,
|
||||||
|
won=player.team == state.winner,
|
||||||
|
team=TEAM_NAMES[player.team],
|
||||||
|
score=float(state.scores[player.team]),
|
||||||
|
details={"captured_cards": len(player.captured)},
|
||||||
|
)
|
||||||
|
for player in state.players
|
||||||
|
],
|
||||||
|
summary={
|
||||||
|
"team_a_score": state.scores[0],
|
||||||
|
"team_b_score": state.scores[1],
|
||||||
|
"winner_team": TEAM_NAMES[state.winner],
|
||||||
|
"target_score": state.target_score,
|
||||||
|
"deals_played": state.hand_number,
|
||||||
|
"deal_scores": state.hand_scores,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BriscolaEngine(_BriscolaBase):
|
||||||
|
"""Classic two-player briscola as a platform game engine."""
|
||||||
|
|
||||||
|
id = "briscola"
|
||||||
|
name = "Briscola"
|
||||||
|
description = (
|
||||||
|
"The classic two-player Italian trick-taking game: three cards "
|
||||||
|
"each, a trump suit and 120 points per deal. First to the target "
|
||||||
|
"score wins."
|
||||||
|
)
|
||||||
|
min_players = 2
|
||||||
|
max_players = 2
|
||||||
|
player_count = 2
|
||||||
|
|
||||||
|
|
||||||
|
class BriscolaQuattroEngine(_BriscolaBase):
|
||||||
|
"""Four-player fixed-partnership briscola as a platform game engine."""
|
||||||
|
|
||||||
|
id = "briscola_a_quattro"
|
||||||
|
name = "Briscola a quattro"
|
||||||
|
description = (
|
||||||
|
"Briscola played in two fixed partnerships of two, three cards "
|
||||||
|
"each and a trump suit. First team to the target score wins."
|
||||||
|
)
|
||||||
|
min_players = 4
|
||||||
|
max_players = 4
|
||||||
|
player_count = 4
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
"""In-memory representation of a briscola game.
|
||||||
|
|
||||||
|
The whole mutable game lives in :class:`BriscolaState`, which is
|
||||||
|
serialized to and from plain JSON for storage inside the platform's
|
||||||
|
session envelope (see :mod:`tavolo.platform.store`). Keeping the
|
||||||
|
representation JSON-native means the store needs no custom codecs and the
|
||||||
|
state is inspectable with ``redis-cli``.
|
||||||
|
|
||||||
|
The state contains only game data: the platform owns the session envelope
|
||||||
|
(id, join code, seats, timestamps, stats persistence) — see
|
||||||
|
:class:`tavolo.platform.engine.GameSession`.
|
||||||
|
|
||||||
|
Deck convention: a 40-card Italian deck. Suits are ``D`` (denari),
|
||||||
|
``C`` (coppe), ``S`` (spade) and ``B`` (bastoni); ranks are ``1``..``10``.
|
||||||
|
A card is rendered as ``RRSUIT`` (e.g. ``07D``).
|
||||||
|
"""
|
||||||
|
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). In the two-player variant the teams
|
||||||
|
# are simply the two players; in briscola a quattro the partners sit
|
||||||
|
# opposite each other.
|
||||||
|
TEAM_A = 0
|
||||||
|
TEAM_B = 1
|
||||||
|
TEAM_NAMES = ("A", "B")
|
||||||
|
|
||||||
|
PHASE_LOBBY = "lobby"
|
||||||
|
PHASE_PLAYING = "playing"
|
||||||
|
# Between deals of an unfinished match: deal summary shown to every
|
||||||
|
# player; the next deal is dealt once all players acknowledge (or the
|
||||||
|
# hand-end timeout elapses).
|
||||||
|
PHASE_HAND_END = "hand_end"
|
||||||
|
PHASE_FINISHED = "finished"
|
||||||
|
|
||||||
|
# Supported table sizes: classic head-to-head and four-player partnerships.
|
||||||
|
PLAYER_COUNTS = (2, 4)
|
||||||
|
|
||||||
|
DEFAULT_TARGET_SCORE = 61
|
||||||
|
MIN_TARGET_SCORE = 61
|
||||||
|
MAX_TARGET_SCORE = 120
|
||||||
|
|
||||||
|
# Cards dealt to each player at the start of a deal.
|
||||||
|
HAND_SIZE = 3
|
||||||
|
|
||||||
|
# Default seconds the between-deals summary waits before dealing anyway.
|
||||||
|
# Games carry their own copy in ``BriscolaState.hand_ack_timeout``.
|
||||||
|
DEFAULT_HAND_ACK_TIMEOUT_SECONDS = 30
|
||||||
|
|
||||||
|
# Default seconds a player has to play before the server plays a random
|
||||||
|
# card for them. Games carry their own copy in
|
||||||
|
# ``BriscolaState.turn_timeout``.
|
||||||
|
DEFAULT_TURN_TIMEOUT_SECONDS = 30
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
return Card.parse(str(code))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TrickCard:
|
||||||
|
"""One card played in the current trick, in play order."""
|
||||||
|
|
||||||
|
seat: int
|
||||||
|
card: str
|
||||||
|
|
||||||
|
def to_json(self) -> Dict[str, Any]:
|
||||||
|
return {"seat": self.seat, "card": self.card}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_json(data: Dict[str, Any]) -> "TrickCard":
|
||||||
|
return TrickCard(seat=int(data["seat"]), card=str(data["card"]))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Trick:
|
||||||
|
"""A completed trick: the winner's seat and the cards it captured."""
|
||||||
|
|
||||||
|
winner: int
|
||||||
|
cards: List[TrickCard] = field(default_factory=list)
|
||||||
|
|
||||||
|
def to_json(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"winner": self.winner,
|
||||||
|
"cards": [c.to_json() for c in self.cards],
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_json(data: Dict[str, Any]) -> "Trick":
|
||||||
|
return Trick(
|
||||||
|
winner=int(data["winner"]),
|
||||||
|
cards=[TrickCard.from_json(c) for c in data.get("cards", [])],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PlayerState:
|
||||||
|
sub: str
|
||||||
|
name: str
|
||||||
|
seat: int
|
||||||
|
hand: List[Card] = field(default_factory=list)
|
||||||
|
captured: List[Card] = field(default_factory=list)
|
||||||
|
|
||||||
|
@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],
|
||||||
|
}
|
||||||
|
|
||||||
|
@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", [])],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BriscolaState:
|
||||||
|
player_count: int = 2
|
||||||
|
target_score: int = DEFAULT_TARGET_SCORE
|
||||||
|
phase: str = PHASE_LOBBY
|
||||||
|
players: List[PlayerState] = field(default_factory=list)
|
||||||
|
# Face-down draw pile; cards are drawn from the end.
|
||||||
|
stock: List[Card] = field(default_factory=list)
|
||||||
|
# The face-up card turned at deal time: its suit is the trump suit for
|
||||||
|
# the whole deal, and it is the last card drawn from the stock.
|
||||||
|
briscola_card: Optional[Card] = None
|
||||||
|
briscola_suit: str = ""
|
||||||
|
dealer: int = 0
|
||||||
|
turn: int = 0
|
||||||
|
# 1-based deal counter, used in the audit trail and deadlines.
|
||||||
|
hand_number: int = 1
|
||||||
|
# Cards of the trick in progress, in play order.
|
||||||
|
trick: List[TrickCard] = field(default_factory=list)
|
||||||
|
trick_leader: int = 0
|
||||||
|
# The most recently completed trick, for the table announcement.
|
||||||
|
last_trick: Optional[Trick] = None
|
||||||
|
# Match points accumulated across deals (patta deals do not count).
|
||||||
|
scores: List[int] = field(default_factory=lambda: [0, 0])
|
||||||
|
winner: Optional[int] = None
|
||||||
|
# Per-deal points awarded, for a compact audit trail in the API.
|
||||||
|
hand_scores: List[Dict[str, Any]] = field(default_factory=list)
|
||||||
|
# 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 deal summary waits before dealing anyway.
|
||||||
|
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS
|
||||||
|
# While phase == "playing": when the server plays a random card for
|
||||||
|
# the player on turn.
|
||||||
|
turn_deadline: Optional[str] = None
|
||||||
|
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS
|
||||||
|
|
||||||
|
# -- serialization ----------------------------------------------------
|
||||||
|
|
||||||
|
def to_json(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"player_count": self.player_count,
|
||||||
|
"target_score": self.target_score,
|
||||||
|
"phase": self.phase,
|
||||||
|
"players": [p.to_json() for p in self.players],
|
||||||
|
"stock": [c.to_json() for c in self.stock],
|
||||||
|
"briscola_card": (
|
||||||
|
self.briscola_card.to_json() if self.briscola_card else None
|
||||||
|
),
|
||||||
|
"briscola_suit": self.briscola_suit,
|
||||||
|
"dealer": self.dealer,
|
||||||
|
"turn": self.turn,
|
||||||
|
"hand_number": self.hand_number,
|
||||||
|
"trick": [t.to_json() for t in self.trick],
|
||||||
|
"trick_leader": self.trick_leader,
|
||||||
|
"last_trick": self.last_trick.to_json() if self.last_trick else None,
|
||||||
|
"scores": list(self.scores),
|
||||||
|
"winner": self.winner,
|
||||||
|
"hand_scores": list(self.hand_scores),
|
||||||
|
"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]) -> "BriscolaState":
|
||||||
|
return BriscolaState(
|
||||||
|
player_count=int(data.get("player_count", 2)),
|
||||||
|
target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)),
|
||||||
|
phase=str(data.get("phase", PHASE_LOBBY)),
|
||||||
|
players=[PlayerState.from_json(p) for p in data.get("players", [])],
|
||||||
|
stock=[Card.from_json(c) for c in data.get("stock", [])],
|
||||||
|
briscola_card=(
|
||||||
|
Card.from_json(data["briscola_card"])
|
||||||
|
if data.get("briscola_card")
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
briscola_suit=str(data.get("briscola_suit", "")),
|
||||||
|
dealer=int(data.get("dealer", 0)),
|
||||||
|
turn=int(data.get("turn", 0)),
|
||||||
|
hand_number=int(data.get("hand_number", 1)),
|
||||||
|
trick=[TrickCard.from_json(t) for t in data.get("trick", [])],
|
||||||
|
trick_leader=int(data.get("trick_leader", 0)),
|
||||||
|
last_trick=(
|
||||||
|
Trick.from_json(data["last_trick"]) if data.get("last_trick") else None
|
||||||
|
),
|
||||||
|
scores=[int(x) for x in data.get("scores", [0, 0])],
|
||||||
|
winner=data.get("winner"),
|
||||||
|
hand_scores=list(data.get("hand_scores", [])),
|
||||||
|
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", DEFAULT_HAND_ACK_TIMEOUT_SECONDS)
|
||||||
|
),
|
||||||
|
turn_deadline=data.get("turn_deadline"),
|
||||||
|
turn_timeout=int(
|
||||||
|
data.get("turn_timeout", DEFAULT_TURN_TIMEOUT_SECONDS)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- helpers ----------------------------------------------------------
|
||||||
|
|
||||||
|
def player_for(self, sub: Optional[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,691 @@
|
|||||||
|
"""Rule engine tests: deals, tricks, draws, scoring and full-match simulation."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from tavolo.briscola import engine
|
||||||
|
from tavolo.briscola.errors import (
|
||||||
|
CardNotInHand,
|
||||||
|
GameFinished,
|
||||||
|
GameNotStarted,
|
||||||
|
IllegalMove,
|
||||||
|
NotYourTurn,
|
||||||
|
)
|
||||||
|
from tavolo.briscola.state import (
|
||||||
|
HAND_SIZE,
|
||||||
|
PHASE_FINISHED,
|
||||||
|
PHASE_HAND_END,
|
||||||
|
PHASE_PLAYING,
|
||||||
|
BriscolaState,
|
||||||
|
Card,
|
||||||
|
PlayerState,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def card(code: str) -> Card:
|
||||||
|
return Card.parse(code)
|
||||||
|
|
||||||
|
|
||||||
|
def make_state(
|
||||||
|
hands,
|
||||||
|
*,
|
||||||
|
briscola_suit: str = "S",
|
||||||
|
briscola_card: str | None = None,
|
||||||
|
stock=None,
|
||||||
|
captured=None,
|
||||||
|
turn: int = 0,
|
||||||
|
dealer: int = 0,
|
||||||
|
target: int = 61,
|
||||||
|
scores=None,
|
||||||
|
phase: str = PHASE_PLAYING,
|
||||||
|
) -> BriscolaState:
|
||||||
|
"""Build a controlled game state directly (bypassing the deal)."""
|
||||||
|
state = BriscolaState(
|
||||||
|
player_count=len(hands),
|
||||||
|
target_score=target,
|
||||||
|
phase=phase,
|
||||||
|
dealer=dealer,
|
||||||
|
turn=turn,
|
||||||
|
briscola_suit=briscola_suit,
|
||||||
|
briscola_card=card(briscola_card) if briscola_card else None,
|
||||||
|
stock=[card(c) for c in (stock or [])],
|
||||||
|
scores=list(scores or [0, 0]),
|
||||||
|
)
|
||||||
|
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]
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def trick_cards(state: BriscolaState) -> list[str]:
|
||||||
|
return [t.card for t in state.trick]
|
||||||
|
|
||||||
|
|
||||||
|
def play_all(state: BriscolaState) -> None:
|
||||||
|
"""Play every card of the current trick in turn order."""
|
||||||
|
for _ in range(state.player_count):
|
||||||
|
player = next(p for p in state.players if p.seat == state.turn)
|
||||||
|
engine.play(state, player.sub, player.hand[0].code)
|
||||||
|
|
||||||
|
|
||||||
|
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}))
|
||||||
|
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_card_points_add_up_to_120(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
120, sum(engine.card_points(c) for c in engine.full_deck())
|
||||||
|
)
|
||||||
|
self.assertEqual(11, engine.card_points(card("01D")))
|
||||||
|
self.assertEqual(10, engine.card_points(card("03C")))
|
||||||
|
self.assertEqual(4, engine.card_points(card("10S")))
|
||||||
|
self.assertEqual(3, engine.card_points(card("09B")))
|
||||||
|
self.assertEqual(2, engine.card_points(card("08D")))
|
||||||
|
self.assertEqual(0, engine.card_points(card("07C")))
|
||||||
|
|
||||||
|
|
||||||
|
class DealTest(unittest.TestCase):
|
||||||
|
def test_two_player_deal(self) -> None:
|
||||||
|
state = engine.create_game("p0", "p0")
|
||||||
|
engine.join_game(state, "p1", "p1")
|
||||||
|
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||||
|
for player in state.players:
|
||||||
|
self.assertEqual(HAND_SIZE, len(player.hand))
|
||||||
|
# 40 - 3*2 - 1 face-up = 33 face-down.
|
||||||
|
self.assertEqual(33, len(state.stock))
|
||||||
|
self.assertIsNotNone(state.briscola_card)
|
||||||
|
assert state.briscola_card is not None
|
||||||
|
self.assertEqual(state.briscola_card.suit, state.briscola_suit)
|
||||||
|
self.assertEqual(1, state.turn) # dealer is seat 0
|
||||||
|
self.assertEqual([], state.trick)
|
||||||
|
|
||||||
|
def test_four_player_deal(self) -> None:
|
||||||
|
state = engine.create_game("p0", "p0", player_count=4)
|
||||||
|
for i in range(1, 4):
|
||||||
|
engine.join_game(state, f"p{i}", f"p{i}")
|
||||||
|
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||||
|
for player in state.players:
|
||||||
|
self.assertEqual(HAND_SIZE, len(player.hand))
|
||||||
|
self.assertEqual(40 - 3 * 4 - 1, len(state.stock))
|
||||||
|
self.assertEqual(1, state.turn)
|
||||||
|
|
||||||
|
def test_deal_cards_are_unique_and_leave_nothing_out(self) -> None:
|
||||||
|
state = engine.create_game("p0", "p0", player_count=4)
|
||||||
|
for i in range(1, 4):
|
||||||
|
engine.join_game(state, f"p{i}", f"p{i}")
|
||||||
|
dealt = [c.code for p in state.players for c in p.hand]
|
||||||
|
assert state.briscola_card is not None
|
||||||
|
dealt.append(state.briscola_card.code)
|
||||||
|
dealt.extend(c.code for c in state.stock)
|
||||||
|
self.assertEqual(40, len(dealt))
|
||||||
|
self.assertEqual(40, len(set(dealt)))
|
||||||
|
|
||||||
|
def test_unsupported_player_count(self) -> None:
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.create_game("p0", "p0", player_count=3)
|
||||||
|
|
||||||
|
def test_target_score_bounds(self) -> None:
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.create_game("p0", "p0", target_score=60)
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.create_game("p0", "p0", target_score=121)
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.create_game("p0", "p0", target_score=True)
|
||||||
|
state = engine.create_game("p0", "p0", target_score=101)
|
||||||
|
self.assertEqual(101, state.target_score)
|
||||||
|
|
||||||
|
|
||||||
|
class TrickTest(unittest.TestCase):
|
||||||
|
def test_higher_card_of_led_suit_wins(self) -> None:
|
||||||
|
state = make_state([["02D"], ["05D"]], briscola_suit="S")
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
engine.play(state, "p1", "05D")
|
||||||
|
self.assertEqual(1, state.last_trick.winner)
|
||||||
|
self.assertEqual(["02D", "05D"],
|
||||||
|
[c.code for c in state.players[1].captured])
|
||||||
|
|
||||||
|
def test_trump_beats_a_higher_led_card(self) -> None:
|
||||||
|
state = make_state([["07D"], ["02C"]], briscola_suit="C")
|
||||||
|
engine.play(state, "p0", "07D")
|
||||||
|
engine.play(state, "p1", "02C")
|
||||||
|
self.assertEqual(1, state.last_trick.winner)
|
||||||
|
|
||||||
|
def test_higher_trump_wins_among_trumps(self) -> None:
|
||||||
|
state = make_state([["02C"], ["05C"]], briscola_suit="C")
|
||||||
|
engine.play(state, "p0", "02C")
|
||||||
|
engine.play(state, "p1", "05C")
|
||||||
|
self.assertEqual(1, state.last_trick.winner)
|
||||||
|
|
||||||
|
def test_off_suit_non_trump_loses(self) -> None:
|
||||||
|
state = make_state([["04D"], ["01C"]], briscola_suit="B")
|
||||||
|
engine.play(state, "p0", "04D")
|
||||||
|
engine.play(state, "p1", "01C")
|
||||||
|
self.assertEqual(0, state.last_trick.winner)
|
||||||
|
|
||||||
|
def test_rank_strength_order(self) -> None:
|
||||||
|
# (winner card, loser card) pairs of the led suit, strongest first:
|
||||||
|
# ace > three > king > horse > jack > 7 > 6 > 5 > 4 > 2.
|
||||||
|
pairs = [
|
||||||
|
("01D", "03D"), # ace beats three
|
||||||
|
("03D", "10D"), # three beats king
|
||||||
|
("10D", "09D"), # king beats horse
|
||||||
|
("09D", "08D"), # horse beats jack
|
||||||
|
("08D", "07D"), # jack beats seven
|
||||||
|
("07D", "06D"),
|
||||||
|
("06D", "05D"),
|
||||||
|
("05D", "04D"),
|
||||||
|
("04D", "02D"),
|
||||||
|
]
|
||||||
|
for winner, loser in pairs:
|
||||||
|
state = make_state([["02D"], ["05D"]], briscola_suit="B")
|
||||||
|
state.players[0].hand = [card(winner)]
|
||||||
|
state.players[1].hand = [card(loser)]
|
||||||
|
engine.play(state, "p0", winner)
|
||||||
|
engine.play(state, "p1", loser)
|
||||||
|
self.assertEqual(
|
||||||
|
0, state.last_trick.winner, f"{winner} should beat {loser}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_four_player_trick(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D"], ["05D"], ["04D"], ["07D"]], briscola_suit="S"
|
||||||
|
)
|
||||||
|
for seat in range(4):
|
||||||
|
engine.play(state, f"p{seat}", state.players[seat].hand[0].code)
|
||||||
|
self.assertEqual(3, state.last_trick.winner)
|
||||||
|
|
||||||
|
def test_four_player_trump_beats_all(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D"], ["02C"], ["04D"], ["07D"]], briscola_suit="C"
|
||||||
|
)
|
||||||
|
for seat in range(4):
|
||||||
|
engine.play(state, f"p{seat}", state.players[seat].hand[0].code)
|
||||||
|
self.assertEqual(1, state.last_trick.winner)
|
||||||
|
|
||||||
|
def test_winner_leads_the_next_trick(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D"], ["05D"], ["04D"], ["07D"]],
|
||||||
|
briscola_suit="S",
|
||||||
|
stock=["01B", "02B", "03B", "04B"],
|
||||||
|
)
|
||||||
|
for seat in range(4):
|
||||||
|
engine.play(state, f"p{seat}", state.players[seat].hand[0].code)
|
||||||
|
self.assertEqual(3, state.turn)
|
||||||
|
self.assertEqual(3, state.trick_leader)
|
||||||
|
self.assertEqual([], state.trick)
|
||||||
|
|
||||||
|
def test_turn_advances_within_a_trick(self) -> None:
|
||||||
|
state = make_state([["02D"], ["05D"]], briscola_suit="S")
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
self.assertEqual(1, state.turn)
|
||||||
|
self.assertEqual(["02D"], trick_cards(state))
|
||||||
|
|
||||||
|
def test_not_your_turn(self) -> None:
|
||||||
|
state = make_state([["02D"], ["05D"]], briscola_suit="S", turn=1)
|
||||||
|
with self.assertRaises(NotYourTurn):
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
|
||||||
|
def test_card_not_in_hand(self) -> None:
|
||||||
|
state = make_state([["02D"], ["05D"]], briscola_suit="S")
|
||||||
|
with self.assertRaises(CardNotInHand):
|
||||||
|
engine.play(state, "p0", "07D")
|
||||||
|
|
||||||
|
def test_finished_and_hand_end_reject_plays(self) -> None:
|
||||||
|
state = make_state([["02D"], ["05D"]], briscola_suit="S")
|
||||||
|
state.phase = PHASE_FINISHED
|
||||||
|
with self.assertRaises(GameFinished):
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
state = make_state([["02D"], ["05D"]], briscola_suit="S")
|
||||||
|
state.phase = PHASE_HAND_END
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
|
||||||
|
|
||||||
|
class DrawTest(unittest.TestCase):
|
||||||
|
def test_winner_draws_first(self) -> None:
|
||||||
|
# The stock is drawn from its end: 04B first, then 03B, ...
|
||||||
|
state = make_state(
|
||||||
|
[["02D", "09C"], ["05D", "09B"]],
|
||||||
|
briscola_suit="S",
|
||||||
|
stock=["01B", "02B", "03B", "04B"],
|
||||||
|
)
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
engine.play(state, "p1", "05D")
|
||||||
|
hands = {p.seat: [c.code for c in p.hand] for p in state.players}
|
||||||
|
self.assertEqual(["09C", "03B"], hands[0])
|
||||||
|
self.assertEqual(["09B", "04B"], hands[1])
|
||||||
|
|
||||||
|
def test_face_up_briscola_is_drawn_last(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D", "09C"], ["05D", "09B"]],
|
||||||
|
briscola_suit="C",
|
||||||
|
briscola_card="05C",
|
||||||
|
stock=["03B"],
|
||||||
|
)
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
engine.play(state, "p1", "05D")
|
||||||
|
hands = {p.seat: [c.code for c in p.hand] for p in state.players}
|
||||||
|
# The winner draws first and takes the last face-down card; the
|
||||||
|
# other player then takes the face-up briscola.
|
||||||
|
self.assertEqual(["09B", "03B"], hands[1])
|
||||||
|
self.assertEqual(["09C", "05C"], hands[0])
|
||||||
|
self.assertIsNone(state.briscola_card)
|
||||||
|
|
||||||
|
def test_no_draw_when_stock_is_empty(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D", "03D"], ["05D", "06D"]], briscola_suit="S", stock=[]
|
||||||
|
)
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
engine.play(state, "p1", "05D")
|
||||||
|
# p1 wins the trick; with no stock nobody draws and the hands
|
||||||
|
# shrink for the rest of the deal.
|
||||||
|
for player in state.players:
|
||||||
|
self.assertEqual(1, len(player.hand))
|
||||||
|
|
||||||
|
def test_hands_stay_at_three_until_stock_runs_out(self) -> None:
|
||||||
|
state = engine.create_game("p0", "p0")
|
||||||
|
engine.join_game(state, "p1", "p1")
|
||||||
|
while state.stock or state.briscola_card is not None:
|
||||||
|
play_all(state)
|
||||||
|
for player in state.players:
|
||||||
|
self.assertEqual(HAND_SIZE, len(player.hand))
|
||||||
|
self.assertEqual(0, len(state.stock))
|
||||||
|
self.assertIsNone(state.briscola_card)
|
||||||
|
|
||||||
|
|
||||||
|
class ScoringTest(unittest.TestCase):
|
||||||
|
def test_deal_points_group_by_team(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[[], [], [], []],
|
||||||
|
briscola_suit="S",
|
||||||
|
captured=[
|
||||||
|
["01D", "07C"], # seat 0, team A: 11
|
||||||
|
["03C", "08D"], # seat 1, team B: 10
|
||||||
|
["10S"], # seat 2, team A: 4
|
||||||
|
["09B"], # seat 3, team B: 3
|
||||||
|
],
|
||||||
|
)
|
||||||
|
points, details = engine.deal_points(state)
|
||||||
|
self.assertEqual([15, 15], points)
|
||||||
|
self.assertEqual({"A": 15, "B": 15}, details["points"])
|
||||||
|
self.assertEqual({"A": 3, "B": 3}, details["cards"])
|
||||||
|
|
||||||
|
def test_full_deal_scores_120(self) -> None:
|
||||||
|
state = engine.create_game("p0", "p0", player_count=4)
|
||||||
|
for i in range(1, 4):
|
||||||
|
engine.join_game(state, f"p{i}", f"p{i}")
|
||||||
|
while state.phase == PHASE_PLAYING:
|
||||||
|
player = next(p for p in state.players if p.seat == state.turn)
|
||||||
|
engine.play(state, player.sub, player.hand[0].code)
|
||||||
|
points, _ = engine.deal_points(state)
|
||||||
|
self.assertEqual(120, sum(points))
|
||||||
|
|
||||||
|
def test_match_ends_when_target_reached(self) -> None:
|
||||||
|
# p0 captures the ace of denari (11) on the last trick: 60+11 >= 61.
|
||||||
|
state = make_state(
|
||||||
|
[["02D"], ["05D"]],
|
||||||
|
briscola_suit="S",
|
||||||
|
stock=[],
|
||||||
|
captured=[["01C"], ["10C"]],
|
||||||
|
scores=[60, 50],
|
||||||
|
target=61,
|
||||||
|
)
|
||||||
|
# p0's pile is worth 11+0 = 11; p1's 4. p0 wins the trick with a
|
||||||
|
# higher card: 02D vs 05D -> p1 would win, so give p0 the five.
|
||||||
|
state.players[0].hand = [card("05D")]
|
||||||
|
state.players[1].hand = [card("02D")]
|
||||||
|
engine.play(state, "p0", "05D")
|
||||||
|
engine.play(state, "p1", "02D")
|
||||||
|
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||||
|
self.assertEqual(0, state.winner)
|
||||||
|
self.assertEqual([71, 54], state.scores)
|
||||||
|
|
||||||
|
def test_tie_at_target_is_broken_by_another_deal(self) -> None:
|
||||||
|
# 65-55 before the deal; this deal gives A 55 and B 65: both reach
|
||||||
|
# 120 and tie, so another deal must be played.
|
||||||
|
a_cards = [f"{rank:02d}D" for rank in range(1, 11)] + ["01C", "03C", "10C"]
|
||||||
|
b_cards = ["09C", "08C", "07C", "06C", "05C", "04C", "02C"] + [
|
||||||
|
f"{rank:02d}S" for rank in range(1, 11)
|
||||||
|
] + [f"{rank:02d}B" for rank in range(1, 11)]
|
||||||
|
state = make_state(
|
||||||
|
[[], []],
|
||||||
|
briscola_suit="S",
|
||||||
|
stock=[],
|
||||||
|
captured=[a_cards, b_cards],
|
||||||
|
scores=[65, 55],
|
||||||
|
target=120,
|
||||||
|
)
|
||||||
|
engine._end_deal(state)
|
||||||
|
self.assertEqual(PHASE_HAND_END, state.phase)
|
||||||
|
self.assertEqual([120, 120], state.scores)
|
||||||
|
self.assertIsNone(state.winner)
|
||||||
|
|
||||||
|
def test_patta_is_discarded_and_replayed(self) -> None:
|
||||||
|
a_cards = [f"{rank:02d}D" for rank in range(1, 11)] + [
|
||||||
|
f"{rank:02d}C" for rank in range(1, 11)
|
||||||
|
]
|
||||||
|
b_cards = [f"{rank:02d}S" for rank in range(1, 11)] + [
|
||||||
|
f"{rank:02d}B" for rank in range(1, 11)
|
||||||
|
]
|
||||||
|
state = make_state(
|
||||||
|
[["02D"], ["02S"]],
|
||||||
|
briscola_suit="S",
|
||||||
|
stock=[],
|
||||||
|
captured=[a_cards, b_cards],
|
||||||
|
scores=[10, 20],
|
||||||
|
target=61,
|
||||||
|
)
|
||||||
|
engine._end_deal(state)
|
||||||
|
self.assertEqual(PHASE_HAND_END, state.phase)
|
||||||
|
# The 60-60 deal does not count: totals are unchanged.
|
||||||
|
self.assertEqual([10, 20], state.scores)
|
||||||
|
entry = state.hand_scores[-1]
|
||||||
|
self.assertTrue(entry["patta"])
|
||||||
|
self.assertEqual({"A": 60, "B": 60}, entry["points"])
|
||||||
|
# The next deal is played at the same cumulative totals.
|
||||||
|
engine.acknowledge_hand(state, "p0")
|
||||||
|
engine.acknowledge_hand(state, "p1")
|
||||||
|
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||||
|
self.assertEqual(2, state.hand_number)
|
||||||
|
self.assertEqual([10, 20], state.scores)
|
||||||
|
|
||||||
|
def test_audit_trail_records_each_deal(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D"], ["05D"]],
|
||||||
|
briscola_suit="S",
|
||||||
|
stock=[],
|
||||||
|
captured=[["01C"], []],
|
||||||
|
scores=[0, 0],
|
||||||
|
)
|
||||||
|
engine._end_deal(state)
|
||||||
|
entry = state.hand_scores[-1]
|
||||||
|
self.assertEqual(1, entry["deal"])
|
||||||
|
self.assertFalse(entry["patta"])
|
||||||
|
self.assertEqual({"A": 11, "B": 0}, entry["points"])
|
||||||
|
self.assertEqual({"A": 11, "B": 0}, entry["totals"])
|
||||||
|
self.assertEqual({"A": 1, "B": 0}, entry["cards"])
|
||||||
|
|
||||||
|
|
||||||
|
class MatchFlowTest(unittest.TestCase):
|
||||||
|
def test_join_starts_when_full(self) -> None:
|
||||||
|
state = engine.create_game("p0", "p0")
|
||||||
|
self.assertEqual(1, len(state.players))
|
||||||
|
engine.join_game(state, "p1", "p1")
|
||||||
|
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||||
|
|
||||||
|
state = engine.create_game("p0", "p0", player_count=4)
|
||||||
|
for i in range(1, 3):
|
||||||
|
engine.join_game(state, f"p{i}", f"p{i}")
|
||||||
|
self.assertEqual("lobby", state.phase)
|
||||||
|
engine.join_game(state, "p3", "p3")
|
||||||
|
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||||
|
|
||||||
|
def test_join_errors(self) -> None:
|
||||||
|
from tavolo.platform.errors import AlreadyJoined, GameNotStarted
|
||||||
|
|
||||||
|
state = engine.create_game("p0", "p0")
|
||||||
|
with self.assertRaises(AlreadyJoined):
|
||||||
|
engine.join_game(state, "p0", "p0")
|
||||||
|
engine.join_game(state, "p1", "p1")
|
||||||
|
with self.assertRaises(GameNotStarted):
|
||||||
|
engine.join_game(state, "p2", "p2")
|
||||||
|
|
||||||
|
def test_full_random_two_player_match(self) -> None:
|
||||||
|
self._random_match(player_count=2)
|
||||||
|
|
||||||
|
def test_full_random_four_player_match(self) -> None:
|
||||||
|
self._random_match(player_count=4)
|
||||||
|
|
||||||
|
def _random_match(self, player_count: int) -> None:
|
||||||
|
state = engine.create_game("p0", "p0", player_count=player_count)
|
||||||
|
for i in range(1, player_count):
|
||||||
|
engine.join_game(state, f"p{i}", f"p{i}")
|
||||||
|
rng = random.Random(2026)
|
||||||
|
moves = 0
|
||||||
|
while state.phase != PHASE_FINISHED and moves < 200000:
|
||||||
|
if state.phase == PHASE_HAND_END:
|
||||||
|
for player in state.players:
|
||||||
|
engine.acknowledge_hand(state, player.sub)
|
||||||
|
continue
|
||||||
|
player = next(p for p in state.players if p.seat == state.turn)
|
||||||
|
engine.play(state, player.sub, rng.choice(player.hand).code)
|
||||||
|
moves += 1
|
||||||
|
|
||||||
|
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||||
|
self.assertIn(state.winner, (0, 1))
|
||||||
|
self.assertEqual([], state.trick)
|
||||||
|
self.assertTrue(all(not p.hand for p in state.players))
|
||||||
|
self.assertEqual(
|
||||||
|
40, sum(len(p.captured) for p in state.players)
|
||||||
|
)
|
||||||
|
# Every counted deal totals 120 points, and the winner reached the
|
||||||
|
# target with a clear lead.
|
||||||
|
winner_score = state.scores[state.winner]
|
||||||
|
loser_score = state.scores[1 - state.winner]
|
||||||
|
self.assertGreaterEqual(winner_score, state.target_score)
|
||||||
|
self.assertGreater(winner_score, loser_score)
|
||||||
|
self.assertEqual(0, (winner_score + loser_score) % 120)
|
||||||
|
|
||||||
|
|
||||||
|
class AckTest(unittest.TestCase):
|
||||||
|
def _hand_end_state(self) -> BriscolaState:
|
||||||
|
state = make_state(
|
||||||
|
[["02D"], ["05D"]],
|
||||||
|
briscola_suit="S",
|
||||||
|
stock=[],
|
||||||
|
captured=[["01C"], []],
|
||||||
|
target=120,
|
||||||
|
)
|
||||||
|
engine._end_deal(state)
|
||||||
|
return state
|
||||||
|
|
||||||
|
def test_ack_all_deals_the_next_deal(self) -> None:
|
||||||
|
state = self._hand_end_state()
|
||||||
|
dealer_before = state.dealer
|
||||||
|
engine.acknowledge_hand(state, "p0")
|
||||||
|
self.assertEqual([0], state.acked)
|
||||||
|
self.assertEqual(PHASE_HAND_END, state.phase)
|
||||||
|
engine.acknowledge_hand(state, "p1")
|
||||||
|
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||||
|
self.assertEqual(2, state.hand_number)
|
||||||
|
self.assertEqual((dealer_before + 1) % 2, state.dealer)
|
||||||
|
self.assertEqual((dealer_before + 2) % 2, state.turn)
|
||||||
|
self.assertEqual([], state.acked)
|
||||||
|
self.assertIsNone(state.hand_end_deadline)
|
||||||
|
for player in state.players:
|
||||||
|
self.assertEqual(HAND_SIZE, len(player.hand))
|
||||||
|
self.assertEqual([], player.captured)
|
||||||
|
|
||||||
|
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"], ["05D"]], briscola_suit="S")
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
class AutoPlayTest(unittest.TestCase):
|
||||||
|
def test_auto_play_plays_a_card_and_advances_turn(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D", "03C"], ["05D"]],
|
||||||
|
briscola_suit="S",
|
||||||
|
stock=["01B", "02B"],
|
||||||
|
briscola_card="03B",
|
||||||
|
)
|
||||||
|
engine.auto_play(state, random.Random(7))
|
||||||
|
self.assertEqual(1, state.turn)
|
||||||
|
self.assertEqual(1, len(state.players[0].hand))
|
||||||
|
self.assertEqual(1, len(state.trick))
|
||||||
|
|
||||||
|
def test_auto_play_can_end_the_deal(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D"], ["05D"]],
|
||||||
|
briscola_suit="S",
|
||||||
|
stock=[],
|
||||||
|
captured=[["01C"], []],
|
||||||
|
target=120,
|
||||||
|
)
|
||||||
|
# Both players are on their last card: two auto-plays complete the
|
||||||
|
# trick and the deal, scored A=11 / B=0.
|
||||||
|
engine.auto_play(state)
|
||||||
|
engine.auto_play(state)
|
||||||
|
self.assertEqual(PHASE_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"], ["05D"]], briscola_suit="S")
|
||||||
|
state.phase = PHASE_HAND_END
|
||||||
|
with self.assertRaises(GameNotStarted):
|
||||||
|
engine.auto_play(state)
|
||||||
|
|
||||||
|
|
||||||
|
class ViewTest(unittest.TestCase):
|
||||||
|
def test_state_for_player_hides_other_hands(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D", "03C"], ["04D"], ["05D"], ["06D"]],
|
||||||
|
briscola_suit="D",
|
||||||
|
briscola_card="07D",
|
||||||
|
stock=["01B", "02B"],
|
||||||
|
)
|
||||||
|
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("D", view["briscola_suit"])
|
||||||
|
self.assertEqual("07D", view["briscola_card"])
|
||||||
|
self.assertEqual(2, view["stock_count"])
|
||||||
|
self.assertTrue(view.get("your_turn"))
|
||||||
|
|
||||||
|
def test_spectator_sees_public_info_only(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D"], ["04D"]],
|
||||||
|
briscola_suit="D",
|
||||||
|
briscola_card="07D",
|
||||||
|
)
|
||||||
|
view = engine.state_for_player(state, None)
|
||||||
|
for player in view["players"]:
|
||||||
|
self.assertNotIn("hand", player)
|
||||||
|
self.assertNotIn("your_turn", view)
|
||||||
|
self.assertEqual("07D", view["briscola_card"])
|
||||||
|
self.assertEqual({"A": 0, "B": 0}, view["scores"])
|
||||||
|
|
||||||
|
def test_player_points_are_public(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D"], ["04D"]],
|
||||||
|
briscola_suit="D",
|
||||||
|
captured=[["01C", "03B"], ["10D"]],
|
||||||
|
)
|
||||||
|
view = engine.state_for_player(state, "p0")
|
||||||
|
players = {p["seat"]: p for p in view["players"]}
|
||||||
|
self.assertEqual(21, players[0]["points"])
|
||||||
|
self.assertEqual(4, players[1]["points"])
|
||||||
|
self.assertEqual(2, players[0]["captured_count"])
|
||||||
|
|
||||||
|
def test_hand_end_view_exposes_the_deal_summary(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D"], ["05D"]],
|
||||||
|
briscola_suit="S",
|
||||||
|
stock=[],
|
||||||
|
captured=[["01C"], []],
|
||||||
|
target=120,
|
||||||
|
)
|
||||||
|
engine._end_deal(state)
|
||||||
|
view = engine.state_for_player(state, "p0")
|
||||||
|
self.assertEqual(PHASE_HAND_END, view["phase"])
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"deal": 1,
|
||||||
|
"patta": False,
|
||||||
|
"points": {"A": 11, "B": 0},
|
||||||
|
"cards": {"A": 1, "B": 0},
|
||||||
|
"totals": {"A": 11, "B": 0},
|
||||||
|
},
|
||||||
|
view["last_deal"],
|
||||||
|
)
|
||||||
|
self.assertTrue(view["hand_end_deadline"])
|
||||||
|
|
||||||
|
def test_trick_is_public_in_play_order(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D"], ["05D"], ["04D"], ["07D"]], briscola_suit="S"
|
||||||
|
)
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
engine.play(state, "p1", "05D")
|
||||||
|
view = engine.state_for_player(state, None)
|
||||||
|
self.assertEqual(
|
||||||
|
[{"seat": 0, "card": "02D"}, {"seat": 1, "card": "05D"}],
|
||||||
|
view["trick"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SerializationTest(unittest.TestCase):
|
||||||
|
def test_state_roundtrip(self) -> None:
|
||||||
|
state = engine.create_game("p0", "p0", player_count=4, target_score=101)
|
||||||
|
for i in range(1, 4):
|
||||||
|
engine.join_game(state, f"p{i}", f"p{i}")
|
||||||
|
engine.play(state, state.players[1].sub, state.players[1].hand[0].code)
|
||||||
|
restored = BriscolaState.from_json(state.to_json())
|
||||||
|
self.assertEqual(state.phase, restored.phase)
|
||||||
|
self.assertEqual(state.turn, restored.turn)
|
||||||
|
self.assertEqual(state.hand_number, restored.hand_number)
|
||||||
|
self.assertEqual(state.target_score, restored.target_score)
|
||||||
|
self.assertEqual(state.briscola_suit, restored.briscola_suit)
|
||||||
|
self.assertEqual(
|
||||||
|
state.briscola_card.code if state.briscola_card else None,
|
||||||
|
restored.briscola_card.code if restored.briscola_card else None,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[c.code for c in state.stock], [c.code for c in restored.stock]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[(t.seat, t.card) for t in state.trick],
|
||||||
|
[(t.seat, t.card) for t in restored.trick],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[p.sub for p in state.players], [p.sub for p in restored.players]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[[c.code for c in p.hand] for p in state.players],
|
||||||
|
[[c.code for c in p.hand] for p in restored.players],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_legacy_state_defaults(self) -> None:
|
||||||
|
state = make_state([["02D"], ["05D"]], briscola_suit="S")
|
||||||
|
data = state.to_json()
|
||||||
|
del data["player_count"]
|
||||||
|
del data["briscola_card"]
|
||||||
|
del data["last_trick"]
|
||||||
|
restored = BriscolaState.from_json(data)
|
||||||
|
self.assertEqual(2, restored.player_count)
|
||||||
|
self.assertIsNone(restored.briscola_card)
|
||||||
|
self.assertIsNone(restored.last_trick)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
"""Adapter tests: the platform contract over the pure briscola rules."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from tavolo.platform import GameSession, Seat
|
||||||
|
from tavolo.platform.errors import (
|
||||||
|
AlreadyJoined,
|
||||||
|
GameError,
|
||||||
|
GameFinished,
|
||||||
|
GameNotStarted,
|
||||||
|
IllegalMove,
|
||||||
|
)
|
||||||
|
from tavolo.briscola import BriscolaEngine, BriscolaQuattroEngine
|
||||||
|
from tavolo.briscola import engine as rules
|
||||||
|
from tavolo.briscola.state import (
|
||||||
|
PHASE_FINISHED,
|
||||||
|
PHASE_HAND_END,
|
||||||
|
PHASE_PLAYING,
|
||||||
|
BriscolaState,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _session(engine, **options) -> GameSession:
|
||||||
|
session = GameSession(
|
||||||
|
id="b1",
|
||||||
|
game_type=engine.id,
|
||||||
|
join_code="CODE01",
|
||||||
|
creator_sub="alice",
|
||||||
|
players=[Seat(user_sub="alice", display_name="Alice")],
|
||||||
|
)
|
||||||
|
engine.create(session, options)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def _started(engine, **options) -> GameSession:
|
||||||
|
session = _session(engine, **options)
|
||||||
|
for i in range(1, engine.max_players):
|
||||||
|
name = f"p{i}"
|
||||||
|
engine.join(session, name, name.capitalize())
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def _drive_to_finish(engine, session: GameSession, limit: int = 200000) -> None:
|
||||||
|
moves = 0
|
||||||
|
while not engine.is_finished(session) and moves < limit:
|
||||||
|
state = session.state
|
||||||
|
if state.phase == PHASE_HAND_END:
|
||||||
|
for player in state.players:
|
||||||
|
engine.handle_action(session, player.sub, "ack", {})
|
||||||
|
continue
|
||||||
|
player = next(p for p in state.players if p.seat == state.turn)
|
||||||
|
engine.handle_action(
|
||||||
|
session, player.sub, "play", {"card": player.hand[0].code}
|
||||||
|
)
|
||||||
|
moves += 1
|
||||||
|
|
||||||
|
|
||||||
|
class MetadataTest(unittest.TestCase):
|
||||||
|
def test_two_player_metadata(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
self.assertEqual("briscola", engine.id)
|
||||||
|
self.assertEqual("Briscola", engine.name)
|
||||||
|
self.assertTrue(engine.description)
|
||||||
|
self.assertEqual(2, engine.min_players)
|
||||||
|
self.assertEqual(2, engine.max_players)
|
||||||
|
target = engine.options_schema["properties"]["target_score"]
|
||||||
|
self.assertEqual("integer", target["type"])
|
||||||
|
self.assertEqual(61, target["minimum"])
|
||||||
|
self.assertEqual(120, target["maximum"])
|
||||||
|
self.assertEqual(61, target["default"])
|
||||||
|
|
||||||
|
def test_four_player_metadata(self) -> None:
|
||||||
|
engine = BriscolaQuattroEngine()
|
||||||
|
self.assertEqual("briscola_a_quattro", engine.id)
|
||||||
|
self.assertEqual("Briscola a quattro", engine.name)
|
||||||
|
self.assertTrue(engine.description)
|
||||||
|
self.assertEqual(4, engine.min_players)
|
||||||
|
self.assertEqual(4, engine.max_players)
|
||||||
|
self.assertIn("target_score", engine.options_schema["properties"])
|
||||||
|
|
||||||
|
|
||||||
|
class CreateTest(unittest.TestCase):
|
||||||
|
def test_create_seats_creator_on_team_a(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _session(engine)
|
||||||
|
self.assertEqual("A", session.players[0].team)
|
||||||
|
self.assertIsInstance(session.state, BriscolaState)
|
||||||
|
self.assertEqual(61, session.state.target_score)
|
||||||
|
self.assertEqual(2, session.state.player_count)
|
||||||
|
|
||||||
|
def test_create_options(self) -> None:
|
||||||
|
engine = BriscolaQuattroEngine()
|
||||||
|
session = _session(engine, target_score=101)
|
||||||
|
self.assertEqual(101, session.state.target_score)
|
||||||
|
self.assertEqual(4, session.state.player_count)
|
||||||
|
|
||||||
|
def test_create_rejects_bad_options(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
_session(engine, target_score=60)
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
_session(engine, target_score=121)
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
_session(engine, target_score="sixtyone")
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
_session(engine, target_score=True)
|
||||||
|
|
||||||
|
def test_timeouts_come_from_the_engine(self) -> None:
|
||||||
|
engine = BriscolaEngine(turn_timeout_seconds=7, hand_ack_timeout_seconds=9)
|
||||||
|
session = _session(engine)
|
||||||
|
self.assertEqual(7, session.state.turn_timeout)
|
||||||
|
self.assertEqual(9, session.state.hand_ack_timeout)
|
||||||
|
|
||||||
|
|
||||||
|
class JoinTest(unittest.TestCase):
|
||||||
|
def test_two_player_join_starts(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _session(engine)
|
||||||
|
self.assertTrue(engine.in_lobby(session))
|
||||||
|
engine.join(session, "bob", "Bob")
|
||||||
|
self.assertEqual("B", session.players[-1].team)
|
||||||
|
self.assertFalse(engine.in_lobby(session))
|
||||||
|
self.assertEqual(PHASE_PLAYING, session.state.phase)
|
||||||
|
|
||||||
|
def test_four_player_teams_alternate(self) -> None:
|
||||||
|
engine = BriscolaQuattroEngine()
|
||||||
|
session = _session(engine)
|
||||||
|
teams = []
|
||||||
|
for name in ("bob", "carol", "dave"):
|
||||||
|
engine.join(session, name, name.capitalize())
|
||||||
|
teams.append(session.players[-1].team)
|
||||||
|
self.assertEqual(["B", "A", "B"], teams)
|
||||||
|
self.assertEqual(PHASE_PLAYING, session.state.phase)
|
||||||
|
|
||||||
|
def test_join_errors(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _session(engine)
|
||||||
|
with self.assertRaises(AlreadyJoined):
|
||||||
|
engine.join(session, "alice", "Alice")
|
||||||
|
engine.join(session, "bob", "Bob")
|
||||||
|
with self.assertRaises(GameNotStarted):
|
||||||
|
engine.join(session, "carol", "Carol")
|
||||||
|
|
||||||
|
|
||||||
|
class ActionTest(unittest.TestCase):
|
||||||
|
def test_unknown_action_rejected(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.handle_action(session, "alice", "dance", {})
|
||||||
|
|
||||||
|
def test_play_validates_payload(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.handle_action(session, "p1", "play", {"card": 42})
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.handle_action(session, "p1", "play", {})
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.handle_action(session, "p1", "play", {"card": "nope"})
|
||||||
|
|
||||||
|
def test_play_advances_the_trick(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
state = session.state
|
||||||
|
self.assertEqual(1, state.turn)
|
||||||
|
card = state.players[1].hand[0].code
|
||||||
|
engine.handle_action(session, "p1", "play", {"card": card})
|
||||||
|
self.assertEqual([{"seat": 1, "card": card}], engine.view_for(session, "p1")["trick"])
|
||||||
|
|
||||||
|
def test_play_out_of_turn_rejected(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
state = session.state
|
||||||
|
card = state.players[0].hand[0].code
|
||||||
|
with self.assertRaises(GameError):
|
||||||
|
engine.handle_action(session, "alice", "play", {"card": card})
|
||||||
|
|
||||||
|
def test_ack_outside_hand_end_rejected(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.handle_action(session, "alice", "ack", {})
|
||||||
|
|
||||||
|
def test_finished_match_rejects_actions(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
_drive_to_finish(engine, session)
|
||||||
|
self.assertTrue(engine.is_finished(session))
|
||||||
|
with self.assertRaises(GameFinished):
|
||||||
|
engine.handle_action(session, "alice", "play", {"card": "01D"})
|
||||||
|
|
||||||
|
def test_ack_all_deals_next(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _started(engine, target_score=120)
|
||||||
|
state = session.state
|
||||||
|
# Force the deal-end phase: both players keep 3 cards, so the
|
||||||
|
# match continues after the summary.
|
||||||
|
state.phase = PHASE_HAND_END
|
||||||
|
state.acked = []
|
||||||
|
state.hand_end_deadline = (
|
||||||
|
datetime.now(timezone.utc) + timedelta(seconds=60)
|
||||||
|
).isoformat()
|
||||||
|
engine.handle_action(session, "alice", "ack", {})
|
||||||
|
self.assertEqual(PHASE_HAND_END, state.phase)
|
||||||
|
engine.handle_action(session, "p1", "ack", {})
|
||||||
|
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||||
|
self.assertEqual(2, state.hand_number)
|
||||||
|
|
||||||
|
|
||||||
|
class ViewTest(unittest.TestCase):
|
||||||
|
def test_view_for_hides_other_hands(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
view = engine.view_for(session, "alice")
|
||||||
|
players = {p["seat"]: p for p in view["players"]}
|
||||||
|
self.assertIn("hand", players[0])
|
||||||
|
self.assertNotIn("hand", players[1])
|
||||||
|
# The envelope is the platform's job, not the view's.
|
||||||
|
self.assertNotIn("id", view)
|
||||||
|
self.assertNotIn("join_code", view)
|
||||||
|
self.assertNotIn("game_type", view)
|
||||||
|
self.assertIn("briscola_suit", view)
|
||||||
|
self.assertIn("stock_count", view)
|
||||||
|
|
||||||
|
def test_view_for_spectator_hides_all_hands(self) -> None:
|
||||||
|
engine = BriscolaQuattroEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
view = engine.view_for(session, None)
|
||||||
|
for player in view["players"]:
|
||||||
|
self.assertNotIn("hand", player)
|
||||||
|
self.assertNotIn("your_turn", view)
|
||||||
|
self.assertEqual({"A": 0, "B": 0}, view["scores"])
|
||||||
|
|
||||||
|
def test_lobby_view(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _session(engine, target_score=101)
|
||||||
|
lobby = engine.lobby_view(session)
|
||||||
|
self.assertEqual("lobby", lobby["phase"])
|
||||||
|
self.assertEqual(101, lobby["target_score"])
|
||||||
|
|
||||||
|
|
||||||
|
class SerializationTest(unittest.TestCase):
|
||||||
|
def test_state_roundtrip(self) -> None:
|
||||||
|
engine = BriscolaQuattroEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
restored = engine.state_from_json(engine.state_to_json(session.state))
|
||||||
|
self.assertIsInstance(restored, BriscolaState)
|
||||||
|
self.assertEqual(session.state.phase, restored.phase)
|
||||||
|
self.assertEqual(session.state.turn, restored.turn)
|
||||||
|
self.assertEqual(
|
||||||
|
[p.sub for p in session.state.players],
|
||||||
|
[p.sub for p in restored.players],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeadlineTest(unittest.TestCase):
|
||||||
|
def test_no_deadline_in_lobby(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
self.assertIsNone(engine.next_deadline(_session(engine)))
|
||||||
|
|
||||||
|
def test_turn_deadline_and_revalidation(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
deadline = engine.next_deadline(session)
|
||||||
|
assert deadline is not None
|
||||||
|
self.assertEqual("turn", deadline.kind)
|
||||||
|
with self.assertRaises(GameError):
|
||||||
|
engine.fire_deadline(session, "turn", "turn:1:1:0")
|
||||||
|
turn_before = session.state.turn
|
||||||
|
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||||
|
self.assertNotEqual(turn_before, session.state.turn)
|
||||||
|
|
||||||
|
def test_stale_deadline_after_play(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
deadline = engine.next_deadline(session)
|
||||||
|
assert deadline is not None
|
||||||
|
state = session.state
|
||||||
|
player = next(p for p in state.players if p.seat == state.turn)
|
||||||
|
engine.handle_action(session, player.sub, "play", {"card": player.hand[0].code})
|
||||||
|
with self.assertRaises(GameError):
|
||||||
|
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||||
|
|
||||||
|
def test_hand_end_deadline_advances(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _started(engine, target_score=120)
|
||||||
|
state = session.state
|
||||||
|
state.phase = PHASE_HAND_END
|
||||||
|
state.hand_end_deadline = (
|
||||||
|
datetime.now(timezone.utc) + timedelta(seconds=60)
|
||||||
|
).isoformat()
|
||||||
|
deadline = engine.next_deadline(session)
|
||||||
|
assert deadline is not None
|
||||||
|
self.assertEqual("hand_end", deadline.kind)
|
||||||
|
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||||
|
self.assertEqual(PHASE_PLAYING, session.state.phase)
|
||||||
|
self.assertEqual(2, session.state.hand_number)
|
||||||
|
|
||||||
|
|
||||||
|
class ResultTest(unittest.TestCase):
|
||||||
|
def test_result_of_finished_match(self) -> None:
|
||||||
|
engine = BriscolaQuattroEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
_drive_to_finish(engine, session)
|
||||||
|
result = engine.result(session)
|
||||||
|
self.assertEqual(2, len(result.teams))
|
||||||
|
self.assertEqual([2, 2], [len(team) for team in result.teams])
|
||||||
|
self.assertIn(result.winner_team, (0, 1))
|
||||||
|
self.assertEqual(4, len(result.players))
|
||||||
|
winner_name = "A" if result.winner_team == 0 else "B"
|
||||||
|
for player in result.players:
|
||||||
|
self.assertEqual(player.won, player.team == winner_name)
|
||||||
|
self.assertIn("captured_cards", player.details)
|
||||||
|
self.assertIn("team_a_score", result.summary)
|
||||||
|
self.assertIn("team_b_score", result.summary)
|
||||||
|
self.assertIn("winner_team", result.summary)
|
||||||
|
self.assertIn("target_score", result.summary)
|
||||||
|
self.assertIn("deals_played", result.summary)
|
||||||
|
self.assertIn("deal_scores", result.summary)
|
||||||
|
|
||||||
|
def test_two_player_result(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
_drive_to_finish(engine, session)
|
||||||
|
result = engine.result(session)
|
||||||
|
self.assertEqual([[session.players[0].user_sub], [session.players[1].user_sub]], result.teams)
|
||||||
|
self.assertEqual(2, len(result.players))
|
||||||
|
|
||||||
|
def test_result_requires_finished_match(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
with self.assertRaises(GameError):
|
||||||
|
engine.result(_started(engine))
|
||||||
|
|
||||||
|
def test_game_over_view(self) -> None:
|
||||||
|
engine = BriscolaEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
session.state.phase = PHASE_FINISHED
|
||||||
|
session.state.winner = 1
|
||||||
|
session.state.scores = [55, 65]
|
||||||
|
over = engine.game_over_view(session, "alice")
|
||||||
|
self.assertEqual({"A": 55, "B": 65}, over["scores"])
|
||||||
|
self.assertEqual("B", over["winner"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# tavolo-platform
|
||||||
|
|
||||||
|
The game-independent half of tavolo: lobby, live play, match history and
|
||||||
|
leaderboards. Everything here works for any game that implements the
|
||||||
|
[`GameEngine`](src/tavolo/platform/engine.py) contract; the package
|
||||||
|
itself ships no game.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- `engine.py` — the platform↔game contract: `GameEngine` (ABC),
|
||||||
|
`GameSession` (the platform-owned envelope with an opaque `state` blob),
|
||||||
|
`Seat`, `Deadline` (kind + due time + revalidation token) and the
|
||||||
|
`MatchResult`/`PlayerResult` outcome types.
|
||||||
|
- `registry.py` — `GameRegistry`: game id → engine lookup, single source
|
||||||
|
of truth for which games exist.
|
||||||
|
- `mixin.py` — `Platform` (registry + store + scheduler + OIDC, the
|
||||||
|
collaborators every endpoint needs) and `PlatformMixin`, the kaya mixin
|
||||||
|
that registers all routes and the websocket endpoint on an app.
|
||||||
|
- `routes/` — `health` (`GET /api/health`), `me` (`GET /api/me`), `games`
|
||||||
|
(lobby: `GET /api/game-types`, `POST /api/games`, `POST /api/games/join`,
|
||||||
|
`GET /api/games/{id}`) and `stats` (`GET /api/me/matches`,
|
||||||
|
`GET /api/leaderboard`, `GET /api/me/ratings`).
|
||||||
|
- `ws.py` — the live-play websocket (`/ws/games/{id}`): connection
|
||||||
|
lifecycle, the message envelope and the publish/subscribe fan-out.
|
||||||
|
Game-specific actions are dispatched to the session's engine.
|
||||||
|
- `store.py` — live-session persistence: `RedisGameStore` (production)
|
||||||
|
and `InMemoryGameStore` (tests/dev) over a JSON envelope plus the
|
||||||
|
engine's opaque state blob, with per-game locks, change signals and the
|
||||||
|
shared deadline queue.
|
||||||
|
- `deadlines.py` — `DeadlineScheduler`: enqueue the deadline an engine
|
||||||
|
declares after each mutation; a per-loop consumer fires due entries
|
||||||
|
through `engine.fire_deadline` under the per-game lock. Engines
|
||||||
|
revalidate the token, so stale or duplicate deliveries are harmless.
|
||||||
|
- `models.py` — `Match` (`game_type`, timestamps, game-specific JSON
|
||||||
|
`result`), `MatchPlayer` (seat, team, won, points, Elo delta, JSON
|
||||||
|
`details`) and `PlayerRating` (Elo per `(user_sub, game_type)`).
|
||||||
|
- `stats.py` — `save_match_result` (engine `MatchResult` → Postgres,
|
||||||
|
transactionally, with Elo) and `apply_elo`.
|
||||||
|
- `elo.py` — chess-style Elo math generalized to two-team matches.
|
||||||
|
- `backfill_elo.py` — rebuild every rating from the match history:
|
||||||
|
`python -m tavolo.platform.backfill_elo --database-url postgres://…`.
|
||||||
|
- `auth.py`, `http.py`, `pagination.py`, `openapi.py`,
|
||||||
|
`tortoise_mixin.py` — OIDC helpers, JSON helpers, keyset pagination,
|
||||||
|
shared OpenAPI fragments, the TortoiseORM lifecycle mixin.
|
||||||
|
|
||||||
|
## Adding a game
|
||||||
|
|
||||||
|
Implement `GameEngine` (see `engine.py` for the full contract), register
|
||||||
|
it in a `GameRegistry`, and mount `PlatformMixin(Platform(...))` on a
|
||||||
|
`KayaApp` — see the composition root in `tavolo.app`. The canonical
|
||||||
|
examples are [`tavolo-scopone`](../tavolo-scopone/README.md) and
|
||||||
|
[`tavolo-briscola`](../tavolo-briscola/README.md).
|
||||||
|
|
||||||
|
## Development (from `server/`)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
.venv/bin/python -m unittest discover -s packages/tavolo-platform/tests
|
||||||
|
.venv/bin/python -m mypy -p tavolo.platform
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests run fully in-process against a `DummyEngine` (a two-player toy game
|
||||||
|
in `tests/helpers.py`): in-memory sqlite, in-memory stores, a patched
|
||||||
|
OIDC user and `httpx` / `httpx-ws` ASGI transports. No test in this
|
||||||
|
package may import a real game.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "tavolo-platform"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Game-independent multiplayer game platform (lobby, live play, match history, leaderboards) built on the kaya framework"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"kaya-core>=0.0.7",
|
||||||
|
"kaya-session",
|
||||||
|
"kaya-oidc>=0.0.7",
|
||||||
|
"kaya-openapi",
|
||||||
|
"tortoise-orm",
|
||||||
|
"redis",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
namespaces = true
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.12"
|
||||||
|
ignore_missing_imports = true
|
||||||
|
plugins = []
|
||||||
|
|
||||||
|
# TortoiseORM auto-generates `<fk>_id` attributes on ForeignKeyField at
|
||||||
|
# runtime; without the (unavailable here) tortoise mypy plugin the stubs
|
||||||
|
# only declare the relation field. These are real attributes, not bugs.
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = "tavolo.platform.models"
|
||||||
|
disable_error_code = ["attr-defined"]
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = "tavolo.platform.routes.*"
|
||||||
|
disable_error_code = ["attr-defined"]
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""The game-independent half of tavolo: lobby, live play, history, ratings.
|
||||||
|
|
||||||
|
This package hosts every concern that does not depend on the rules of a
|
||||||
|
specific game: the lobby HTTP API, the live-game store, the websocket
|
||||||
|
endpoint, the deadline scheduler, match persistence and the Elo
|
||||||
|
leaderboards. A game plugs in by implementing
|
||||||
|
:class:`~tavolo.platform.engine.GameEngine` and registering it in a
|
||||||
|
:class:`~tavolo.platform.registry.GameRegistry`; the application then
|
||||||
|
mounts everything with :class:`~tavolo.platform.mixin.PlatformMixin`.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .engine import (
|
||||||
|
Deadline,
|
||||||
|
GameEngine,
|
||||||
|
GameSession,
|
||||||
|
MatchResult,
|
||||||
|
PlayerResult,
|
||||||
|
Seat,
|
||||||
|
)
|
||||||
|
from .errors import (
|
||||||
|
AlreadyJoined,
|
||||||
|
GameError,
|
||||||
|
GameFinished,
|
||||||
|
GameNotFound,
|
||||||
|
GameNotStarted,
|
||||||
|
IllegalMove,
|
||||||
|
LobbyFull,
|
||||||
|
NotYourTurn,
|
||||||
|
)
|
||||||
|
from .mixin import Platform, PlatformMixin
|
||||||
|
from .registry import GameRegistry, UnknownGameType
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AlreadyJoined",
|
||||||
|
"Deadline",
|
||||||
|
"GameEngine",
|
||||||
|
"GameError",
|
||||||
|
"GameFinished",
|
||||||
|
"GameNotFound",
|
||||||
|
"GameNotStarted",
|
||||||
|
"GameRegistry",
|
||||||
|
"GameSession",
|
||||||
|
"IllegalMove",
|
||||||
|
"LobbyFull",
|
||||||
|
"MatchResult",
|
||||||
|
"NotYourTurn",
|
||||||
|
"Platform",
|
||||||
|
"PlatformMixin",
|
||||||
|
"PlayerResult",
|
||||||
|
"Seat",
|
||||||
|
"UnknownGameType",
|
||||||
|
]
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Authentication helpers on top of the kaya-oidc mixin.
|
||||||
|
|
||||||
|
The platform has no application roles: every authenticated user may
|
||||||
|
create and join games. Authorization beyond login is game membership,
|
||||||
|
checked against the seats of the live session.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable, Mapping, Optional
|
||||||
|
|
||||||
|
from kaya.core import HttpContext, WebSocket
|
||||||
|
from kaya.oidc import OIDCProviders, OIDCUser
|
||||||
|
from kaya.session import Session
|
||||||
|
|
||||||
|
# kaya-oidc namespaces its session state per provider as
|
||||||
|
# ``oidc:{provider}:user`` (since 0.0.6), so the lookup cannot assume a
|
||||||
|
# fixed key or provider name.
|
||||||
|
_OIDC_PREFIX = "oidc:"
|
||||||
|
_USER_SUFFIX = ":user"
|
||||||
|
|
||||||
|
|
||||||
|
def session_user(session: Session) -> Optional[OIDCUser]:
|
||||||
|
"""Return the authenticated user stored in a session, if any.
|
||||||
|
|
||||||
|
The kaya-oidc mixin writes the userinfo at login under a per-provider
|
||||||
|
key, so any provider's user is accepted (the configured provider is
|
||||||
|
``default`` unless the app says otherwise).
|
||||||
|
"""
|
||||||
|
for key in session:
|
||||||
|
if key.startswith(_OIDC_PREFIX) and key.endswith(_USER_SUFFIX):
|
||||||
|
claims = session.get(key)
|
||||||
|
if isinstance(claims, Mapping):
|
||||||
|
return OIDCUser(claims)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_ws_user(ws: WebSocket) -> Optional[OIDCUser]:
|
||||||
|
"""Return the authenticated user of a WebSocket connection, if any.
|
||||||
|
|
||||||
|
The session mixin injects ``session`` into the websocket wrapper; the
|
||||||
|
OIDC mixin stores the userinfo there at login. Patched in tests.
|
||||||
|
"""
|
||||||
|
session = getattr(ws, "session", None)
|
||||||
|
if session is None:
|
||||||
|
return None
|
||||||
|
return session_user(session)
|
||||||
|
|
||||||
|
|
||||||
|
def display_name(user: OIDCUser) -> str:
|
||||||
|
"""Best-effort human-readable name for a user."""
|
||||||
|
for key in ("name", "preferred_username", "email"):
|
||||||
|
value = user.get(key)
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
return value
|
||||||
|
return user.sub
|
||||||
|
|
||||||
|
|
||||||
|
def current_user(oidc: OIDCProviders, ctx: HttpContext) -> Optional[OIDCUser]:
|
||||||
|
"""Return the authenticated user of an HTTP request, if any.
|
||||||
|
|
||||||
|
Sessions are single-identity by default, so at most one provider has
|
||||||
|
a user; if several do, the first configured provider wins.
|
||||||
|
"""
|
||||||
|
return next(iter(oidc.get_users(ctx).values()), None)
|
||||||
|
|
||||||
|
|
||||||
|
def require_auth(oidc: OIDCProviders) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
||||||
|
"""Build a decorator gating a handler on being authenticated.
|
||||||
|
|
||||||
|
Responds ``401`` with a JSON error envelope when unauthenticated —
|
||||||
|
unlike kaya's built-in ``OIDCMixin.require_auth`` which redirects to
|
||||||
|
the login page (wrong for a JSON API).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(handler: Callable[..., Any]) -> Callable[..., Any]:
|
||||||
|
async def guarded(ctx: HttpContext, *args: Any, **kwargs: Any) -> None:
|
||||||
|
if current_user(oidc, ctx) is None:
|
||||||
|
await ctx.send_bytes(
|
||||||
|
401,
|
||||||
|
b'{"error":"unauthenticated"}',
|
||||||
|
{"content-type": ("application/json",)},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
await handler(ctx, *args, **kwargs)
|
||||||
|
|
||||||
|
return guarded
|
||||||
|
|
||||||
|
return decorator
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Recompute every Elo rating from the recorded match history.
|
||||||
|
|
||||||
|
Ratings are deterministic given the finished matches, so this replays all
|
||||||
|
matches in chronological order and rewrites the ``player_rating`` table
|
||||||
|
and each ``match_player.elo_delta`` from scratch. Run any time ratings
|
||||||
|
need to be rebuilt::
|
||||||
|
|
||||||
|
python -m tavolo.platform.backfill_elo --database-url postgres://...
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
from collections import defaultdict
|
||||||
|
from logging import getLogger
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
from tortoise.transactions import in_transaction
|
||||||
|
|
||||||
|
from .stats import apply_elo
|
||||||
|
from .tortoise_mixin import TortoiseMixin
|
||||||
|
|
||||||
|
log = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def backfill_elo() -> int:
|
||||||
|
"""Rebuild all ratings; returns the number of matches replayed."""
|
||||||
|
from .models import Match, MatchPlayer, PlayerRating
|
||||||
|
|
||||||
|
replayed = 0
|
||||||
|
async with in_transaction():
|
||||||
|
await PlayerRating.all().delete()
|
||||||
|
matches = await Match.all().order_by("finished_at", "id")
|
||||||
|
for match in matches:
|
||||||
|
players = await MatchPlayer.filter(match_id=match.id)
|
||||||
|
by_team: Dict[str, List[str]] = defaultdict(list)
|
||||||
|
for player in players:
|
||||||
|
if player.team is not None:
|
||||||
|
by_team[player.team].append(player.user_sub)
|
||||||
|
# Deterministic team order; the winner is the team of any
|
||||||
|
# player flagged as having won.
|
||||||
|
teams = [by_team[team] for team in sorted(by_team)]
|
||||||
|
winner_index = next(
|
||||||
|
(
|
||||||
|
i
|
||||||
|
for i, members in enumerate(teams)
|
||||||
|
if any(p.won and p.user_sub in members for p in players)
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
deltas = await apply_elo(match.game_type, teams, winner_index)
|
||||||
|
for player in players:
|
||||||
|
player.elo_delta = deltas[player.user_sub]
|
||||||
|
await player.save()
|
||||||
|
replayed += 1
|
||||||
|
return replayed
|
||||||
|
|
||||||
|
|
||||||
|
async def _main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"--database-url",
|
||||||
|
required=True,
|
||||||
|
help="Tortoise database URL, e.g. postgres://user:pass@host/db",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
mixin = TortoiseMixin(
|
||||||
|
database_url=args.database_url,
|
||||||
|
models_modules=["tavolo.platform.models"],
|
||||||
|
)
|
||||||
|
await mixin._bind()
|
||||||
|
try:
|
||||||
|
replayed = await backfill_elo()
|
||||||
|
log.info("elo backfill complete: %d matches replayed", replayed)
|
||||||
|
print(f"Recomputed ratings from {replayed} matches.")
|
||||||
|
finally:
|
||||||
|
await mixin.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(_main())
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
"""Deadline-driven timeouts, independent of player connections.
|
||||||
|
|
||||||
|
In-match timeouts (e.g. scopone's per-turn auto-play and hand-end
|
||||||
|
auto-continue) are driven by absolute deadlines, never by which players
|
||||||
|
(or whether any players) are connected. The platform owns the scheduling
|
||||||
|
machinery; the *meaning* of each deadline belongs to the game engine:
|
||||||
|
|
||||||
|
* after every mutation the engine's
|
||||||
|
:meth:`~tavolo.platform.engine.GameEngine.next_deadline` computes the
|
||||||
|
deadline the new state implies (kind + due time + a revalidation
|
||||||
|
token), and the scheduler enqueues it in the store's shared deadline
|
||||||
|
queue (a Redis sorted set in production, see
|
||||||
|
:mod:`tavolo.platform.store`); enqueueing is idempotent;
|
||||||
|
* enqueueing a deadline announces it on a shared pub/sub channel, so
|
||||||
|
every worker's consumer wakes immediately instead of waiting for its
|
||||||
|
next poll; the consumer also wakes on a slow heartbeat, which is the
|
||||||
|
backstop recovering notifications lost to a reconnect or a worker
|
||||||
|
crash. Due entries are handed to the engine's
|
||||||
|
:meth:`~tavolo.platform.engine.GameEngine.fire_deadline` under the
|
||||||
|
per-game lock; the engine revalidates the token against the live state
|
||||||
|
and raises :class:`~tavolo.platform.errors.GameError` when the entry
|
||||||
|
was overtaken by events, so duplicate or stale deliveries are harmless.
|
||||||
|
|
||||||
|
Delivery is at-least-once: an entry is removed from the queue only after
|
||||||
|
it has been processed. If a worker dies mid-processing, the entry stays
|
||||||
|
in Redis and another worker's consumer picks it up. Entries whose game
|
||||||
|
has expired are dropped the first time they fire, so the queue is
|
||||||
|
self-cleaning.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from logging import getLogger
|
||||||
|
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||||
|
|
||||||
|
from kaya.core import KayaApp, KayaMixin
|
||||||
|
|
||||||
|
from .engine import GameSession
|
||||||
|
from .errors import GameError
|
||||||
|
from .registry import GameRegistry
|
||||||
|
from .stats import save_match_result
|
||||||
|
from .store import GameStore
|
||||||
|
|
||||||
|
log = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def encode(entry: Dict[str, Any]) -> str:
|
||||||
|
"""Canonical queue-member encoding for a deadline entry."""
|
||||||
|
return json.dumps(entry, sort_keys=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _decode(member: Any) -> Optional[Dict[str, Any]]:
|
||||||
|
if isinstance(member, bytes):
|
||||||
|
member = member.decode("utf-8")
|
||||||
|
if not isinstance(member, str):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
entry = json.loads(member)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return entry if isinstance(entry, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
class DeadlineScheduler:
|
||||||
|
"""Enqueue and fire the deadlines game engines ask for.
|
||||||
|
|
||||||
|
Holds the store and the registry so a single instance serves every
|
||||||
|
game type. One consumer task, its listener task and its wake-up
|
||||||
|
event are kept per event loop — tests run each test on a fresh loop.
|
||||||
|
|
||||||
|
``context_binder`` is an optional async callable the consumer awaits
|
||||||
|
before persisting a finished match. The consumer is a long-lived task
|
||||||
|
of its own, outside any request, so it must bind whatever ambient
|
||||||
|
context the database layer needs (see
|
||||||
|
:meth:`~tavolo.platform.tortoise_mixin.TortoiseMixin.ensure_context`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
store: GameStore,
|
||||||
|
registry: GameRegistry,
|
||||||
|
heartbeat_ms: int = 1000,
|
||||||
|
retry_ms: int = 1000,
|
||||||
|
context_binder: Optional[Callable[[], Awaitable[None]]] = None,
|
||||||
|
) -> None:
|
||||||
|
self._store = store
|
||||||
|
self._registry = registry
|
||||||
|
self._heartbeat = heartbeat_ms / 1000
|
||||||
|
# Backoff for entries that failed to process and for listener
|
||||||
|
# reconnects; deliberately independent of the (slow) heartbeat.
|
||||||
|
self._retry_delay = retry_ms / 1000
|
||||||
|
self._context_binder = context_binder
|
||||||
|
self._consumers: Dict[asyncio.AbstractEventLoop, asyncio.Task] = {}
|
||||||
|
self._wake_events: Dict[asyncio.AbstractEventLoop, asyncio.Event] = {}
|
||||||
|
self._listeners: Dict[asyncio.AbstractEventLoop, asyncio.Task] = {}
|
||||||
|
|
||||||
|
async def sync_deadline(self, session: GameSession) -> None:
|
||||||
|
"""Enqueue the deadline the current state implies, if any.
|
||||||
|
|
||||||
|
Called after every mutation that can set a deadline and as a
|
||||||
|
backstop when a client connects. Enqueueing is idempotent: an
|
||||||
|
identical entry is already queued with the same due time, so
|
||||||
|
re-adding it changes nothing.
|
||||||
|
"""
|
||||||
|
engine = self._registry.require(session.game_type)
|
||||||
|
deadline = engine.next_deadline(session)
|
||||||
|
if deadline is None:
|
||||||
|
return
|
||||||
|
entry = {
|
||||||
|
"game_id": session.id,
|
||||||
|
"kind": deadline.kind,
|
||||||
|
"token": deadline.token,
|
||||||
|
}
|
||||||
|
self.ensure_consumer()
|
||||||
|
due_at = deadline.due_at.timestamp()
|
||||||
|
await self._store.add_deadline(encode(entry), due_at)
|
||||||
|
# Announce the enqueue so consumers on other workers wake up too.
|
||||||
|
# The entry is already queued, so a lost notification only delays
|
||||||
|
# their discovery to the next poll backstop.
|
||||||
|
try:
|
||||||
|
await self._store.notify_deadline()
|
||||||
|
except Exception:
|
||||||
|
log.warning(
|
||||||
|
"deadline enqueue notification failed; relying on the poll backstop",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
wake = self._wake_events.get(asyncio.get_running_loop())
|
||||||
|
if wake is not None:
|
||||||
|
wake.set()
|
||||||
|
|
||||||
|
async def finalize_mutation(self, session: GameSession) -> None:
|
||||||
|
"""Persist a successful mutation, notify subscribers and enqueue
|
||||||
|
the next deadline.
|
||||||
|
|
||||||
|
Callers must hold the per-game lock. Handles the terminal
|
||||||
|
transition: the match result is written to Postgres once
|
||||||
|
(guarded by ``stats_saved``).
|
||||||
|
"""
|
||||||
|
engine = self._registry.require(session.game_type)
|
||||||
|
if engine.is_finished(session):
|
||||||
|
if session.finished_at is None:
|
||||||
|
session.finished_at = datetime.now(timezone.utc)
|
||||||
|
# A deadline can finish the match in the consumer task, which
|
||||||
|
# has no request context: bind the database context first.
|
||||||
|
if self._context_binder is not None:
|
||||||
|
await self._context_binder()
|
||||||
|
await save_match_result(session, engine)
|
||||||
|
log.info("game %s (%s) finished", session.id, session.game_type)
|
||||||
|
await self._store.save(session)
|
||||||
|
await self._store.publish(session.id)
|
||||||
|
await self.sync_deadline(session)
|
||||||
|
|
||||||
|
async def process_due(self, member: Any) -> None:
|
||||||
|
"""Fire a single due deadline entry.
|
||||||
|
|
||||||
|
The engine revalidates the entry against the live state under
|
||||||
|
the per-game lock; stale or foreign entries are discarded
|
||||||
|
without effect. The entry is removed from the queue once handled
|
||||||
|
(including "nothing to do"); if handling fails unexpectedly
|
||||||
|
(e.g. the lock cannot be acquired), the entry is left in the
|
||||||
|
queue so another consumer retries it.
|
||||||
|
"""
|
||||||
|
entry = _decode(member)
|
||||||
|
if entry is None:
|
||||||
|
log.warning("deadline consumer: dropping malformed entry %r", member)
|
||||||
|
await self._store.remove_deadline(member)
|
||||||
|
return
|
||||||
|
game_id = entry.get("game_id")
|
||||||
|
kind = entry.get("kind")
|
||||||
|
token = entry.get("token")
|
||||||
|
if not isinstance(game_id, str) or not isinstance(kind, str):
|
||||||
|
await self._store.remove_deadline(member)
|
||||||
|
return
|
||||||
|
async with self._store.lock(game_id):
|
||||||
|
session = await self._store.load(game_id)
|
||||||
|
if session is not None:
|
||||||
|
engine = self._registry.require(session.game_type)
|
||||||
|
try:
|
||||||
|
engine.fire_deadline(session, kind, str(token))
|
||||||
|
except GameError:
|
||||||
|
# Stale or inapplicable entry: nothing to do.
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
await self.finalize_mutation(session)
|
||||||
|
await self._store.remove_deadline(member)
|
||||||
|
|
||||||
|
# --- consumer lifecycle -------------------------------------------------
|
||||||
|
|
||||||
|
def ensure_consumer(
|
||||||
|
self, loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
|
) -> None:
|
||||||
|
"""Start the deadline consumer on the given (or running) loop if
|
||||||
|
not yet running.
|
||||||
|
|
||||||
|
Called lazily whenever a deadline is enqueued (the ASGI test
|
||||||
|
transport never fires the lifespan hooks, so the mixin's
|
||||||
|
``setup`` alone is not enough) and on application startup. The
|
||||||
|
explicit ``loop`` matters at startup: under RSGI granian calls
|
||||||
|
``setup`` before the loop runs, so ``asyncio.get_running_loop()``
|
||||||
|
would fail there.
|
||||||
|
"""
|
||||||
|
if loop is None:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
for old in list(self._consumers):
|
||||||
|
if old.is_closed():
|
||||||
|
self._consumers.pop(old, None)
|
||||||
|
self._wake_events.pop(old, None)
|
||||||
|
self._listeners.pop(old, None)
|
||||||
|
task = self._consumers.get(loop)
|
||||||
|
if task is None or task.done():
|
||||||
|
self._wake_events[loop] = asyncio.Event()
|
||||||
|
self._consumers[loop] = loop.create_task(self._run(loop))
|
||||||
|
listener = self._listeners.get(loop)
|
||||||
|
if listener is None or listener.done():
|
||||||
|
self._listeners[loop] = loop.create_task(self._listen(loop))
|
||||||
|
log.debug("deadline consumer started")
|
||||||
|
|
||||||
|
def stop_consumer(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||||
|
task = self._consumers.pop(loop, None)
|
||||||
|
self._wake_events.pop(loop, None)
|
||||||
|
listener = self._listeners.pop(loop, None)
|
||||||
|
if listener is not None:
|
||||||
|
listener.cancel()
|
||||||
|
if task is not None:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
async def _run(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||||
|
wake = self._wake_events[loop]
|
||||||
|
while True:
|
||||||
|
# Clear before polling so an enqueue racing the poll re-wakes us.
|
||||||
|
wake.clear()
|
||||||
|
delay = self._heartbeat
|
||||||
|
failed = False
|
||||||
|
try:
|
||||||
|
for member in await self._store.due_deadlines(time.time()):
|
||||||
|
try:
|
||||||
|
await self.process_due(member)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
# Left in the queue; retried on the next pass.
|
||||||
|
log.exception("deadline consumer: failed to process %r", member)
|
||||||
|
failed = True
|
||||||
|
next_due = await self._store.next_deadline()
|
||||||
|
if next_due is not None:
|
||||||
|
delay = max(0.0, min(self._heartbeat, next_due - time.time()))
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
log.exception("deadline consumer: poll failed; retrying")
|
||||||
|
failed = True
|
||||||
|
if failed:
|
||||||
|
# A failed entry is still due: back off briefly instead of
|
||||||
|
# polling it again immediately. Kept independent of the
|
||||||
|
# heartbeat, which is only a slow backstop.
|
||||||
|
delay = self._retry_delay
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(wake.wait(), timeout=delay)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _listen(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||||
|
"""Wake the consumer on deadline-enqueue notifications.
|
||||||
|
|
||||||
|
The subscription is best effort: a dropped connection or a lost
|
||||||
|
message is recovered by the consumer's heartbeat backstop, so
|
||||||
|
this task only has to reconnect and keep forwarding signals.
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
async with self._store.subscribe_deadlines() as events:
|
||||||
|
async for _ in events:
|
||||||
|
wake = self._wake_events.get(loop)
|
||||||
|
if wake is None:
|
||||||
|
# Shut down: the consumer is gone.
|
||||||
|
return
|
||||||
|
wake.set()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
log.exception("deadline listener failed; reconnecting")
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(self._retry_delay)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
class DeadlineSchedulerMixin(KayaMixin):
|
||||||
|
"""Run the deadline consumer for the whole app lifetime.
|
||||||
|
|
||||||
|
Every worker (and every pod) runs the same consumer and listener;
|
||||||
|
coordination happens exclusively through the shared deadline queue,
|
||||||
|
its notification channel and the per-game locks, so any worker may
|
||||||
|
fire any game's deadline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, scheduler: DeadlineScheduler) -> None:
|
||||||
|
self._scheduler = scheduler
|
||||||
|
|
||||||
|
def apply(self, app: KayaApp) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def setup(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||||
|
self._scheduler.ensure_consumer(loop)
|
||||||
|
|
||||||
|
def shutdown(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||||
|
self._scheduler.stop_consumer(loop)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""Chess-style Elo ratings, generalized to two-team matches.
|
||||||
|
|
||||||
|
Every player starts at :data:`INITIAL_RATING`. A team's rating is the mean
|
||||||
|
of its members' current ratings, so the usual chess formula applies
|
||||||
|
unchanged between the two teams:
|
||||||
|
|
||||||
|
* expected score ``E = 1 / (1 + 10 ** ((R_opponent - R_team) / 400))``
|
||||||
|
* actual score ``S`` is 1 for a win and 0 for a loss (matches never draw)
|
||||||
|
* every member of a team gains/loses the same ``round(K * (S - E))``
|
||||||
|
|
||||||
|
Deltas are rounded to integers and ratings are stored as integers, so the
|
||||||
|
system is exactly zero-sum: what the winners gain the losers lose.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
INITIAL_RATING = 1500
|
||||||
|
K_FACTOR = 32
|
||||||
|
|
||||||
|
|
||||||
|
def expected_score(rating: float, opponent_rating: float) -> float:
|
||||||
|
"""Expected score (0..1) of a side rated ``rating`` against
|
||||||
|
``opponent_rating``."""
|
||||||
|
return 1.0 / (1.0 + 10.0 ** ((opponent_rating - rating) / 400.0))
|
||||||
|
|
||||||
|
|
||||||
|
def team_rating(ratings: Sequence[float]) -> float:
|
||||||
|
"""A team's rating is the mean of its members' ratings."""
|
||||||
|
if not ratings:
|
||||||
|
raise ValueError("a team needs at least one rating")
|
||||||
|
return sum(ratings) / len(ratings)
|
||||||
|
|
||||||
|
|
||||||
|
def match_delta(
|
||||||
|
team_a_ratings: Sequence[float],
|
||||||
|
team_b_ratings: Sequence[float],
|
||||||
|
winner_team: int,
|
||||||
|
) -> int:
|
||||||
|
"""Rating change applied to each member of team A.
|
||||||
|
|
||||||
|
``winner_team`` is 0 when team A won, 1 when team B won. Team B
|
||||||
|
members change by the negation of the returned value (zero-sum).
|
||||||
|
"""
|
||||||
|
rating_a = team_rating(team_a_ratings)
|
||||||
|
rating_b = team_rating(team_b_ratings)
|
||||||
|
expected = expected_score(rating_a, rating_b)
|
||||||
|
score = 1.0 if winner_team == 0 else 0.0
|
||||||
|
return round(K_FACTOR * (score - expected))
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
"""The contract between the platform and a game implementation.
|
||||||
|
|
||||||
|
The platform owns everything that is game-independent: the lobby, live
|
||||||
|
game storage, websockets, timeouts, match history and leaderboards. A
|
||||||
|
game (e.g. scopone scientifico) implements :class:`GameEngine` and plugs
|
||||||
|
into the platform through :class:`~tavolo.platform.registry.GameRegistry`;
|
||||||
|
the platform never imports a concrete game module.
|
||||||
|
|
||||||
|
The central type is :class:`GameSession`: the platform-owned envelope
|
||||||
|
carrying the lifecycle metadata (id, join code, seats, timestamps) plus
|
||||||
|
an opaque ``state`` blob that only the engine interprets. The engine
|
||||||
|
serializes/deserializes that blob (:meth:`GameEngine.state_to_json` /
|
||||||
|
:meth:`GameEngine.state_from_json`) so the store stays game-agnostic.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, List, Mapping, Optional
|
||||||
|
|
||||||
|
from .errors import GameError
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Deadline",
|
||||||
|
"GameEngine",
|
||||||
|
"GameSession",
|
||||||
|
"MatchResult",
|
||||||
|
"PlayerResult",
|
||||||
|
"Seat",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Seat:
|
||||||
|
"""One occupied chair in a game, owned by the platform.
|
||||||
|
|
||||||
|
Games may keep their own per-player state internally; the seat is
|
||||||
|
what the platform needs for the lobby, for access control and for
|
||||||
|
persisting participations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
user_sub: str
|
||||||
|
display_name: str
|
||||||
|
# Game-defined team label ("A"/"B", ...); ``None`` for games without
|
||||||
|
# fixed teams.
|
||||||
|
team: Optional[str] = None
|
||||||
|
|
||||||
|
def to_json(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"user_sub": self.user_sub,
|
||||||
|
"display_name": self.display_name,
|
||||||
|
"team": self.team,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_json(data: Mapping[str, Any]) -> "Seat":
|
||||||
|
return Seat(
|
||||||
|
user_sub=str(data["user_sub"]),
|
||||||
|
display_name=str(data["display_name"]),
|
||||||
|
team=data.get("team"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GameSession:
|
||||||
|
"""The platform-owned envelope of a live game.
|
||||||
|
|
||||||
|
``state`` is opaque to the platform: only the engine registered for
|
||||||
|
``game_type`` reads or writes it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
game_type: str
|
||||||
|
join_code: str
|
||||||
|
creator_sub: str
|
||||||
|
players: List[Seat] = field(default_factory=list)
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
finished_at: Optional[datetime] = None
|
||||||
|
# Set once the finished match has been copied to Postgres.
|
||||||
|
stats_saved: bool = False
|
||||||
|
state: Any = None
|
||||||
|
|
||||||
|
def seated(self, sub: str) -> bool:
|
||||||
|
"""Whether ``sub`` occupies a seat in this game."""
|
||||||
|
return self.seat_of(sub) is not None
|
||||||
|
|
||||||
|
def seat_of(self, sub: str) -> Optional[Seat]:
|
||||||
|
for seat in self.players:
|
||||||
|
if seat.user_sub == sub:
|
||||||
|
return seat
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Deadline:
|
||||||
|
"""A timeout the engine wants the platform to fire.
|
||||||
|
|
||||||
|
``kind`` is an engine-defined string (e.g. ``"turn"``); ``token`` is
|
||||||
|
an opaque revalidation token: when the deadline fires, the engine must
|
||||||
|
recompute it from the live session and refuse to act (raising
|
||||||
|
:class:`~tavolo.platform.errors.GameError`) if it no longer matches,
|
||||||
|
which makes duplicate or overtaken deliveries harmless.
|
||||||
|
"""
|
||||||
|
|
||||||
|
kind: str
|
||||||
|
due_at: datetime
|
||||||
|
token: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PlayerResult:
|
||||||
|
"""The outcome of a finished match for one participant."""
|
||||||
|
|
||||||
|
user_sub: str
|
||||||
|
seat: int
|
||||||
|
won: bool
|
||||||
|
team: Optional[str] = None
|
||||||
|
# Points scored, aggregated by the leaderboard.
|
||||||
|
score: float = 0.0
|
||||||
|
# Game-specific extras persisted alongside the participation.
|
||||||
|
details: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MatchResult:
|
||||||
|
"""The game-independent outcome of a finished match.
|
||||||
|
|
||||||
|
``teams`` groups the participants' subs into the sides the Elo rating
|
||||||
|
treats as opponents (exactly two for now); ``winner_team`` is the
|
||||||
|
index of the winning side. ``summary`` is persisted verbatim as the
|
||||||
|
match's JSON ``result`` column.
|
||||||
|
"""
|
||||||
|
|
||||||
|
teams: List[List[str]]
|
||||||
|
winner_team: int
|
||||||
|
players: List[PlayerResult]
|
||||||
|
summary: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class GameEngine(ABC):
|
||||||
|
"""The interface every playable game implements.
|
||||||
|
|
||||||
|
Implementations must be deterministic and I/O-free apart from
|
||||||
|
logging: all persistence, locking and transport concerns belong to
|
||||||
|
the platform. Rule violations are reported by raising
|
||||||
|
:class:`~tavolo.platform.errors.GameError` subclasses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: Unique id carried by sessions and persisted matches.
|
||||||
|
id: str
|
||||||
|
#: Human-readable name and description, for the lobby UI.
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
min_players: int
|
||||||
|
max_players: int
|
||||||
|
#: JSON-schema fragment describing the game-specific creation options
|
||||||
|
#: accepted by :meth:`create` (exposed via ``GET /api/game-types``).
|
||||||
|
options_schema: Mapping[str, Any] = {}
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def create(self, session: GameSession, options: Mapping[str, Any]) -> None:
|
||||||
|
"""Initialize ``session.state`` for a fresh lobby game.
|
||||||
|
|
||||||
|
The platform has already filled the session envelope and seated
|
||||||
|
the creator (``session.players[0]``, without a team label yet).
|
||||||
|
Assign the creator's team here if the game has fixed teams, then
|
||||||
|
validate ``options`` (the opaque object from the create request;
|
||||||
|
raise :class:`~tavolo.platform.errors.GameError` on invalid
|
||||||
|
values) and initialize ``session.state``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def join(self, session: GameSession, user_sub: str, display_name: str) -> None:
|
||||||
|
"""Seat a player, starting the match when the lobby fills up.
|
||||||
|
|
||||||
|
Must append a :class:`Seat` to ``session.players`` and mirror
|
||||||
|
whatever per-player state the game keeps in ``session.state``.
|
||||||
|
Raises ``AlreadyJoined`` / ``LobbyFull`` / ``GameNotStarted``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def handle_action(
|
||||||
|
self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any]
|
||||||
|
) -> None:
|
||||||
|
"""Apply one player action received over the websocket.
|
||||||
|
|
||||||
|
``payload`` is the client message minus its ``action`` field.
|
||||||
|
Unknown actions and rule violations raise
|
||||||
|
:class:`~tavolo.platform.errors.GameError`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
|
||||||
|
"""The personalized game view for one viewer.
|
||||||
|
|
||||||
|
Must hide information the viewer is not entitled to (e.g. other
|
||||||
|
players' hands). ``user_sub`` is ``None`` for a spectator — an
|
||||||
|
anonymous user or an authenticated user who is not seated — and
|
||||||
|
the view must then contain no privileged information either (no
|
||||||
|
hand, no legal moves: only what everyone at the table can see).
|
||||||
|
The platform merges in the envelope fields (``id``,
|
||||||
|
``join_code`` for seated players, ``game_type``, ``spectator``)
|
||||||
|
itself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
|
||||||
|
"""Game-specific fields of the lobby payload (options echo, phase).
|
||||||
|
|
||||||
|
Echo the creation options under the same keys as
|
||||||
|
:attr:`options_schema`, plus ``phase``, so clients can render a
|
||||||
|
generic pre-join preview (``GET /api/games/preview``) from the
|
||||||
|
schema without game-specific code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def in_lobby(self, session: GameSession) -> bool:
|
||||||
|
"""Whether the game is still waiting for players."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def is_finished(self, session: GameSession) -> bool:
|
||||||
|
"""Whether the match is over and a result can be extracted."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def result(self, session: GameSession) -> MatchResult:
|
||||||
|
"""The outcome of the match. Only called when finished."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def state_to_json(self, state: Any) -> Dict[str, Any]:
|
||||||
|
"""Serialize the opaque game state to plain JSON."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def state_from_json(self, data: Mapping[str, Any]) -> Any:
|
||||||
|
"""Rebuild the opaque game state from its JSON form."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def next_deadline(self, session: GameSession) -> Optional[Deadline]:
|
||||||
|
"""The deadline the current state implies, if any.
|
||||||
|
|
||||||
|
Called after every mutation; the platform enqueues the returned
|
||||||
|
deadline (enqueueing is idempotent) and never removes previously
|
||||||
|
enqueued ones — stale entries are discarded at fire time by
|
||||||
|
:meth:`fire_deadline` revalidation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def fire_deadline(self, session: GameSession, kind: str, token: str) -> None:
|
||||||
|
"""Apply the timeout action for a due deadline.
|
||||||
|
|
||||||
|
Must recompute the current deadline and raise
|
||||||
|
:class:`~tavolo.platform.errors.GameError` without acting when
|
||||||
|
``kind``/``token`` no longer match the live state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def game_over_view(
|
||||||
|
self, session: GameSession, user_sub: Optional[str]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Extra fields merged into the ``game_over`` websocket message.
|
||||||
|
|
||||||
|
``user_sub`` is ``None`` for a spectator (see :meth:`view_for`);
|
||||||
|
the default fields are public, so the default implementation
|
||||||
|
ignores it.
|
||||||
|
"""
|
||||||
|
return {}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Typed errors shared between the platform and game implementations.
|
||||||
|
|
||||||
|
Game engines raise :class:`GameError` subclasses on rule/validation
|
||||||
|
failures; the platform translates them into 4xx HTTP responses or
|
||||||
|
``error`` WebSocket messages, using :attr:`GameError.code` as the
|
||||||
|
machine-readable error code. Engines themselves stay transport-agnostic.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class GameError(Exception):
|
||||||
|
"""Base class for every rule/validation failure a game can raise."""
|
||||||
|
|
||||||
|
#: Machine-readable code carried by WebSocket ``error`` messages.
|
||||||
|
code = "illegal_move"
|
||||||
|
|
||||||
|
|
||||||
|
class IllegalMove(GameError):
|
||||||
|
"""The requested action violates the rules of the game."""
|
||||||
|
|
||||||
|
|
||||||
|
class NotYourTurn(GameError):
|
||||||
|
"""A player attempted to act out of turn."""
|
||||||
|
|
||||||
|
|
||||||
|
class GameNotStarted(GameError):
|
||||||
|
"""An action was attempted before the game left the lobby."""
|
||||||
|
|
||||||
|
|
||||||
|
class GameFinished(GameError):
|
||||||
|
"""An action was attempted after the match ended."""
|
||||||
|
|
||||||
|
|
||||||
|
class LobbyFull(GameError):
|
||||||
|
"""A game already has its full complement of players."""
|
||||||
|
|
||||||
|
|
||||||
|
class AlreadyJoined(GameError):
|
||||||
|
"""A player tried to join a game they are already seated in."""
|
||||||
|
|
||||||
|
|
||||||
|
class GameNotFound(GameError):
|
||||||
|
"""No live game exists for the given id or join code."""
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""The :class:`PlatformMixin`: mounts the whole platform on a kaya app.
|
||||||
|
|
||||||
|
``Platform`` bundles the collaborators every platform endpoint needs —
|
||||||
|
the game registry, the live-game store, the deadline scheduler and the
|
||||||
|
OIDC providers used for authentication — so route and websocket modules
|
||||||
|
never reach for module-global state. :class:`PlatformMixin` is a plain
|
||||||
|
:class:`~kaya.core.KayaMixin`: applying it registers the lobby, stats
|
||||||
|
and health HTTP routes plus the live-play websocket endpoint, and the
|
||||||
|
app stays a :class:`~kaya.core.KayaApp` (both ASGI and RSGI keep
|
||||||
|
working).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from kaya.core import KayaApp, KayaMixin
|
||||||
|
from kaya.oidc import OIDCProviders
|
||||||
|
|
||||||
|
from .deadlines import DeadlineScheduler
|
||||||
|
from .registry import GameRegistry
|
||||||
|
from .store import GameStore
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Platform:
|
||||||
|
"""The collaborators shared by all platform endpoints."""
|
||||||
|
|
||||||
|
registry: GameRegistry
|
||||||
|
game_store: GameStore
|
||||||
|
scheduler: DeadlineScheduler
|
||||||
|
oidc: OIDCProviders
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformMixin(KayaMixin):
|
||||||
|
"""Register the game-independent routes and the websocket endpoint."""
|
||||||
|
|
||||||
|
def __init__(self, platform: Platform) -> None:
|
||||||
|
self.platform = platform
|
||||||
|
|
||||||
|
def apply(self, app: KayaApp) -> None:
|
||||||
|
# Imported here so module import order stays acyclic: the route
|
||||||
|
# modules reference Platform only for typing.
|
||||||
|
from . import ws
|
||||||
|
from .routes import auth, games, health, me, stats
|
||||||
|
|
||||||
|
health.register(app, self.platform)
|
||||||
|
auth.register(app, self.platform)
|
||||||
|
me.register(app, self.platform)
|
||||||
|
games.register(app, self.platform)
|
||||||
|
stats.register(app, self.platform)
|
||||||
|
ws.register(app, self.platform)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Tortoise ORM models: match statistics persisted in Postgres.
|
||||||
|
|
||||||
|
Live game state lives in Redis (see :mod:`tavolo.platform.store`); only
|
||||||
|
completed matches are written here. The schema is game-independent:
|
||||||
|
|
||||||
|
* :class:`Match` — one row per finished match; everything game-specific
|
||||||
|
(scores, hands played, options, per-hand audit trail) lives in the
|
||||||
|
JSON ``result`` column produced by the game's engine.
|
||||||
|
* :class:`MatchPlayer` — one row per participant, linking an OIDC
|
||||||
|
``sub`` to a seat, an optional team label, whether they won, the
|
||||||
|
points they scored and their Elo delta.
|
||||||
|
* :class:`PlayerRating` — current chess-style Elo rating of a player for
|
||||||
|
one game type, updated transactionally with every finished match (see
|
||||||
|
:mod:`tavolo.platform.elo`).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from tortoise import fields
|
||||||
|
from tortoise.models import Model
|
||||||
|
|
||||||
|
from .elo import INITIAL_RATING
|
||||||
|
|
||||||
|
|
||||||
|
class Match(Model):
|
||||||
|
"""A completed match of one of the registered game types."""
|
||||||
|
|
||||||
|
id = fields.UUIDField(pk=True)
|
||||||
|
# Which game was played (an id from the game registry).
|
||||||
|
game_type = fields.CharField(max_length=32, db_index=True)
|
||||||
|
started_at = fields.DatetimeField()
|
||||||
|
finished_at = fields.DatetimeField()
|
||||||
|
# Game-specific outcome summary as reported by the engine's
|
||||||
|
# ``MatchResult.summary`` (scores, hands played, options, ...).
|
||||||
|
result: dict = fields.JSONField(default=dict)
|
||||||
|
|
||||||
|
players: fields.ReverseRelation["MatchPlayer"]
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
table = "match"
|
||||||
|
ordering = ["-finished_at"]
|
||||||
|
|
||||||
|
|
||||||
|
class MatchPlayer(Model):
|
||||||
|
"""Participation of one user in one match."""
|
||||||
|
|
||||||
|
id = fields.UUIDField(pk=True)
|
||||||
|
match: fields.ForeignKeyRelation[Match] = fields.ForeignKeyField(
|
||||||
|
"models.Match", related_name="players", on_delete=fields.CASCADE
|
||||||
|
)
|
||||||
|
# OIDC subject of the player; no local users table.
|
||||||
|
user_sub = fields.CharField(max_length=255, db_index=True)
|
||||||
|
display_name = fields.CharField(max_length=200)
|
||||||
|
seat = fields.SmallIntField()
|
||||||
|
# Game-defined team label; null for games without fixed teams.
|
||||||
|
team = fields.CharField(max_length=32, null=True)
|
||||||
|
won = fields.BooleanField()
|
||||||
|
# Points the player scored, aggregated by the leaderboard.
|
||||||
|
score = fields.FloatField(default=0)
|
||||||
|
# Elo change this match produced for the player (see
|
||||||
|
# tavolo.platform.elo); null for matches recorded before ratings
|
||||||
|
# existed.
|
||||||
|
elo_delta = fields.SmallIntField(null=True)
|
||||||
|
# Game-specific extras reported by the engine's PlayerResult.
|
||||||
|
details: dict = fields.JSONField(default=dict)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
table = "match_player"
|
||||||
|
unique_together = (("match", "user_sub"),)
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerRating(Model):
|
||||||
|
"""Current Elo rating of one player for one game type."""
|
||||||
|
|
||||||
|
id = fields.UUIDField(pk=True)
|
||||||
|
# OIDC subject of the player; no local users table.
|
||||||
|
user_sub = fields.CharField(max_length=255)
|
||||||
|
# Which game the rating applies to (an id from the game registry).
|
||||||
|
game_type = fields.CharField(max_length=32)
|
||||||
|
rating = fields.IntField(default=INITIAL_RATING)
|
||||||
|
matches_played = fields.IntField(default=0)
|
||||||
|
updated_at = fields.DatetimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
table = "player_rating"
|
||||||
|
unique_together = (("user_sub", "game_type"),)
|
||||||
|
indexes = (("game_type", "rating"),)
|
||||||
+28
-2
@@ -15,12 +15,15 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from tortoise.queryset import QuerySet
|
from tortoise.queryset import QuerySet
|
||||||
|
|
||||||
from .http import extract_query_params
|
from .http import extract_query_params
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from tortoise.models import Model
|
||||||
|
|
||||||
DEFAULT_LIMIT = 20
|
DEFAULT_LIMIT = 20
|
||||||
MAX_LIMIT = 100
|
MAX_LIMIT = 100
|
||||||
MIN_LIMIT = 1
|
MIN_LIMIT = 1
|
||||||
@@ -81,6 +84,28 @@ def parse_cursor_params(query_string: str) -> Cursor:
|
|||||||
Sort = List[Tuple[str, str]]
|
Sort = List[Tuple[str, str]]
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_sort_values(
|
||||||
|
model: "type[Model]", sort: Sort, after: Dict[str, Any]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Restore the Python type of every decoded cursor value.
|
||||||
|
|
||||||
|
The cursor is JSON, so field values travel as strings (datetimes via
|
||||||
|
``default=str``). asyncpg rejects strings where a ``timestamp``
|
||||||
|
parameter is expected, and sqlite only tolerates them by accident, so
|
||||||
|
each value is run through its model field's ``to_python_value`` before
|
||||||
|
it reaches the keyset filter.
|
||||||
|
"""
|
||||||
|
fields = model._meta.fields_map
|
||||||
|
coerced: Dict[str, Any] = {}
|
||||||
|
for field, _ in sort:
|
||||||
|
value = after.get(field)
|
||||||
|
field_obj = fields.get(field)
|
||||||
|
if value is not None and field_obj is not None:
|
||||||
|
value = field_obj.to_python_value(value)
|
||||||
|
coerced[field] = value
|
||||||
|
return coerced
|
||||||
|
|
||||||
|
|
||||||
def _keyset_where(sort: Sort, after: Dict[str, Any]):
|
def _keyset_where(sort: Sort, after: Dict[str, Any]):
|
||||||
"""Build a Tortoise ``Q`` filter from a cursor."""
|
"""Build a Tortoise ``Q`` filter from a cursor."""
|
||||||
from tortoise.queryset import Q # local to keep import edge narrow
|
from tortoise.queryset import Q # local to keep import edge narrow
|
||||||
@@ -116,7 +141,8 @@ async def paginate(
|
|||||||
order_by.append(field if direction == "ASC" else f"-{field}")
|
order_by.append(field if direction == "ASC" else f"-{field}")
|
||||||
qs = queryset.order_by(*order_by)
|
qs = queryset.order_by(*order_by)
|
||||||
if cursor.after:
|
if cursor.after:
|
||||||
qs = qs.filter(_keyset_where(sort, cursor.after))
|
after = _coerce_sort_values(queryset.model, sort, cursor.after)
|
||||||
|
qs = qs.filter(_keyset_where(sort, after))
|
||||||
rows = await qs.limit(cursor.limit + 1)
|
rows = await qs.limit(cursor.limit + 1)
|
||||||
if len(rows) <= cursor.limit:
|
if len(rows) <= cursor.limit:
|
||||||
return rows, None
|
return rows, None
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Registry of the games the platform can host.
|
||||||
|
|
||||||
|
The registry maps a ``game_type`` id to its
|
||||||
|
:class:`~tavolo.platform.engine.GameEngine` implementation and is the
|
||||||
|
single source of truth for which games exist: the lobby lists it, the
|
||||||
|
store uses it to deserialize opaque game state, and the stats endpoints
|
||||||
|
use it to validate ``game_type`` filters. Games register themselves at
|
||||||
|
composition time (see the application entry point); the platform itself
|
||||||
|
ships no game.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Dict, Iterable, List, Optional
|
||||||
|
|
||||||
|
from .engine import GameEngine
|
||||||
|
from .errors import GameError
|
||||||
|
|
||||||
|
|
||||||
|
class UnknownGameType(GameError):
|
||||||
|
"""A request named a ``game_type`` no registered engine provides."""
|
||||||
|
|
||||||
|
code = "unknown_game_type"
|
||||||
|
|
||||||
|
|
||||||
|
class GameRegistry:
|
||||||
|
"""An ordered collection of game engines, keyed by their id."""
|
||||||
|
|
||||||
|
def __init__(self, engines: Iterable[GameEngine] = ()) -> None:
|
||||||
|
self._engines: Dict[str, GameEngine] = {}
|
||||||
|
for engine in engines:
|
||||||
|
self.register(engine)
|
||||||
|
|
||||||
|
def register(self, engine: GameEngine) -> GameEngine:
|
||||||
|
"""Add ``engine``; the first registered becomes the default."""
|
||||||
|
if engine.id in self._engines:
|
||||||
|
raise ValueError(f"duplicate game engine id: {engine.id!r}")
|
||||||
|
self._engines[engine.id] = engine
|
||||||
|
return engine
|
||||||
|
|
||||||
|
def get(self, game_type: str) -> Optional[GameEngine]:
|
||||||
|
"""Return the engine for ``game_type``, or ``None``."""
|
||||||
|
return self._engines.get(game_type)
|
||||||
|
|
||||||
|
def require(self, game_type: str) -> GameEngine:
|
||||||
|
"""Return the engine for ``game_type`` or raise."""
|
||||||
|
engine = self.get(game_type)
|
||||||
|
if engine is None:
|
||||||
|
raise UnknownGameType(f"unknown game_type: {game_type!r}")
|
||||||
|
return engine
|
||||||
|
|
||||||
|
@property
|
||||||
|
def default(self) -> GameEngine:
|
||||||
|
"""The first registered engine, used when no type is requested."""
|
||||||
|
try:
|
||||||
|
return next(iter(self._engines.values()))
|
||||||
|
except StopIteration:
|
||||||
|
raise RuntimeError("no game engines registered") from None
|
||||||
|
|
||||||
|
def all(self) -> List[GameEngine]:
|
||||||
|
return list(self._engines.values())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""HTTP endpoints of the platform: health, identity, lobby, statistics."""
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Authentication plumbing for the single-page app.
|
||||||
|
|
||||||
|
kaya-oidc registers one login/callback/logout route per provider
|
||||||
|
(``/auth/<provider>/...``); this module adds the provider-agnostic
|
||||||
|
pieces:
|
||||||
|
|
||||||
|
* ``GET /api/auth/providers`` lists the configured providers with their
|
||||||
|
login URLs, so the SPA can offer a choice without hardcoding names;
|
||||||
|
* ``GET /auth/login`` redirects to the first configured provider;
|
||||||
|
* ``GET /auth/logout`` redirects to the provider this session is logged
|
||||||
|
in with (or the SPA root when not logged in), so the UI can keep a
|
||||||
|
single logout link.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from kaya.core import HttpContext, KayaApp
|
||||||
|
from kaya.openapi import operation
|
||||||
|
|
||||||
|
from ..http import send_json
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ..mixin import Platform
|
||||||
|
|
||||||
|
|
||||||
|
def _login_url(platform: "Platform", provider: str) -> str:
|
||||||
|
return f"{platform.oidc.base_path}/{provider}/login"
|
||||||
|
|
||||||
|
|
||||||
|
def register(app: KayaApp, platform: "Platform") -> None:
|
||||||
|
providers = list(platform.oidc.providers)
|
||||||
|
|
||||||
|
@app.GET("/api/auth/providers")
|
||||||
|
@operation(summary="Configured login providers",
|
||||||
|
description="Names and login URLs of the configured OIDC "
|
||||||
|
"providers, in configuration order.",
|
||||||
|
tags=["auth"],
|
||||||
|
responses={200: {"description": "The configured providers"}})
|
||||||
|
async def auth_providers(ctx: HttpContext) -> None:
|
||||||
|
await send_json(
|
||||||
|
ctx,
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"providers": [
|
||||||
|
{"name": name, "login_url": _login_url(platform, name)}
|
||||||
|
for name in providers
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.GET("/auth/login")
|
||||||
|
async def login(ctx: HttpContext) -> None:
|
||||||
|
await ctx.send_empty(302, {"Location": _login_url(platform, providers[0])})
|
||||||
|
|
||||||
|
@app.GET("/auth/logout")
|
||||||
|
async def logout(ctx: HttpContext) -> None:
|
||||||
|
logged_in = platform.oidc.logged_in_providers(ctx)
|
||||||
|
location = (
|
||||||
|
f"{platform.oidc.base_path}/{logged_in[0]}/logout" if logged_in else "/"
|
||||||
|
)
|
||||||
|
await ctx.send_empty(302, {"Location": location})
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
"""Game lobby endpoints.
|
||||||
|
|
||||||
|
A game starts as a lobby: the creator is seated first and shares the
|
||||||
|
six-character ``join_code``. When the lobby fills up, the engine starts
|
||||||
|
the match. Live play then happens over the ``/ws/games/{id}`` websocket
|
||||||
|
(see :mod:`tavolo.platform.ws`); these endpoints cover creation, joining
|
||||||
|
and snapshotting state.
|
||||||
|
|
||||||
|
Everything game-specific — which options a game accepts, when the lobby
|
||||||
|
is full, what the personalized view looks like — is delegated to the
|
||||||
|
engine registered for the session's ``game_type``.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from logging import getLogger
|
||||||
|
from typing import TYPE_CHECKING, Any, Dict, Optional
|
||||||
|
|
||||||
|
from kaya.core import HttpContext, KayaApp
|
||||||
|
from kaya.openapi import operation
|
||||||
|
|
||||||
|
from ..auth import current_user, display_name, require_auth
|
||||||
|
from ..engine import GameSession, Seat
|
||||||
|
from ..errors import GameError
|
||||||
|
from ..http import (
|
||||||
|
JsonRequestError,
|
||||||
|
extract_query_params,
|
||||||
|
read_json,
|
||||||
|
read_json_optional,
|
||||||
|
send_error,
|
||||||
|
send_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ..mixin import Platform
|
||||||
|
|
||||||
|
log = getLogger(__name__)
|
||||||
|
|
||||||
|
# Join codes avoid characters that are easy to confuse when read aloud.
|
||||||
|
_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
_CODE_LENGTH = 6
|
||||||
|
_MAX_CODE_ATTEMPTS = 20
|
||||||
|
|
||||||
|
|
||||||
|
def _now_code() -> str:
|
||||||
|
return "".join(secrets.choice(_CODE_ALPHABET) for _ in range(_CODE_LENGTH))
|
||||||
|
|
||||||
|
|
||||||
|
async def _unique_code(platform: "Platform") -> str:
|
||||||
|
for _ in range(_MAX_CODE_ATTEMPTS):
|
||||||
|
code = _now_code()
|
||||||
|
if await platform.game_store.find_by_code(code) is None:
|
||||||
|
return code
|
||||||
|
raise RuntimeError("could not allocate a unique join code")
|
||||||
|
|
||||||
|
|
||||||
|
def _lobby_payload(platform: "Platform", session: GameSession) -> Dict[str, Any]:
|
||||||
|
engine = platform.registry.require(session.game_type)
|
||||||
|
return {
|
||||||
|
"id": session.id,
|
||||||
|
"join_code": session.join_code,
|
||||||
|
"game_type": session.game_type,
|
||||||
|
"players": [
|
||||||
|
{
|
||||||
|
"sub": seat.user_sub,
|
||||||
|
"name": seat.display_name,
|
||||||
|
"seat": index,
|
||||||
|
"team": seat.team,
|
||||||
|
}
|
||||||
|
for index, seat in enumerate(session.players)
|
||||||
|
],
|
||||||
|
"seats_open": max(0, engine.max_players - len(session.players)),
|
||||||
|
**engine.lobby_view(session),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _view_payload(
|
||||||
|
platform: "Platform", session: GameSession, sub: Optional[str]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
engine = platform.registry.require(session.game_type)
|
||||||
|
seated = sub is not None and session.seated(sub)
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"id": session.id,
|
||||||
|
"game_type": session.game_type,
|
||||||
|
"spectator": not seated,
|
||||||
|
# Free chairs; the lobby view needs it for games with other player
|
||||||
|
# counts than four.
|
||||||
|
"seats_open": max(0, engine.max_players - len(session.players)),
|
||||||
|
}
|
||||||
|
if seated:
|
||||||
|
# The join code is a credential: only players get it.
|
||||||
|
payload["join_code"] = session.join_code
|
||||||
|
payload.update(engine.view_for(session, sub))
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _preview_payload(platform: "Platform", session: GameSession) -> Dict[str, Any]:
|
||||||
|
"""The join-code preview: the lobby payload plus the game's name and
|
||||||
|
description, so a client can render a pre-join summary without a
|
||||||
|
second lookup."""
|
||||||
|
engine = platform.registry.require(session.game_type)
|
||||||
|
return {
|
||||||
|
**_lobby_payload(platform, session),
|
||||||
|
"game_name": engine.name,
|
||||||
|
"game_description": engine.description,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def register(app: KayaApp, platform: "Platform") -> None:
|
||||||
|
@app.GET("/api/game-types")
|
||||||
|
@operation(summary="List available games",
|
||||||
|
description="Every game the platform can host, for the "
|
||||||
|
"match-creation dropdown. ``options_schema`` "
|
||||||
|
"describes the per-game creation options accepted "
|
||||||
|
"by POST /api/games.",
|
||||||
|
tags=["games"],
|
||||||
|
responses={200: {"description": "The available game types"}})
|
||||||
|
async def list_game_types(ctx: HttpContext) -> None:
|
||||||
|
await send_json(ctx, 200, {
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": engine.id,
|
||||||
|
"name": engine.name,
|
||||||
|
"description": engine.description,
|
||||||
|
"min_players": engine.min_players,
|
||||||
|
"max_players": engine.max_players,
|
||||||
|
"options_schema": engine.options_schema,
|
||||||
|
}
|
||||||
|
for engine in platform.registry.all()
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.POST("/api/games")
|
||||||
|
@operation(summary="Create a game",
|
||||||
|
description="Creates a lobby game and seats the caller in "
|
||||||
|
"seat 0. Share the returned join_code with the "
|
||||||
|
"other players. ``options`` is an opaque object "
|
||||||
|
"validated by the chosen game (see "
|
||||||
|
"GET /api/game-types).",
|
||||||
|
tags=["games"],
|
||||||
|
request_body={
|
||||||
|
"required": False,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"game_type": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "One of the ids from "
|
||||||
|
"GET /api/game-types; "
|
||||||
|
"defaults to the "
|
||||||
|
"platform's first "
|
||||||
|
"registered game",
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Game-specific creation "
|
||||||
|
"options",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses={
|
||||||
|
201: {"description": "The created lobby"},
|
||||||
|
400: {"description": "Invalid game_type, options or body"},
|
||||||
|
401: {"description": "Authentication required"},
|
||||||
|
})
|
||||||
|
@require_auth(platform.oidc)
|
||||||
|
async def create_game(ctx: HttpContext) -> None:
|
||||||
|
body: dict = {}
|
||||||
|
try:
|
||||||
|
body = await read_json_optional(ctx)
|
||||||
|
except JsonRequestError as exc:
|
||||||
|
await send_error(ctx, 400, str(exc))
|
||||||
|
return
|
||||||
|
|
||||||
|
game_type: Any = body.get("game_type")
|
||||||
|
if game_type is None:
|
||||||
|
engine = platform.registry.default
|
||||||
|
elif isinstance(game_type, str) and platform.registry.get(game_type) is not None:
|
||||||
|
engine = platform.registry.require(game_type)
|
||||||
|
else:
|
||||||
|
await send_error(ctx, 400, f"unknown game_type: {game_type!r}")
|
||||||
|
return
|
||||||
|
|
||||||
|
options: Any = body.get("options", {})
|
||||||
|
if not isinstance(options, dict):
|
||||||
|
await send_error(ctx, 400, "options must be an object")
|
||||||
|
return
|
||||||
|
|
||||||
|
user = current_user(platform.oidc, ctx)
|
||||||
|
assert user is not None # enforced by @require_auth
|
||||||
|
session = GameSession(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
game_type=engine.id,
|
||||||
|
join_code=await _unique_code(platform),
|
||||||
|
creator_sub=user.sub,
|
||||||
|
players=[Seat(user_sub=user.sub, display_name=display_name(user))],
|
||||||
|
created_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
engine.create(session, options)
|
||||||
|
except GameError as exc:
|
||||||
|
await send_error(ctx, 400, str(exc))
|
||||||
|
return
|
||||||
|
await platform.game_store.save(session)
|
||||||
|
log.info(
|
||||||
|
"game %s created by %s (%s, options %r)",
|
||||||
|
session.id,
|
||||||
|
user.sub,
|
||||||
|
engine.id,
|
||||||
|
options,
|
||||||
|
)
|
||||||
|
await send_json(ctx, 201, _lobby_payload(platform, session))
|
||||||
|
|
||||||
|
@app.POST("/api/games/join")
|
||||||
|
@operation(summary="Join a game by code",
|
||||||
|
description="Seats the caller in the next free chair. Joining "
|
||||||
|
"as the last player starts the match.",
|
||||||
|
tags=["games"],
|
||||||
|
request_body={
|
||||||
|
"required": True,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"code": {"type": "string"}},
|
||||||
|
"required": ["code"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses={
|
||||||
|
200: {"description": "Seated; game state (may be playing)"},
|
||||||
|
400: {"description": "Missing code"},
|
||||||
|
401: {"description": "Authentication required"},
|
||||||
|
404: {"description": "Unknown join code"},
|
||||||
|
409: {"description": "Already joined or lobby full"},
|
||||||
|
})
|
||||||
|
@require_auth(platform.oidc)
|
||||||
|
async def join_game(ctx: HttpContext) -> None:
|
||||||
|
try:
|
||||||
|
body = await read_json(ctx)
|
||||||
|
except JsonRequestError as exc:
|
||||||
|
await send_error(ctx, 400, str(exc))
|
||||||
|
return
|
||||||
|
code = body.get("code")
|
||||||
|
if not isinstance(code, str) or not code:
|
||||||
|
await send_error(ctx, 400, "code is required")
|
||||||
|
return
|
||||||
|
|
||||||
|
user = current_user(platform.oidc, ctx)
|
||||||
|
assert user is not None
|
||||||
|
existing = await platform.game_store.find_by_code(code)
|
||||||
|
if existing is None:
|
||||||
|
log.debug("join rejected for %s: unknown code %r", user.sub, code)
|
||||||
|
await send_error(ctx, 404, "unknown join code")
|
||||||
|
return
|
||||||
|
|
||||||
|
async with platform.game_store.lock(existing.id):
|
||||||
|
session = await platform.game_store.load(existing.id)
|
||||||
|
if session is None:
|
||||||
|
await send_error(ctx, 404, "unknown join code")
|
||||||
|
return
|
||||||
|
engine = platform.registry.require(session.game_type)
|
||||||
|
try:
|
||||||
|
engine.join(session, user.sub, display_name(user))
|
||||||
|
except GameError as exc:
|
||||||
|
log.debug("join rejected for %s in game %s: %s", user.sub, session.id, exc)
|
||||||
|
await send_error(ctx, 409, str(exc))
|
||||||
|
return
|
||||||
|
await platform.game_store.save(session)
|
||||||
|
await platform.game_store.publish(session.id)
|
||||||
|
# When the last join started the match, the engine may have
|
||||||
|
# armed a deadline; queue it so it fires even if nobody ever
|
||||||
|
# connects.
|
||||||
|
await platform.scheduler.sync_deadline(session)
|
||||||
|
seat = next(i for i, s in enumerate(session.players) if s.user_sub == user.sub)
|
||||||
|
if engine.in_lobby(session):
|
||||||
|
log.info(
|
||||||
|
"%s joined game %s (seat %d, %d/%d players)",
|
||||||
|
user.sub, session.id, seat, len(session.players), engine.max_players,
|
||||||
|
)
|
||||||
|
await send_json(ctx, 200, _lobby_payload(platform, session))
|
||||||
|
return
|
||||||
|
log.info("%s joined game %s (seat %d); match started", user.sub, session.id, seat)
|
||||||
|
await send_json(ctx, 200, _view_payload(platform, session, user.sub))
|
||||||
|
|
||||||
|
@app.GET("/api/games/preview")
|
||||||
|
@operation(summary="Preview a game by join code",
|
||||||
|
description="Looks up a game by its join code without "
|
||||||
|
"joining, for the lobby's pre-join summary: game "
|
||||||
|
"name and description, seated players, open "
|
||||||
|
"seats and the game-specific options echoed by "
|
||||||
|
"its engine.",
|
||||||
|
tags=["games"],
|
||||||
|
responses={
|
||||||
|
200: {"description": "The lobby preview"},
|
||||||
|
400: {"description": "Missing code"},
|
||||||
|
401: {"description": "Authentication required"},
|
||||||
|
404: {"description": "Unknown join code"},
|
||||||
|
})
|
||||||
|
@require_auth(platform.oidc)
|
||||||
|
async def preview_game(ctx: HttpContext) -> None:
|
||||||
|
values = extract_query_params(ctx.query_string).get("code")
|
||||||
|
code = values[0].strip() if values else ""
|
||||||
|
if not code:
|
||||||
|
await send_error(ctx, 400, "code is required")
|
||||||
|
return
|
||||||
|
session = await platform.game_store.find_by_code(code)
|
||||||
|
if session is None:
|
||||||
|
await send_error(ctx, 404, "unknown join code")
|
||||||
|
return
|
||||||
|
await send_json(ctx, 200, _preview_payload(platform, session))
|
||||||
|
|
||||||
|
@app.GET("/api/games/${game_id}")
|
||||||
|
@operation(summary="Get a game snapshot",
|
||||||
|
description="Anyone who knows the game id may read a game. "
|
||||||
|
"Seated players get the personalized view (their "
|
||||||
|
"own hand plus the join code); everyone else — "
|
||||||
|
"anonymous users and authenticated non-players — "
|
||||||
|
"gets the read-only spectator view: no hands, no "
|
||||||
|
"join code, just what is public at the table.",
|
||||||
|
tags=["games"],
|
||||||
|
responses={
|
||||||
|
200: {"description": "The personalized (player) or "
|
||||||
|
"read-only (spectator) game state"},
|
||||||
|
404: {"description": "Game not found"},
|
||||||
|
})
|
||||||
|
async def get_game(ctx: HttpContext, game_id: str) -> None:
|
||||||
|
session = await platform.game_store.load(game_id)
|
||||||
|
if session is None:
|
||||||
|
await send_error(ctx, 404, "game not found")
|
||||||
|
return
|
||||||
|
user = current_user(platform.oidc, ctx)
|
||||||
|
await send_json(
|
||||||
|
ctx, 200, _view_payload(platform, session, user.sub if user else None)
|
||||||
|
)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""Liveness probe."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from kaya.core import HttpContext, KayaApp
|
||||||
|
from kaya.openapi import operation
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ..mixin import Platform
|
||||||
|
|
||||||
|
|
||||||
|
def register(app: KayaApp, platform: "Platform") -> None:
|
||||||
|
@app.GET("/api/health")
|
||||||
|
@operation(summary="Health check",
|
||||||
|
tags=["health"],
|
||||||
|
responses={200: {"description": "The service is up"}})
|
||||||
|
async def health(ctx: HttpContext) -> None:
|
||||||
|
await ctx.send_bytes(
|
||||||
|
200,
|
||||||
|
b'{"status":"ok"}',
|
||||||
|
{"content-type": ("application/json",)},
|
||||||
|
)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Whoami endpoint: lets the single-page app detect the login state."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from kaya.core import HttpContext, KayaApp
|
||||||
|
from kaya.openapi import operation
|
||||||
|
|
||||||
|
from ..auth import current_user, display_name, require_auth
|
||||||
|
from ..http import send_json
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ..mixin import Platform
|
||||||
|
|
||||||
|
|
||||||
|
def register(app: KayaApp, platform: "Platform") -> None:
|
||||||
|
@app.GET("/api/me")
|
||||||
|
@operation(summary="Current user",
|
||||||
|
description="Returns the authenticated user's identity from the "
|
||||||
|
"session; 401 when not logged in.",
|
||||||
|
tags=["auth"],
|
||||||
|
responses={
|
||||||
|
200: {"description": "The current user"},
|
||||||
|
401: {"description": "Not logged in"},
|
||||||
|
})
|
||||||
|
@require_auth(platform.oidc)
|
||||||
|
async def me(ctx: HttpContext) -> None:
|
||||||
|
user = current_user(platform.oidc, ctx)
|
||||||
|
assert user is not None # enforced by @require_auth
|
||||||
|
await send_json(ctx, 200, {"sub": user.sub, "name": display_name(user)})
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
"""Player statistics endpoints, served from Postgres.
|
||||||
|
|
||||||
|
Every finished match is persisted by
|
||||||
|
:func:`tavolo.platform.stats.save_match_result`. These endpoints expose a
|
||||||
|
player's own match history and a global leaderboard aggregated from the
|
||||||
|
same two tables. The game-specific outcome of each match is exposed
|
||||||
|
verbatim through the ``result`` JSON column.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from kaya.core import HttpContext, KayaApp
|
||||||
|
from kaya.openapi import operation
|
||||||
|
|
||||||
|
from ..auth import current_user, require_auth
|
||||||
|
from ..elo import INITIAL_RATING
|
||||||
|
from ..http import extract_query_params, send_error, send_json
|
||||||
|
from ..models import Match, MatchPlayer, PlayerRating
|
||||||
|
from ..openapi import PAGINATION_PARAMETERS
|
||||||
|
from ..pagination import CursorDecodeError, paginate, parse_cursor_params
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ..mixin import Platform
|
||||||
|
|
||||||
|
GAME_TYPE_PARAMETER: Dict[str, Any] = {
|
||||||
|
"name": "game_type",
|
||||||
|
"in": "query",
|
||||||
|
"required": False,
|
||||||
|
"schema": {"type": "string"},
|
||||||
|
"description": "Only count matches of this game (id from GET /api/game-types).",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_game_type(
|
||||||
|
platform: "Platform", query_string: str
|
||||||
|
) -> Tuple[Optional[str], Optional[str]]:
|
||||||
|
"""Parse the ``game_type`` query parameter.
|
||||||
|
|
||||||
|
Returns ``(value, error)``: ``(None, None)`` when absent, ``(id, None)``
|
||||||
|
when valid, ``(None, message)`` when it names no registered game."""
|
||||||
|
values = extract_query_params(query_string).get("game_type")
|
||||||
|
if not values:
|
||||||
|
return None, None
|
||||||
|
game_type = values[0]
|
||||||
|
if platform.registry.get(game_type) is None:
|
||||||
|
return None, f"unknown game_type: {game_type!r}"
|
||||||
|
return game_type, None
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_match(
|
||||||
|
match: Match, viewer: str, participants: List[MatchPlayer]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": str(match.id),
|
||||||
|
"game_type": match.game_type,
|
||||||
|
# The engine's MatchResult.summary: for scopone, the teams' final
|
||||||
|
# scores, the winner, the target score and the per-hand audit.
|
||||||
|
"result": match.result,
|
||||||
|
"started_at": match.started_at.isoformat(),
|
||||||
|
"finished_at": match.finished_at.isoformat(),
|
||||||
|
"you_won": any(p.user_sub == viewer and p.won for p in participants),
|
||||||
|
"your_elo_delta": next(
|
||||||
|
(p.elo_delta for p in participants if p.user_sub == viewer), None
|
||||||
|
),
|
||||||
|
"players": [
|
||||||
|
{
|
||||||
|
"user_sub": p.user_sub,
|
||||||
|
"display_name": p.display_name,
|
||||||
|
"seat": p.seat,
|
||||||
|
"team": p.team,
|
||||||
|
"won": p.won,
|
||||||
|
"score": p.score,
|
||||||
|
"elo_delta": p.elo_delta,
|
||||||
|
"details": p.details,
|
||||||
|
}
|
||||||
|
for p in participants
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def register(app: KayaApp, platform: "Platform") -> None:
|
||||||
|
@app.GET("/api/me/matches")
|
||||||
|
@operation(summary="List my matches",
|
||||||
|
description="Cursor-paginated history of finished matches the "
|
||||||
|
"caller played, newest first, with the result.",
|
||||||
|
tags=["stats"],
|
||||||
|
parameters=[*PAGINATION_PARAMETERS, GAME_TYPE_PARAMETER],
|
||||||
|
responses={
|
||||||
|
200: {"description": "A page of matches"},
|
||||||
|
400: {"description": "Invalid pagination cursor or game_type"},
|
||||||
|
401: {"description": "Authentication required"},
|
||||||
|
})
|
||||||
|
@require_auth(platform.oidc)
|
||||||
|
async def my_matches(ctx: HttpContext) -> None:
|
||||||
|
try:
|
||||||
|
cursor = parse_cursor_params(ctx.query_string)
|
||||||
|
except CursorDecodeError as exc:
|
||||||
|
await send_error(ctx, 400, str(exc))
|
||||||
|
return
|
||||||
|
game_type, error = _parse_game_type(platform, ctx.query_string)
|
||||||
|
if error is not None:
|
||||||
|
await send_error(ctx, 400, error)
|
||||||
|
return
|
||||||
|
user = current_user(platform.oidc, ctx)
|
||||||
|
assert user is not None
|
||||||
|
queryset = Match.filter(players__user_sub=user.sub).distinct()
|
||||||
|
if game_type is not None:
|
||||||
|
queryset = queryset.filter(game_type=game_type)
|
||||||
|
matches, next_cursor = await paginate(
|
||||||
|
queryset, [("finished_at", "DESC"), ("id", "ASC")], cursor
|
||||||
|
)
|
||||||
|
# One participants query for the whole page instead of one per
|
||||||
|
# match; the grouping keeps each match's players seat-ordered.
|
||||||
|
by_match: Dict[UUID, List[MatchPlayer]] = defaultdict(list)
|
||||||
|
if matches:
|
||||||
|
participants = (
|
||||||
|
await MatchPlayer.filter(match_id__in=[m.id for m in matches])
|
||||||
|
.order_by("match_id", "seat")
|
||||||
|
)
|
||||||
|
for player in participants:
|
||||||
|
# Tortoise sets the FK's raw id on instances but does not
|
||||||
|
# declare it on the model class, hence the getattr.
|
||||||
|
by_match[getattr(player, "match_id")].append(player)
|
||||||
|
results = [
|
||||||
|
_serialize_match(m, user.sub, by_match.get(m.id, [])) for m in matches
|
||||||
|
]
|
||||||
|
await send_json(ctx, 200, {"results": results, "next_cursor": next_cursor})
|
||||||
|
|
||||||
|
@app.GET("/api/leaderboard")
|
||||||
|
@operation(summary="Global leaderboard",
|
||||||
|
description="Elo rating, aggregated wins, matches played and "
|
||||||
|
"points for every player with at least one "
|
||||||
|
"finished match. Sorted by Elo rating (the rating "
|
||||||
|
"for the requested game_type, or the default game "
|
||||||
|
"when the filter is absent).",
|
||||||
|
tags=["stats"],
|
||||||
|
parameters=[GAME_TYPE_PARAMETER],
|
||||||
|
responses={
|
||||||
|
200: {"description": "The leaderboard"},
|
||||||
|
400: {"description": "Unknown game_type"},
|
||||||
|
})
|
||||||
|
async def leaderboard(ctx: HttpContext) -> None:
|
||||||
|
game_type, error = _parse_game_type(platform, ctx.query_string)
|
||||||
|
if error is not None:
|
||||||
|
await send_error(ctx, 400, error)
|
||||||
|
return
|
||||||
|
queryset = MatchPlayer.all()
|
||||||
|
if game_type is not None:
|
||||||
|
queryset = queryset.filter(match__game_type=game_type)
|
||||||
|
rows = await queryset
|
||||||
|
# Ratings are per game type; without a filter show the default
|
||||||
|
# game's.
|
||||||
|
rating_game = game_type or platform.registry.default.id
|
||||||
|
rating_rows = await PlayerRating.filter(game_type=rating_game)
|
||||||
|
ratings = {row.user_sub: row.rating for row in rating_rows}
|
||||||
|
aggregate: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for row in rows:
|
||||||
|
entry = aggregate.setdefault(
|
||||||
|
row.user_sub,
|
||||||
|
{
|
||||||
|
"user_sub": row.user_sub,
|
||||||
|
"display_name": row.display_name,
|
||||||
|
"matches": 0,
|
||||||
|
"wins": 0,
|
||||||
|
"points": 0,
|
||||||
|
"elo": ratings.get(row.user_sub, INITIAL_RATING),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
entry["matches"] += 1
|
||||||
|
entry["wins"] += 1 if row.won else 0
|
||||||
|
entry["points"] += row.score
|
||||||
|
# Keep the most recent display name seen.
|
||||||
|
entry["display_name"] = row.display_name
|
||||||
|
|
||||||
|
ranking: List[Dict[str, Any]] = sorted(
|
||||||
|
aggregate.values(),
|
||||||
|
key=lambda e: (e["elo"], e["wins"], e["points"], -e["matches"]),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
await send_json(ctx, 200, {"results": ranking})
|
||||||
|
|
||||||
|
@app.GET("/api/me/ratings")
|
||||||
|
@operation(summary="My Elo ratings",
|
||||||
|
description="The caller's current Elo rating for every game "
|
||||||
|
"type they have played.",
|
||||||
|
tags=["stats"],
|
||||||
|
responses={
|
||||||
|
200: {"description": "The caller's ratings"},
|
||||||
|
401: {"description": "Authentication required"},
|
||||||
|
})
|
||||||
|
@require_auth(platform.oidc)
|
||||||
|
async def my_ratings(ctx: HttpContext) -> None:
|
||||||
|
user = current_user(platform.oidc, ctx)
|
||||||
|
assert user is not None
|
||||||
|
rows = await PlayerRating.filter(user_sub=user.sub).order_by("game_type")
|
||||||
|
await send_json(ctx, 200, {
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"game_type": row.game_type,
|
||||||
|
"rating": row.rating,
|
||||||
|
"matches_played": row.matches_played,
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
})
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""Copy finished match results from the live store into Postgres.
|
||||||
|
|
||||||
|
Called once when a session reaches the finished state (guarded by the
|
||||||
|
``stats_saved`` flag on the session). The write is transactional so a
|
||||||
|
match never appears with only some of its players. The same transaction
|
||||||
|
also updates the participants' Elo ratings (see
|
||||||
|
:mod:`tavolo.platform.elo`).
|
||||||
|
|
||||||
|
This module is game-independent: everything it persists comes from the
|
||||||
|
engine's :class:`~tavolo.platform.engine.MatchResult`.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from logging import getLogger
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
from tortoise.transactions import in_transaction
|
||||||
|
|
||||||
|
from .elo import match_delta
|
||||||
|
from .engine import GameEngine, GameSession
|
||||||
|
|
||||||
|
log = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_elo(
|
||||||
|
game_type: str, teams: List[List[str]], winner_team: int
|
||||||
|
) -> Dict[str, int]:
|
||||||
|
"""Update the Elo ratings of ``teams`` for ``game_type``.
|
||||||
|
|
||||||
|
``teams`` groups the participants' subs into the two opposing sides;
|
||||||
|
``winner_team`` is the index of the winning side. Ratings are read
|
||||||
|
from (and written back to) the ``player_rating`` table; unrated
|
||||||
|
players start at the initial rating. Returns the per-player delta.
|
||||||
|
Must be called inside a transaction.
|
||||||
|
"""
|
||||||
|
if len(teams) != 2:
|
||||||
|
raise ValueError("Elo rating requires exactly two teams")
|
||||||
|
from .models import PlayerRating
|
||||||
|
|
||||||
|
ratings: Dict[str, "PlayerRating"] = {}
|
||||||
|
for subs in teams:
|
||||||
|
for sub in subs:
|
||||||
|
rating = await PlayerRating.get_or_none(
|
||||||
|
user_sub=sub, game_type=game_type
|
||||||
|
)
|
||||||
|
if rating is None:
|
||||||
|
rating = await PlayerRating.create(
|
||||||
|
id=uuid.uuid4(), user_sub=sub, game_type=game_type
|
||||||
|
)
|
||||||
|
ratings[sub] = rating
|
||||||
|
delta_a = match_delta(
|
||||||
|
[ratings[sub].rating for sub in teams[0]],
|
||||||
|
[ratings[sub].rating for sub in teams[1]],
|
||||||
|
winner_team,
|
||||||
|
)
|
||||||
|
deltas: Dict[str, int] = {
|
||||||
|
**{sub: delta_a for sub in teams[0]},
|
||||||
|
**{sub: -delta_a for sub in teams[1]},
|
||||||
|
}
|
||||||
|
for sub, delta in deltas.items():
|
||||||
|
rating = ratings[sub]
|
||||||
|
rating.rating += delta
|
||||||
|
rating.matches_played += 1
|
||||||
|
await rating.save()
|
||||||
|
return deltas
|
||||||
|
|
||||||
|
|
||||||
|
async def save_match_result(session: GameSession, engine: GameEngine) -> None:
|
||||||
|
"""Persist ``session`` to Postgres if it is finished and not yet saved."""
|
||||||
|
if session.stats_saved or not engine.is_finished(session):
|
||||||
|
return
|
||||||
|
result = engine.result(session)
|
||||||
|
|
||||||
|
from .models import Match, MatchPlayer
|
||||||
|
|
||||||
|
started_at = session.created_at or datetime.now(timezone.utc)
|
||||||
|
finished_at = session.finished_at or datetime.now(timezone.utc)
|
||||||
|
display_names = {seat.user_sub: seat.display_name for seat in session.players}
|
||||||
|
async with in_transaction():
|
||||||
|
match = await Match.create(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
game_type=session.game_type,
|
||||||
|
started_at=started_at,
|
||||||
|
finished_at=finished_at,
|
||||||
|
result=result.summary,
|
||||||
|
)
|
||||||
|
deltas = await apply_elo(session.game_type, result.teams, result.winner_team)
|
||||||
|
for player in result.players:
|
||||||
|
await MatchPlayer.create(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
match=match,
|
||||||
|
user_sub=player.user_sub,
|
||||||
|
display_name=display_names.get(player.user_sub, player.user_sub),
|
||||||
|
seat=player.seat,
|
||||||
|
team=player.team,
|
||||||
|
won=player.won,
|
||||||
|
score=player.score,
|
||||||
|
elo_delta=deltas[player.user_sub],
|
||||||
|
details=player.details,
|
||||||
|
)
|
||||||
|
session.stats_saved = True
|
||||||
|
log.info(
|
||||||
|
"match result persisted: game %s (%s), team %d of %d won",
|
||||||
|
session.id,
|
||||||
|
session.game_type,
|
||||||
|
result.winner_team,
|
||||||
|
len(result.teams),
|
||||||
|
)
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
"""Persistence for live games.
|
||||||
|
|
||||||
|
Game sessions are small, mutable and short-lived, which makes Redis a
|
||||||
|
natural fit: the whole match is a single JSON value under
|
||||||
|
``tavolo:game:<id>`` with a sliding TTL, and a join-code index maps the
|
||||||
|
short code a player shares to that id. Completed matches are copied to
|
||||||
|
Postgres (see :mod:`tavolo.platform.models`); Redis keeps serving the
|
||||||
|
finished session until it expires.
|
||||||
|
|
||||||
|
Two implementations satisfy the same interface:
|
||||||
|
|
||||||
|
* :class:`RedisGameStore` — production, used when ``REDIS_URL`` is set.
|
||||||
|
* :class:`InMemoryGameStore` — tests and ephemeral dev, used otherwise.
|
||||||
|
|
||||||
|
The store is game-agnostic: a session is serialized as a platform-owned
|
||||||
|
envelope (id, join code, seats, timestamps) plus an opaque ``state``
|
||||||
|
blob produced by the game's engine (resolved through the
|
||||||
|
:class:`~tavolo.platform.registry.GameRegistry` both stores are
|
||||||
|
constructed with).
|
||||||
|
|
||||||
|
Concurrency is handled with a per-game lock so two simultaneous actions
|
||||||
|
cannot interleave. State changes are broadcast on a per-game pub/sub
|
||||||
|
channel as a simple "something changed" signal; every open websocket
|
||||||
|
reloads the session and renders the personalized view. Publishing only a
|
||||||
|
signal (never the state) means updated state reaches connections on
|
||||||
|
every worker without leaking hidden information into the channel.
|
||||||
|
|
||||||
|
Timeouts are driven by a shared delayed-deadline queue: producers
|
||||||
|
enqueue an opaque ``member`` string with a due timestamp and announce it
|
||||||
|
on a shared "deadline enqueued" channel, and a consumer on every worker
|
||||||
|
wakes on that signal to process due entries (see
|
||||||
|
:mod:`tavolo.platform.deadlines`). Delivery is at-least-once — entries
|
||||||
|
are removed only after they are processed — so a worker dying
|
||||||
|
mid-processing cannot lose a deadline; engines revalidate entries
|
||||||
|
against the live state, which makes duplicate deliveries harmless.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from datetime import datetime
|
||||||
|
from logging import getLogger
|
||||||
|
from typing import Any, AsyncContextManager, AsyncIterator, Dict, List, Mapping, Optional, Set, cast
|
||||||
|
|
||||||
|
from redis.asyncio import Redis
|
||||||
|
|
||||||
|
from .engine import GameSession, Seat
|
||||||
|
from .registry import GameRegistry
|
||||||
|
|
||||||
|
log = getLogger(__name__)
|
||||||
|
|
||||||
|
GAME_KEY_PREFIX = "tavolo:game:"
|
||||||
|
CODE_KEY_PREFIX = "tavolo:code:"
|
||||||
|
CHANNEL_PREFIX = "tavolo:game:"
|
||||||
|
DEADLINES_KEY = "tavolo:deadlines"
|
||||||
|
# Channel announcing newly enqueued deadline entries, so every worker's
|
||||||
|
# consumer can wake immediately instead of polling on its heartbeat.
|
||||||
|
DEADLINES_CHANNEL = "tavolo:deadlines:events"
|
||||||
|
# Sorted set of running games (score = expiry timestamp), maintained by the
|
||||||
|
# stores so the telemetry gauge can count ongoing matches without loading
|
||||||
|
# every session. Finished games are removed on their final save.
|
||||||
|
ONGOING_GAMES_KEY = "tavolo:games:ongoing"
|
||||||
|
|
||||||
|
# Sentinel pushed into in-memory subscriber queues to signal a change.
|
||||||
|
_BUMP = b"update"
|
||||||
|
|
||||||
|
|
||||||
|
def _dt_to_json(value: Optional[datetime]) -> Optional[str]:
|
||||||
|
return value.isoformat() if value is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _dt_from_json(value: Any) -> Optional[datetime]:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(str(value))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def session_to_json(session: GameSession, registry: GameRegistry) -> Dict[str, Any]:
|
||||||
|
"""Serialize a session: platform envelope plus the engine's state blob."""
|
||||||
|
engine = registry.require(session.game_type)
|
||||||
|
return {
|
||||||
|
"id": session.id,
|
||||||
|
"game_type": session.game_type,
|
||||||
|
"join_code": session.join_code,
|
||||||
|
"creator_sub": session.creator_sub,
|
||||||
|
"players": [seat.to_json() for seat in session.players],
|
||||||
|
"created_at": _dt_to_json(session.created_at),
|
||||||
|
"finished_at": _dt_to_json(session.finished_at),
|
||||||
|
"stats_saved": session.stats_saved,
|
||||||
|
"state": engine.state_to_json(session.state),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def session_from_json(data: Mapping[str, Any], registry: GameRegistry) -> GameSession:
|
||||||
|
"""Rebuild a session, delegating the state blob to its game engine."""
|
||||||
|
game_type = str(data["game_type"])
|
||||||
|
engine = registry.require(game_type)
|
||||||
|
return GameSession(
|
||||||
|
id=str(data["id"]),
|
||||||
|
game_type=game_type,
|
||||||
|
join_code=str(data["join_code"]),
|
||||||
|
creator_sub=str(data.get("creator_sub", "")),
|
||||||
|
players=[Seat.from_json(p) for p in data.get("players", [])],
|
||||||
|
created_at=_dt_from_json(data.get("created_at")),
|
||||||
|
finished_at=_dt_from_json(data.get("finished_at")),
|
||||||
|
stats_saved=bool(data.get("stats_saved", False)),
|
||||||
|
state=engine.state_from_json(data.get("state") or {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GameStore(ABC):
|
||||||
|
"""Abstract persistence + notification layer for live games."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def load(self, game_id: str) -> Optional[GameSession]:
|
||||||
|
"""Return the live session for ``game_id`` or ``None``."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def save(self, session: GameSession) -> None:
|
||||||
|
"""Persist ``session``, refreshing its TTL and code index."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def find_by_code(self, code: str) -> Optional[GameSession]:
|
||||||
|
"""Return the live session for a join ``code`` or ``None``."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def count_ongoing(self) -> int:
|
||||||
|
"""Return the number of live games that have not finished yet.
|
||||||
|
|
||||||
|
Lobbies and matches in progress count; matches that are over but
|
||||||
|
still retained for the final-board view do not.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def lock(self, game_id: str) -> AsyncContextManager[None]:
|
||||||
|
"""Async context manager serializing mutations of one game."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def subscribe(self, game_id: str) -> AsyncContextManager[AsyncIterator[None]]:
|
||||||
|
"""Async context manager yielding an async iterator of change signals."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def publish(self, game_id: str) -> None:
|
||||||
|
"""Signal that the session of ``game_id`` changed."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def add_deadline(self, member: str, due_at: float) -> None:
|
||||||
|
"""Enqueue ``member`` to fire at ``due_at`` (epoch seconds).
|
||||||
|
|
||||||
|
Idempotent for identical members: re-adding an existing member only
|
||||||
|
updates its due time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def due_deadlines(self, now: float, limit: int = 32) -> List[str]:
|
||||||
|
"""Return up to ``limit`` enqueued members due at or before ``now``."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def next_deadline(self) -> Optional[float]:
|
||||||
|
"""Return the earliest pending due time (epoch seconds), if any."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def remove_deadline(self, member: str) -> None:
|
||||||
|
"""Remove ``member`` from the queue; a no-op when absent."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def notify_deadline(self) -> None:
|
||||||
|
"""Wake every worker's deadline consumer (best effort).
|
||||||
|
|
||||||
|
Notification is advisory: consumers stay correct when a signal
|
||||||
|
is lost, because their poll backstop recovers it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def subscribe_deadlines(self) -> AsyncContextManager[AsyncIterator[None]]:
|
||||||
|
"""Async context manager yielding deadline-enqueue signals."""
|
||||||
|
|
||||||
|
|
||||||
|
def _channel(game_id: str) -> str:
|
||||||
|
return f"{CHANNEL_PREFIX}{game_id}:events"
|
||||||
|
|
||||||
|
|
||||||
|
class RedisGameStore(GameStore):
|
||||||
|
def __init__(self, redis: Redis, registry: GameRegistry, ttl_seconds: int = 86400) -> None:
|
||||||
|
self._redis = redis
|
||||||
|
self._registry = registry
|
||||||
|
self._ttl = ttl_seconds
|
||||||
|
|
||||||
|
def lock(self, game_id: str):
|
||||||
|
# Lock and state use distinct key names; the lock expires on its own
|
||||||
|
# if a worker dies mid-mutation.
|
||||||
|
return self._redis.lock(f"{GAME_KEY_PREFIX}{game_id}:lock",
|
||||||
|
timeout=10, blocking_timeout=10)
|
||||||
|
|
||||||
|
async def load(self, game_id: str) -> Optional[GameSession]:
|
||||||
|
raw = await self._redis.get(f"{GAME_KEY_PREFIX}{game_id}")
|
||||||
|
if raw is None:
|
||||||
|
log.debug("redis load %s: miss", game_id)
|
||||||
|
return None
|
||||||
|
if isinstance(raw, bytes):
|
||||||
|
raw = raw.decode("utf-8")
|
||||||
|
log.debug("redis load %s: hit", game_id)
|
||||||
|
return session_from_json(json.loads(raw), self._registry)
|
||||||
|
|
||||||
|
async def save(self, session: GameSession) -> None:
|
||||||
|
payload = json.dumps(session_to_json(session, self._registry))
|
||||||
|
engine = self._registry.require(session.game_type)
|
||||||
|
async with self._redis.pipeline(transaction=True) as pipe:
|
||||||
|
pipe.set(f"{GAME_KEY_PREFIX}{session.id}", payload, ex=self._ttl)
|
||||||
|
pipe.set(f"{CODE_KEY_PREFIX}{session.join_code}", session.id, ex=self._ttl)
|
||||||
|
if engine.is_finished(session):
|
||||||
|
pipe.zrem(ONGOING_GAMES_KEY, session.id)
|
||||||
|
else:
|
||||||
|
pipe.zadd(ONGOING_GAMES_KEY, {session.id: time.time() + self._ttl})
|
||||||
|
pipe.expire(ONGOING_GAMES_KEY, self._ttl)
|
||||||
|
await pipe.execute()
|
||||||
|
log.debug("redis save %s (game %s, ttl %ds)", session.id, session.game_type, self._ttl)
|
||||||
|
|
||||||
|
async def find_by_code(self, code: str) -> Optional[GameSession]:
|
||||||
|
game_id = await self._redis.get(f"{CODE_KEY_PREFIX}{code.upper()}")
|
||||||
|
if game_id is None:
|
||||||
|
return None
|
||||||
|
if isinstance(game_id, bytes):
|
||||||
|
game_id = game_id.decode("utf-8")
|
||||||
|
return await self.load(str(game_id))
|
||||||
|
|
||||||
|
async def count_ongoing(self) -> int:
|
||||||
|
# Prune entries whose game has expired since the last poll, then
|
||||||
|
# count what is left. Both commands run in one transaction so the
|
||||||
|
# count cannot see a half-pruned set.
|
||||||
|
now = time.time()
|
||||||
|
async with self._redis.pipeline(transaction=True) as pipe:
|
||||||
|
pipe.zremrangebyscore(ONGOING_GAMES_KEY, "-inf", now)
|
||||||
|
pipe.zcard(ONGOING_GAMES_KEY)
|
||||||
|
_, count = await pipe.execute()
|
||||||
|
return int(count)
|
||||||
|
|
||||||
|
@contextlib.asynccontextmanager
|
||||||
|
async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]:
|
||||||
|
pubsub = self._redis.pubsub()
|
||||||
|
await pubsub.subscribe(_channel(game_id))
|
||||||
|
try:
|
||||||
|
yield _redis_events(pubsub)
|
||||||
|
finally:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await pubsub.unsubscribe(_channel(game_id))
|
||||||
|
await pubsub.aclose()
|
||||||
|
|
||||||
|
async def publish(self, game_id: str) -> None:
|
||||||
|
await self._redis.publish(_channel(game_id), "update")
|
||||||
|
log.debug("redis publish %s", game_id)
|
||||||
|
|
||||||
|
async def add_deadline(self, member: str, due_at: float) -> None:
|
||||||
|
await self._redis.zadd(DEADLINES_KEY, {member: due_at})
|
||||||
|
|
||||||
|
async def due_deadlines(self, now: float, limit: int = 32) -> List[str]:
|
||||||
|
# ``ZRANGE ... BYSCORE`` is the non-deprecated spelling of the
|
||||||
|
# old ``ZRANGEBYSCORE``.
|
||||||
|
members = cast(
|
||||||
|
list,
|
||||||
|
await self._redis.zrange(
|
||||||
|
DEADLINES_KEY,
|
||||||
|
"-inf",
|
||||||
|
now,
|
||||||
|
byscore=True,
|
||||||
|
offset=0,
|
||||||
|
num=limit,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return [m.decode("utf-8") if isinstance(m, bytes) else m for m in members]
|
||||||
|
|
||||||
|
async def next_deadline(self) -> Optional[float]:
|
||||||
|
earliest = await self._redis.zrange(DEADLINES_KEY, 0, 0, withscores=True)
|
||||||
|
return float(earliest[0][1]) if earliest else None
|
||||||
|
|
||||||
|
async def remove_deadline(self, member: str) -> None:
|
||||||
|
await self._redis.zrem(DEADLINES_KEY, member)
|
||||||
|
|
||||||
|
async def notify_deadline(self) -> None:
|
||||||
|
await self._redis.publish(DEADLINES_CHANNEL, "deadline")
|
||||||
|
|
||||||
|
@contextlib.asynccontextmanager
|
||||||
|
async def subscribe_deadlines(self) -> AsyncIterator[AsyncIterator[None]]:
|
||||||
|
pubsub = self._redis.pubsub()
|
||||||
|
await pubsub.subscribe(DEADLINES_CHANNEL)
|
||||||
|
try:
|
||||||
|
yield _redis_events(pubsub)
|
||||||
|
finally:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await pubsub.unsubscribe(DEADLINES_CHANNEL)
|
||||||
|
await pubsub.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def _redis_events(pubsub) -> AsyncIterator[None]:
|
||||||
|
async for message in pubsub.listen():
|
||||||
|
if message.get("type") == "message":
|
||||||
|
yield None
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryGameStore(GameStore):
|
||||||
|
"""Process-local store used by tests and when Redis is not configured."""
|
||||||
|
|
||||||
|
def __init__(self, registry: GameRegistry) -> None:
|
||||||
|
self._registry = registry
|
||||||
|
self._games: Dict[str, str] = {}
|
||||||
|
self._codes: Dict[str, str] = {}
|
||||||
|
self._ongoing: Set[str] = set()
|
||||||
|
self._locks: Dict[str, asyncio.Lock] = {}
|
||||||
|
self._subscribers: Dict[str, Set[asyncio.Queue]] = {}
|
||||||
|
self._deadlines: Dict[str, float] = {}
|
||||||
|
self._deadline_subscribers: Set[asyncio.Queue] = set()
|
||||||
|
|
||||||
|
def _lock_for(self, game_id: str) -> asyncio.Lock:
|
||||||
|
lock = self._locks.get(game_id)
|
||||||
|
if lock is None:
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
self._locks[game_id] = lock
|
||||||
|
return lock
|
||||||
|
|
||||||
|
@contextlib.asynccontextmanager
|
||||||
|
async def lock(self, game_id: str) -> AsyncIterator[None]:
|
||||||
|
async with self._lock_for(game_id):
|
||||||
|
yield
|
||||||
|
|
||||||
|
async def load(self, game_id: str) -> Optional[GameSession]:
|
||||||
|
raw = self._games.get(game_id)
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
return session_from_json(json.loads(raw), self._registry)
|
||||||
|
|
||||||
|
async def save(self, session: GameSession) -> None:
|
||||||
|
self._games[session.id] = json.dumps(session_to_json(session, self._registry))
|
||||||
|
self._codes[session.join_code] = session.id
|
||||||
|
engine = self._registry.require(session.game_type)
|
||||||
|
if engine.is_finished(session):
|
||||||
|
self._ongoing.discard(session.id)
|
||||||
|
else:
|
||||||
|
self._ongoing.add(session.id)
|
||||||
|
|
||||||
|
async def find_by_code(self, code: str) -> Optional[GameSession]:
|
||||||
|
game_id = self._codes.get(code.upper())
|
||||||
|
if game_id is None:
|
||||||
|
return None
|
||||||
|
return await self.load(game_id)
|
||||||
|
|
||||||
|
async def count_ongoing(self) -> int:
|
||||||
|
return len(self._ongoing)
|
||||||
|
|
||||||
|
@contextlib.asynccontextmanager
|
||||||
|
async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]:
|
||||||
|
queue: asyncio.Queue = asyncio.Queue()
|
||||||
|
self._subscribers.setdefault(game_id, set()).add(queue)
|
||||||
|
try:
|
||||||
|
yield _queue_events(queue)
|
||||||
|
finally:
|
||||||
|
subscribers = self._subscribers.get(game_id)
|
||||||
|
if subscribers is not None:
|
||||||
|
subscribers.discard(queue)
|
||||||
|
if not subscribers:
|
||||||
|
self._subscribers.pop(game_id, None)
|
||||||
|
|
||||||
|
async def publish(self, game_id: str) -> None:
|
||||||
|
for queue in list(self._subscribers.get(game_id, ())):
|
||||||
|
queue.put_nowait(_BUMP)
|
||||||
|
|
||||||
|
async def add_deadline(self, member: str, due_at: float) -> None:
|
||||||
|
self._deadlines[member] = due_at
|
||||||
|
|
||||||
|
async def due_deadlines(self, now: float, limit: int = 32) -> List[str]:
|
||||||
|
due = [m for m, due_at in self._deadlines.items() if due_at <= now]
|
||||||
|
due.sort(key=self._deadlines.__getitem__)
|
||||||
|
return due[:limit]
|
||||||
|
|
||||||
|
async def next_deadline(self) -> Optional[float]:
|
||||||
|
return min(self._deadlines.values(), default=None)
|
||||||
|
|
||||||
|
async def remove_deadline(self, member: str) -> None:
|
||||||
|
self._deadlines.pop(member, None)
|
||||||
|
|
||||||
|
async def notify_deadline(self) -> None:
|
||||||
|
for queue in list(self._deadline_subscribers):
|
||||||
|
queue.put_nowait(_BUMP)
|
||||||
|
|
||||||
|
@contextlib.asynccontextmanager
|
||||||
|
async def subscribe_deadlines(self) -> AsyncIterator[AsyncIterator[None]]:
|
||||||
|
queue: asyncio.Queue = asyncio.Queue()
|
||||||
|
self._deadline_subscribers.add(queue)
|
||||||
|
try:
|
||||||
|
yield _queue_events(queue)
|
||||||
|
finally:
|
||||||
|
self._deadline_subscribers.discard(queue)
|
||||||
|
|
||||||
|
|
||||||
|
async def _queue_events(queue: asyncio.Queue) -> AsyncIterator[None]:
|
||||||
|
while True:
|
||||||
|
await queue.get()
|
||||||
|
yield None
|
||||||
+30
@@ -19,6 +19,9 @@ This mixin therefore:
|
|||||||
(``before_websocket`` hook), because the match-result write happens at
|
(``before_websocket`` hook), because the match-result write happens at
|
||||||
the end of a WebSocket match. The long-lived connection task keeps the
|
the end of a WebSocket match. The long-lived connection task keeps the
|
||||||
context for its whole lifetime.
|
context for its whole lifetime.
|
||||||
|
4. Exposes :meth:`ensure_context` for background work that belongs to no
|
||||||
|
request: the deadline scheduler calls it before persisting a match that
|
||||||
|
a timeout finished, binding the context to its own long-lived task.
|
||||||
|
|
||||||
It deliberately avoids the global-fallback singleton
|
It deliberately avoids the global-fallback singleton
|
||||||
(``_enable_global_fallback``), which Tortoise only allows to be set once
|
(``_enable_global_fallback``), which Tortoise only allows to be set once
|
||||||
@@ -83,6 +86,22 @@ class TortoiseMixin(KayaMixin):
|
|||||||
self._ctx = None
|
self._ctx = None
|
||||||
self._init_loop = None
|
self._init_loop = None
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
"""Close the current context's connections and forget it.
|
||||||
|
|
||||||
|
Must be called from the event loop that owns the context. When a
|
||||||
|
loop goes away without this, its aiosqlite connections are orphaned;
|
||||||
|
their non-daemon worker threads then block interpreter shutdown
|
||||||
|
forever. The test suite calls this at the end of every test because
|
||||||
|
each test runs in a fresh event loop.
|
||||||
|
"""
|
||||||
|
ctx = self._ctx
|
||||||
|
self._ctx = None
|
||||||
|
self._init_loop = None
|
||||||
|
if ctx is not None:
|
||||||
|
log.info("closing database connections")
|
||||||
|
await ctx.close_connections()
|
||||||
|
|
||||||
async def _build_context(self) -> TortoiseContext:
|
async def _build_context(self) -> TortoiseContext:
|
||||||
ctx = TortoiseContext()
|
ctx = TortoiseContext()
|
||||||
with ctx:
|
with ctx:
|
||||||
@@ -99,6 +118,17 @@ class TortoiseMixin(KayaMixin):
|
|||||||
log.info("sqlite schemas generated")
|
log.info("sqlite schemas generated")
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
async def ensure_context(self) -> None:
|
||||||
|
"""Bind this mixin's context to the calling task.
|
||||||
|
|
||||||
|
Background work with no request of its own (the deadline
|
||||||
|
scheduler's finished-match write) calls this before touching the
|
||||||
|
database. It is cheap when the context is already built for the
|
||||||
|
running loop and idempotent for callers that already have it
|
||||||
|
bound (HTTP requests, WebSocket connections).
|
||||||
|
"""
|
||||||
|
await self._bind()
|
||||||
|
|
||||||
async def _bind(self) -> None:
|
async def _bind(self) -> None:
|
||||||
loop = get_running_loop()
|
loop = get_running_loop()
|
||||||
if self._init_loop is not loop:
|
if self._init_loop is not loop:
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
"""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). Anyone who knows the game id is accepted:
|
||||||
|
seated players get their personalized view, everyone else — anonymous
|
||||||
|
users and authenticated users who are not seated — is a read-only
|
||||||
|
**spectator** receiving the view without privileged information (no
|
||||||
|
hands, no legal moves). The game view carries ``spectator: true`` for
|
||||||
|
them and never carries the join code. Spectator actions other than
|
||||||
|
``state``/``sync`` are rejected with a ``forbidden`` error; seating is
|
||||||
|
re-checked under the game lock so a viewer who joins mid-connection
|
||||||
|
immediately gains (and, if they somehow left, would lose) the right to
|
||||||
|
act.
|
||||||
|
|
||||||
|
The platform owns the connection lifecycle — authentication, the
|
||||||
|
subscription/publish fan-out and the message envelope — and delegates
|
||||||
|
the game-specific actions to the engine registered for the session's
|
||||||
|
``game_type``.
|
||||||
|
|
||||||
|
Protocol
|
||||||
|
--------
|
||||||
|
Server -> client messages are JSON objects with a ``type``:
|
||||||
|
|
||||||
|
* ``state`` — the personalized game view (the engine's
|
||||||
|
:meth:`~tavolo.platform.engine.GameEngine.view_for` output merged with
|
||||||
|
the session envelope: ``id``, ``join_code``, ``game_type``).
|
||||||
|
* ``game_over`` — sent once when the match ends, carrying the engine's
|
||||||
|
:meth:`~tavolo.platform.engine.GameEngine.game_over_view` fields.
|
||||||
|
* ``error`` — a rejected action or malformed message.
|
||||||
|
|
||||||
|
Client -> server messages are JSON objects::
|
||||||
|
|
||||||
|
{"action": "state"} # platform: resend the view
|
||||||
|
{"action": "<game action>", ...fields} # dispatched to the engine
|
||||||
|
|
||||||
|
For scopone scientifico the game actions are ``play`` (with ``card``
|
||||||
|
and optionally ``capture``) and ``ack`` — see
|
||||||
|
:mod:`tavolo.scopone.plugin`; briscola uses ``play`` with just ``card``
|
||||||
|
plus ``ack`` — see :mod:`tavolo.briscola.plugin`. Because the whole
|
||||||
|
message (minus ``action``) is handed to the engine as the payload, games
|
||||||
|
define their own fields freely.
|
||||||
|
|
||||||
|
Mutations run under the per-game lock; after a successful action the new
|
||||||
|
session is saved and a change signal is published. Every connected
|
||||||
|
websocket is subscribed to that signal and re-renders the state, so all
|
||||||
|
players see the move immediately (and consistently across workers).
|
||||||
|
|
||||||
|
Timeouts do not depend on anyone being connected: they are driven by
|
||||||
|
the absolute deadlines the engine declares, via the shared deadline
|
||||||
|
queue drained by a consumer on every worker (see
|
||||||
|
:mod:`tavolo.platform.deadlines`). A disconnected or idle player
|
||||||
|
therefore cannot stall the match, and a worker dying cannot either.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from contextlib import suppress
|
||||||
|
from logging import getLogger
|
||||||
|
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Optional
|
||||||
|
|
||||||
|
from kaya.core import KayaApp, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
|
from . import auth
|
||||||
|
from .engine import GameSession
|
||||||
|
from .errors import GameError
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # avoid the import cycle: mixin imports this module
|
||||||
|
from .mixin import Platform
|
||||||
|
|
||||||
|
log = getLogger(__name__)
|
||||||
|
|
||||||
|
Send = Callable[[Dict[str, Any]], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
|
def _error(message: str, code: str = "invalid") -> Dict[str, Any]:
|
||||||
|
return {"type": "error", "code": code, "message": message}
|
||||||
|
|
||||||
|
|
||||||
|
async def _reject(ws: WebSocket, status: int) -> None:
|
||||||
|
"""Refuse a websocket upgrade with the HTTP ``status``.
|
||||||
|
|
||||||
|
Rejections happen before :meth:`WebSocket.accept`, so ``close()``
|
||||||
|
carries the HTTP status of the refused upgrade rather than a
|
||||||
|
WebSocket close code: ASGI servers answer ``403`` per the spec, and
|
||||||
|
RSGI (granian) uses the value verbatim as the response status.
|
||||||
|
Passing a 4xxx close code here would panic granian's RSGI layer on
|
||||||
|
the invalid status — and since granian is built with
|
||||||
|
``panic = "abort"``, that kills the worker and takes the whole app
|
||||||
|
down.
|
||||||
|
"""
|
||||||
|
await ws.close(status)
|
||||||
|
|
||||||
|
|
||||||
|
def _state_message(
|
||||||
|
platform: "Platform", session: GameSession, sub: Optional[str]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
engine = platform.registry.require(session.game_type)
|
||||||
|
seated = sub is not None and session.seated(sub)
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"id": session.id,
|
||||||
|
"game_type": session.game_type,
|
||||||
|
"spectator": not seated,
|
||||||
|
# Free chairs; the lobby view needs it for games with other player
|
||||||
|
# counts than four.
|
||||||
|
"seats_open": max(0, engine.max_players - len(session.players)),
|
||||||
|
}
|
||||||
|
if seated:
|
||||||
|
# The join code is a credential: only players get it.
|
||||||
|
payload["join_code"] = session.join_code
|
||||||
|
payload.update(engine.view_for(session, sub))
|
||||||
|
return {"type": "state", "game": payload}
|
||||||
|
|
||||||
|
|
||||||
|
def register(app: KayaApp, platform: "Platform") -> None:
|
||||||
|
"""Register the live-play websocket endpoint on ``app``."""
|
||||||
|
|
||||||
|
@app.websocket("/ws/games/${game_id}")
|
||||||
|
async def game_socket(ws: WebSocket, game_id: str) -> None:
|
||||||
|
# Anonymous and non-seated viewers are welcome as read-only
|
||||||
|
# spectators; only an unknown game id is refused.
|
||||||
|
user = auth.get_ws_user(ws)
|
||||||
|
sub: Optional[str] = user.sub if user is not None else None
|
||||||
|
|
||||||
|
session = await platform.game_store.load(game_id)
|
||||||
|
if session is None:
|
||||||
|
log.debug("websocket rejected: unknown game %s", game_id)
|
||||||
|
await _reject(ws, 404)
|
||||||
|
return
|
||||||
|
|
||||||
|
await ws.accept()
|
||||||
|
log.info("%s connected to game %s", sub or "spectator", game_id)
|
||||||
|
|
||||||
|
send_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def send(payload: Dict[str, Any]) -> None:
|
||||||
|
async with send_lock:
|
||||||
|
try:
|
||||||
|
await ws.send_text(json.dumps(payload))
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
# The peer went away without a close handshake: kaya
|
||||||
|
# raises this from the transport, and the receive loop
|
||||||
|
# ends momentarily.
|
||||||
|
pass
|
||||||
|
|
||||||
|
await send(_state_message(platform, session, sub))
|
||||||
|
# Backstop: make sure the current deadline is queued even if its
|
||||||
|
# entry was lost (e.g. the queue was flushed while the game lived
|
||||||
|
# on thanks to its sliding TTL).
|
||||||
|
await platform.scheduler.sync_deadline(session)
|
||||||
|
|
||||||
|
async with platform.game_store.subscribe(game_id) as events:
|
||||||
|
forward = asyncio.create_task(
|
||||||
|
_forward(platform, events, game_id, sub, send)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async for message in ws:
|
||||||
|
if message.kind == "close":
|
||||||
|
break
|
||||||
|
if message.kind != "text" or not isinstance(message.data, str):
|
||||||
|
await send(_error("expected a text frame with a JSON object"))
|
||||||
|
continue
|
||||||
|
await _handle_message(platform, send, game_id, sub, message.data)
|
||||||
|
finally:
|
||||||
|
forward.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await forward
|
||||||
|
log.debug("%s disconnected from game %s", sub or "spectator", game_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _forward(
|
||||||
|
platform: "Platform",
|
||||||
|
events,
|
||||||
|
game_id: str,
|
||||||
|
sub: Optional[str],
|
||||||
|
send: Send,
|
||||||
|
) -> None:
|
||||||
|
async for _ in events:
|
||||||
|
session = await platform.game_store.load(game_id)
|
||||||
|
if session is None:
|
||||||
|
return
|
||||||
|
engine = platform.registry.require(session.game_type)
|
||||||
|
await send(_state_message(platform, session, sub))
|
||||||
|
if engine.is_finished(session):
|
||||||
|
await send(
|
||||||
|
{
|
||||||
|
"type": "game_over",
|
||||||
|
**engine.game_over_view(session, sub),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_message(
|
||||||
|
platform: "Platform", send: Send, game_id: str, sub: Optional[str], raw: str
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
log.debug("game %s: malformed message from %s (not JSON)", game_id, sub)
|
||||||
|
await send(_error("invalid JSON"))
|
||||||
|
return
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
log.debug("game %s: malformed message from %s (not an object)", game_id, sub)
|
||||||
|
await send(_error("message must be a JSON object"))
|
||||||
|
return
|
||||||
|
|
||||||
|
action = data.get("action")
|
||||||
|
if action in ("state", "sync"):
|
||||||
|
session = await platform.game_store.load(game_id)
|
||||||
|
if session is not None:
|
||||||
|
await send(_state_message(platform, session, sub))
|
||||||
|
return
|
||||||
|
if not isinstance(action, str):
|
||||||
|
await send(_error(f"unknown action: {action!r}"))
|
||||||
|
return
|
||||||
|
await _handle_action(platform, send, game_id, sub, action, data)
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_action(
|
||||||
|
platform: "Platform",
|
||||||
|
send: Send,
|
||||||
|
game_id: str,
|
||||||
|
sub: Optional[str],
|
||||||
|
action: str,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
async with platform.game_store.lock(game_id):
|
||||||
|
session = await platform.game_store.load(game_id)
|
||||||
|
if session is None:
|
||||||
|
await send(_error("game not found", code="not_found"))
|
||||||
|
return
|
||||||
|
# Re-check seating under the lock: an anonymous spectator stays a
|
||||||
|
# spectator forever, while a logged-in viewer who joins the game
|
||||||
|
# mid-connection becomes a player and may act from then on.
|
||||||
|
if sub is None or not session.seated(sub):
|
||||||
|
log.debug("game %s: rejected %r from a spectator", game_id, action)
|
||||||
|
await send(_error("spectators cannot act on the game", code="forbidden"))
|
||||||
|
return
|
||||||
|
engine = platform.registry.require(session.game_type)
|
||||||
|
payload = {key: value for key, value in data.items() if key != "action"}
|
||||||
|
try:
|
||||||
|
engine.handle_action(session, sub, action, payload)
|
||||||
|
except GameError as exc:
|
||||||
|
log.debug("game %s: rejected %r by %s: %s", game_id, action, sub, exc)
|
||||||
|
await send(_error(str(exc), code=exc.code))
|
||||||
|
return
|
||||||
|
log.debug("game %s: %s performed %r", game_id, sub, action)
|
||||||
|
await platform.scheduler.finalize_mutation(session)
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
"""Shared fixtures for the tavolo-platform test suite.
|
||||||
|
|
||||||
|
The centerpiece is :class:`DummyEngine`: a tiny two-player game
|
||||||
|
implementing the platform's
|
||||||
|
:class:`~tavolo.platform.engine.GameEngine` contract, so every platform
|
||||||
|
behaviour (lobby, store, websockets, deadlines, stats) is exercised
|
||||||
|
without importing any real game. Its rules: the first player to reach
|
||||||
|
``target`` plays wins the match.
|
||||||
|
|
||||||
|
:func:`make_platform` builds a throwaway :class:`~kaya.core.KayaApp`
|
||||||
|
wired with in-memory stores, dummy OIDC providers (patched per test) and
|
||||||
|
a sqlite :class:`~tavolo.platform.tortoise_mixin.TortoiseMixin`, so
|
||||||
|
tests construct their own app instead of importing a global one.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import unittest.mock as _mock
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Any, Callable, Coroutine, Dict, Iterator, List, Mapping, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
from kaya.core import KayaApp
|
||||||
|
from kaya.oidc import OIDCConfig, OIDCProviders, OIDCUser
|
||||||
|
from kaya.openapi import OpenAPIMixin
|
||||||
|
from kaya.session import InMemorySessionStore, SessionMixin
|
||||||
|
|
||||||
|
from tavolo.platform import (
|
||||||
|
AlreadyJoined,
|
||||||
|
Deadline,
|
||||||
|
GameEngine,
|
||||||
|
GameError,
|
||||||
|
GameFinished,
|
||||||
|
GameNotStarted,
|
||||||
|
GameSession,
|
||||||
|
IllegalMove,
|
||||||
|
LobbyFull,
|
||||||
|
MatchResult,
|
||||||
|
NotYourTurn,
|
||||||
|
Platform,
|
||||||
|
PlatformMixin,
|
||||||
|
PlayerResult,
|
||||||
|
Seat,
|
||||||
|
)
|
||||||
|
from tavolo.platform.auth import get_ws_user # noqa: F401 (re-exported for patching)
|
||||||
|
from tavolo.platform.deadlines import DeadlineScheduler
|
||||||
|
from tavolo.platform.registry import GameRegistry
|
||||||
|
from tavolo.platform.store import InMemoryGameStore
|
||||||
|
from tavolo.platform.tortoise_mixin import TortoiseMixin
|
||||||
|
|
||||||
|
|
||||||
|
class DummyEngine(GameEngine):
|
||||||
|
"""A two-player toy game: first to ``target`` plays wins.
|
||||||
|
|
||||||
|
Team labels are "A"/"B" (one player per team) so Elo paths are
|
||||||
|
exercised too. A ``deadline_in_seconds`` creation option arms a
|
||||||
|
``tick`` deadline that plays for the first player when it fires.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id = "dummy"
|
||||||
|
name = "Dummy game"
|
||||||
|
description = "A two-player toy game for testing the platform."
|
||||||
|
min_players = 2
|
||||||
|
max_players = 2
|
||||||
|
options_schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"target": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"default": 3,
|
||||||
|
"description": "Plays needed to win the match.",
|
||||||
|
},
|
||||||
|
"deadline_in_seconds": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Arm a tick deadline this far in the future.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def create(self, session: GameSession, options: Mapping[str, Any]) -> None:
|
||||||
|
target = options.get("target", 3)
|
||||||
|
if isinstance(target, bool) or not isinstance(target, int) or target < 1:
|
||||||
|
raise IllegalMove("target must be a positive integer")
|
||||||
|
# The creator takes team A.
|
||||||
|
creator = session.players[0]
|
||||||
|
session.players[0] = Seat(
|
||||||
|
user_sub=creator.user_sub,
|
||||||
|
display_name=creator.display_name,
|
||||||
|
team="A",
|
||||||
|
)
|
||||||
|
session.state = {
|
||||||
|
"target": target,
|
||||||
|
"plays": [],
|
||||||
|
"started": False,
|
||||||
|
"finished": False,
|
||||||
|
"winner": None,
|
||||||
|
"deadline_in_seconds": options.get("deadline_in_seconds"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def join(self, session: GameSession, user_sub: str, display_name: str) -> None:
|
||||||
|
state = session.state
|
||||||
|
if state["started"]:
|
||||||
|
raise GameNotStarted("game has already started")
|
||||||
|
if session.seated(user_sub):
|
||||||
|
raise AlreadyJoined("already joined this game")
|
||||||
|
if len(session.players) >= 2:
|
||||||
|
raise LobbyFull("game is full")
|
||||||
|
session.players.append(
|
||||||
|
Seat(
|
||||||
|
user_sub=user_sub,
|
||||||
|
display_name=display_name,
|
||||||
|
team="A" if not session.players else "B",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len(session.players) == 2:
|
||||||
|
state["started"] = True
|
||||||
|
|
||||||
|
def handle_action(
|
||||||
|
self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any]
|
||||||
|
) -> None:
|
||||||
|
state = session.state
|
||||||
|
if not state["started"]:
|
||||||
|
raise GameNotStarted("the game has not started yet")
|
||||||
|
if state["finished"]:
|
||||||
|
raise GameFinished("the match is over")
|
||||||
|
if not session.seated(user_sub):
|
||||||
|
raise NotYourTurn("you are not seated in this game")
|
||||||
|
if action != "play":
|
||||||
|
raise IllegalMove(f"unknown action: {action!r}")
|
||||||
|
self._play(session, user_sub)
|
||||||
|
|
||||||
|
def _play(self, session: GameSession, user_sub: str) -> None:
|
||||||
|
state = session.state
|
||||||
|
state["plays"].append(user_sub)
|
||||||
|
if len(state["plays"]) >= state["target"]:
|
||||||
|
state["finished"] = True
|
||||||
|
state["winner"] = user_sub
|
||||||
|
|
||||||
|
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
|
||||||
|
state = session.state
|
||||||
|
return {
|
||||||
|
"started": state["started"],
|
||||||
|
"finished": state["finished"],
|
||||||
|
"winner": state["winner"],
|
||||||
|
"plays": len(state["plays"]),
|
||||||
|
"target": state["target"],
|
||||||
|
"viewer_seated": user_sub is not None and session.seated(user_sub),
|
||||||
|
}
|
||||||
|
|
||||||
|
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"phase": "playing" if session.state["started"] else "lobby",
|
||||||
|
"target": session.state["target"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def in_lobby(self, session: GameSession) -> bool:
|
||||||
|
return not session.state["started"]
|
||||||
|
|
||||||
|
def is_finished(self, session: GameSession) -> bool:
|
||||||
|
return bool(session.state["finished"])
|
||||||
|
|
||||||
|
def result(self, session: GameSession) -> MatchResult:
|
||||||
|
state = session.state
|
||||||
|
winner = state["winner"]
|
||||||
|
if winner is None:
|
||||||
|
raise GameError("no result: the match is not finished")
|
||||||
|
subs = [seat.user_sub for seat in session.players]
|
||||||
|
plays: List[str] = state["plays"]
|
||||||
|
return MatchResult(
|
||||||
|
teams=[[subs[0]], [subs[1]]],
|
||||||
|
winner_team=subs.index(winner),
|
||||||
|
players=[
|
||||||
|
PlayerResult(
|
||||||
|
user_sub=seat.user_sub,
|
||||||
|
seat=index,
|
||||||
|
won=seat.user_sub == winner,
|
||||||
|
team=seat.team,
|
||||||
|
score=float(plays.count(seat.user_sub)),
|
||||||
|
details={"plays": plays.count(seat.user_sub)},
|
||||||
|
)
|
||||||
|
for index, seat in enumerate(session.players)
|
||||||
|
],
|
||||||
|
summary={
|
||||||
|
"target": state["target"],
|
||||||
|
"plays": len(plays),
|
||||||
|
"winner": winner,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def state_to_json(self, state: Any) -> Dict[str, Any]:
|
||||||
|
return dict(state)
|
||||||
|
|
||||||
|
def state_from_json(self, data: Mapping[str, Any]) -> Any:
|
||||||
|
return dict(data)
|
||||||
|
|
||||||
|
def next_deadline(self, session: GameSession) -> Optional[Deadline]:
|
||||||
|
seconds = session.state.get("deadline_in_seconds")
|
||||||
|
if (
|
||||||
|
seconds is None
|
||||||
|
or not session.state["started"]
|
||||||
|
or session.state["finished"]
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
due_at = datetime.now(timezone.utc) + timedelta(seconds=float(seconds))
|
||||||
|
return Deadline(
|
||||||
|
kind="tick",
|
||||||
|
due_at=due_at,
|
||||||
|
token=f"tick:{len(session.state['plays'])}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def fire_deadline(self, session: GameSession, kind: str, token: str) -> None:
|
||||||
|
current = self.next_deadline(session)
|
||||||
|
if current is None or current.kind != kind or current.token != token:
|
||||||
|
raise GameError("stale deadline")
|
||||||
|
# The house plays for the first player.
|
||||||
|
self._play(session, session.players[0].user_sub)
|
||||||
|
|
||||||
|
|
||||||
|
def make_platform(
|
||||||
|
engines: Sequence[GameEngine] = (DummyEngine(),),
|
||||||
|
heartbeat_ms: int = 50,
|
||||||
|
oidc_providers: Sequence[str] = ("default",),
|
||||||
|
) -> Tuple[KayaApp, Platform, TortoiseMixin]:
|
||||||
|
"""Build a throwaway app + platform wired with in-memory stores."""
|
||||||
|
registry = GameRegistry(engines)
|
||||||
|
game_store = InMemoryGameStore(registry)
|
||||||
|
session_mixin = SessionMixin(InMemorySessionStore())
|
||||||
|
oidc = OIDCProviders(
|
||||||
|
[
|
||||||
|
OIDCConfig(
|
||||||
|
provider=name,
|
||||||
|
issuer=f"http://localhost:8180/{name}",
|
||||||
|
client_id="tavolo",
|
||||||
|
client_secret=None,
|
||||||
|
redirect_uri=f"http://localhost:8080/auth/{name}/callback",
|
||||||
|
post_login_redirect="/",
|
||||||
|
post_logout_redirect="/",
|
||||||
|
)
|
||||||
|
for name in oidc_providers
|
||||||
|
],
|
||||||
|
session=session_mixin,
|
||||||
|
)
|
||||||
|
tortoise_mixin = TortoiseMixin(
|
||||||
|
database_url="sqlite://:memory:",
|
||||||
|
models_modules=["tavolo.platform.models"],
|
||||||
|
skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}),
|
||||||
|
)
|
||||||
|
openapi_mixin = OpenAPIMixin(
|
||||||
|
title="tavolo-platform-tests",
|
||||||
|
version="0.1.0",
|
||||||
|
description="test app",
|
||||||
|
spec_path="/api/openapi.json",
|
||||||
|
docs_path="/api/docs",
|
||||||
|
)
|
||||||
|
scheduler = DeadlineScheduler(
|
||||||
|
game_store,
|
||||||
|
registry,
|
||||||
|
heartbeat_ms=heartbeat_ms,
|
||||||
|
context_binder=tortoise_mixin.ensure_context,
|
||||||
|
)
|
||||||
|
platform = Platform(
|
||||||
|
registry=registry,
|
||||||
|
game_store=game_store,
|
||||||
|
scheduler=scheduler,
|
||||||
|
oidc=oidc,
|
||||||
|
)
|
||||||
|
app = KayaApp(
|
||||||
|
mixins=[
|
||||||
|
session_mixin,
|
||||||
|
oidc,
|
||||||
|
tortoise_mixin,
|
||||||
|
openapi_mixin,
|
||||||
|
PlatformMixin(platform),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
_tortoise_mixins.append(tortoise_mixin)
|
||||||
|
_schedulers.append(scheduler)
|
||||||
|
return app, platform, tortoise_mixin
|
||||||
|
|
||||||
|
|
||||||
|
_tortoise_mixins: List[TortoiseMixin] = []
|
||||||
|
_schedulers: List[DeadlineScheduler] = []
|
||||||
|
|
||||||
|
|
||||||
|
def async_test(coro: Callable[..., Coroutine[Any, Any, None]]) -> Callable[..., None]:
|
||||||
|
"""Like ``pwo.async_test`` (fresh loop per test), but tear down the
|
||||||
|
platform pieces afterwards: close Tortoise contexts (otherwise
|
||||||
|
orphaned aiosqlite threads block interpreter shutdown) and stop
|
||||||
|
deadline consumers."""
|
||||||
|
|
||||||
|
@wraps(coro)
|
||||||
|
def wrapper(*args: Any, **kwargs: Any) -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
try:
|
||||||
|
await coro(*args, **kwargs)
|
||||||
|
finally:
|
||||||
|
for scheduler in _schedulers:
|
||||||
|
scheduler.stop_consumer(loop)
|
||||||
|
_schedulers.clear()
|
||||||
|
for mixin in _tortoise_mixins:
|
||||||
|
await mixin.aclose()
|
||||||
|
_tortoise_mixins.clear()
|
||||||
|
|
||||||
|
with asyncio.Runner() as runner:
|
||||||
|
runner.run(run())
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
async def use_db(tortoise_mixin: TortoiseMixin):
|
||||||
|
"""Bind the app's Tortoise context for this loop, for seeding rows."""
|
||||||
|
from tortoise.context import TortoiseContext
|
||||||
|
|
||||||
|
await tortoise_mixin._bind()
|
||||||
|
ctx: Optional[TortoiseContext] = tortoise_mixin._ctx
|
||||||
|
assert ctx is not None
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
def make_user(sub: str, name: Optional[str] = None) -> OIDCUser:
|
||||||
|
return OIDCUser({"sub": sub, "preferred_username": name or sub})
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def oidc_user(oidc: OIDCProviders, sub: str, name: Optional[str] = None) -> Iterator[OIDCUser]:
|
||||||
|
"""Context manager: make ``oidc.get_users`` return this user for the
|
||||||
|
first configured provider."""
|
||||||
|
user = make_user(sub, name)
|
||||||
|
provider = next(iter(oidc.providers))
|
||||||
|
patcher = _mock.patch.object(oidc, "get_users", return_value={provider: user})
|
||||||
|
patcher.start()
|
||||||
|
try:
|
||||||
|
yield user
|
||||||
|
finally:
|
||||||
|
patcher.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def ws_users(users: Sequence[OIDCUser]) -> Iterator[None]:
|
||||||
|
"""Context manager: patch ``auth.get_ws_user`` to hand out ``users``
|
||||||
|
one per websocket connection, in order. Once exhausted it keeps
|
||||||
|
returning the last user."""
|
||||||
|
from tavolo.platform import auth
|
||||||
|
|
||||||
|
remaining = list(users)
|
||||||
|
last = remaining[-1] if remaining else None
|
||||||
|
|
||||||
|
def _next(_ws):
|
||||||
|
if remaining:
|
||||||
|
return remaining.pop(0)
|
||||||
|
return last
|
||||||
|
|
||||||
|
patcher = _mock.patch.object(auth, "get_ws_user", side_effect=_next)
|
||||||
|
patcher.start()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
patcher.stop()
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""Tests for the auth helpers' OIDC user lookup and gating.
|
||||||
|
|
||||||
|
kaya-oidc (0.0.6) namespaces session state per provider as
|
||||||
|
``oidc:{provider}:user`` and tavolo wires an :class:`OIDCProviders`; these
|
||||||
|
tests pin both contracts so a kaya upgrade or a provider change cannot
|
||||||
|
silently break HTTP or websocket authentication.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from kaya.oidc import OIDCConfig, OIDCProviders
|
||||||
|
from kaya.session import InMemorySessionStore, Session, SessionMixin
|
||||||
|
|
||||||
|
from helpers import async_test
|
||||||
|
from tavolo.platform.auth import current_user, get_ws_user, require_auth, session_user
|
||||||
|
|
||||||
|
|
||||||
|
def _providers(*names: str) -> OIDCProviders:
|
||||||
|
session = SessionMixin(InMemorySessionStore())
|
||||||
|
return OIDCProviders(
|
||||||
|
[
|
||||||
|
OIDCConfig(
|
||||||
|
provider=name,
|
||||||
|
issuer=f"http://localhost:8180/{name}",
|
||||||
|
client_id="tavolo",
|
||||||
|
redirect_uri=f"http://localhost:8080/auth/{name}/callback",
|
||||||
|
)
|
||||||
|
for name in names
|
||||||
|
],
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx(data: Any) -> Any:
|
||||||
|
return SimpleNamespace(session=Session("sid", data))
|
||||||
|
|
||||||
|
|
||||||
|
def _ws(session: Any) -> Any:
|
||||||
|
return SimpleNamespace(session=session)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionUserTest(unittest.TestCase):
|
||||||
|
def test_finds_default_provider_user(self) -> None:
|
||||||
|
user = session_user(Session("sid", {"oidc:default:user": {"sub": "alice"}}))
|
||||||
|
assert user is not None
|
||||||
|
self.assertEqual("alice", user.sub)
|
||||||
|
|
||||||
|
def test_finds_user_of_any_provider(self) -> None:
|
||||||
|
user = session_user(Session("sid", {"oidc:google:user": {"sub": "alice"}}))
|
||||||
|
assert user is not None
|
||||||
|
self.assertEqual("alice", user.sub)
|
||||||
|
|
||||||
|
def test_ignores_flow_state_and_tokens(self) -> None:
|
||||||
|
session = Session(
|
||||||
|
"sid",
|
||||||
|
{
|
||||||
|
"oidc:default:state": "state",
|
||||||
|
"oidc:default:id_token": "token",
|
||||||
|
"oidc:default:access_token": "token",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertIsNone(session_user(session))
|
||||||
|
|
||||||
|
def test_ignores_legacy_unprefixed_user_key(self) -> None:
|
||||||
|
session = Session("sid", {"oidc_user": {"sub": "alice"}})
|
||||||
|
self.assertIsNone(session_user(session))
|
||||||
|
|
||||||
|
def test_ignores_malformed_user_claims(self) -> None:
|
||||||
|
session = Session("sid", {"oidc:default:user": "alice"})
|
||||||
|
self.assertIsNone(session_user(session))
|
||||||
|
|
||||||
|
|
||||||
|
class GetWsUserTest(unittest.TestCase):
|
||||||
|
def test_returns_user_from_provider_namespaced_session(self) -> None:
|
||||||
|
session = Session(
|
||||||
|
"sid",
|
||||||
|
{"oidc:default:user": {"sub": "alice", "preferred_username": "Alice"}},
|
||||||
|
)
|
||||||
|
user = get_ws_user(_ws(session))
|
||||||
|
assert user is not None
|
||||||
|
self.assertEqual("alice", user.sub)
|
||||||
|
|
||||||
|
def test_missing_session_returns_none(self) -> None:
|
||||||
|
self.assertIsNone(get_ws_user(SimpleNamespace()))
|
||||||
|
|
||||||
|
|
||||||
|
class CurrentUserTest(unittest.TestCase):
|
||||||
|
def test_anonymous_session_has_no_user(self) -> None:
|
||||||
|
self.assertIsNone(current_user(_providers("default"), _ctx({})))
|
||||||
|
|
||||||
|
def test_returns_the_authenticated_provider_user(self) -> None:
|
||||||
|
user = current_user(
|
||||||
|
_providers("default", "google"),
|
||||||
|
_ctx({"oidc:google:user": {"sub": "alice"}}),
|
||||||
|
)
|
||||||
|
assert user is not None
|
||||||
|
self.assertEqual("alice", user.sub)
|
||||||
|
|
||||||
|
def test_first_configured_provider_wins(self) -> None:
|
||||||
|
# Single-identity sessions never hold two users; this only pins the
|
||||||
|
# tie-break should they ever coexist.
|
||||||
|
user = current_user(
|
||||||
|
_providers("default", "google"),
|
||||||
|
_ctx(
|
||||||
|
{
|
||||||
|
"oidc:default:user": {"sub": "alice"},
|
||||||
|
"oidc:google:user": {"sub": "bob"},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert user is not None
|
||||||
|
self.assertEqual("alice", user.sub)
|
||||||
|
|
||||||
|
|
||||||
|
class RequireAuthTest(unittest.TestCase):
|
||||||
|
@async_test
|
||||||
|
async def test_rejects_anonymous_requests(self) -> None:
|
||||||
|
oidc = _providers("default")
|
||||||
|
ctx = _ctx({})
|
||||||
|
sent = {}
|
||||||
|
|
||||||
|
async def send_bytes(*args: Any) -> None:
|
||||||
|
sent["status"] = args[0]
|
||||||
|
|
||||||
|
ctx.send_bytes = send_bytes
|
||||||
|
handled = []
|
||||||
|
|
||||||
|
@require_auth(oidc)
|
||||||
|
async def handler(_ctx: Any) -> None:
|
||||||
|
handled.append(True)
|
||||||
|
|
||||||
|
await handler(ctx)
|
||||||
|
self.assertEqual(401, sent["status"])
|
||||||
|
self.assertEqual([], handled)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_allows_authenticated_requests(self) -> None:
|
||||||
|
oidc = _providers("default")
|
||||||
|
ctx = _ctx({"oidc:default:user": {"sub": "alice"}})
|
||||||
|
handled = []
|
||||||
|
|
||||||
|
@require_auth(oidc)
|
||||||
|
async def handler(request_ctx: Any) -> None:
|
||||||
|
user = current_user(oidc, request_ctx)
|
||||||
|
assert user is not None
|
||||||
|
handled.append(user.sub)
|
||||||
|
|
||||||
|
await handler(ctx)
|
||||||
|
self.assertEqual(["alice"], handled)
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
"""Deadline-scheduler tests, driven by the DummyEngine.
|
||||||
|
|
||||||
|
Timeouts must be driven by the persisted deadlines and the shared queue,
|
||||||
|
not by connected sockets: these tests seed sessions, enqueue their
|
||||||
|
deadlines and let the background consumer fire them without a single
|
||||||
|
websocket. The engine owns the meaning of each deadline; the scheduler
|
||||||
|
owns enqueueing, delivery and removal.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import unittest
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from tavolo.platform import GameSession, Seat
|
||||||
|
from tavolo.platform.deadlines import encode
|
||||||
|
from helpers import DummyEngine, async_test, make_platform, use_db
|
||||||
|
|
||||||
|
|
||||||
|
def _started_session(
|
||||||
|
game_id: str = "dl-1",
|
||||||
|
code: str = "DL0001",
|
||||||
|
target: int = 3,
|
||||||
|
deadline_in_seconds: Optional[float] = None,
|
||||||
|
) -> GameSession:
|
||||||
|
engine = DummyEngine()
|
||||||
|
session = GameSession(
|
||||||
|
id=game_id,
|
||||||
|
game_type=engine.id,
|
||||||
|
join_code=code,
|
||||||
|
creator_sub="alice",
|
||||||
|
players=[Seat(user_sub="alice", display_name="alice", team="A")],
|
||||||
|
)
|
||||||
|
options: Dict[str, Any] = {"target": target}
|
||||||
|
if deadline_in_seconds is not None:
|
||||||
|
options["deadline_in_seconds"] = deadline_in_seconds
|
||||||
|
engine.create(session, options)
|
||||||
|
engine.join(session, "bob", "bob")
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
async def _wait_for(predicate, timeout: float = 5.0):
|
||||||
|
"""Poll the store until ``predicate`` returns a truthy value."""
|
||||||
|
deadline = asyncio.get_running_loop().time() + timeout
|
||||||
|
while asyncio.get_running_loop().time() < deadline:
|
||||||
|
result = await predicate()
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionIndependenceTest(unittest.TestCase):
|
||||||
|
@async_test
|
||||||
|
async def test_tick_fires_with_no_connections(self) -> None:
|
||||||
|
_, platform, _ = make_platform()
|
||||||
|
store = platform.game_store
|
||||||
|
scheduler = platform.scheduler
|
||||||
|
session = _started_session(deadline_in_seconds=0.05)
|
||||||
|
await store.save(session)
|
||||||
|
await scheduler.sync_deadline(session)
|
||||||
|
|
||||||
|
# Nobody ever connects: the consumer must still fire the tick,
|
||||||
|
# which plays for the first player.
|
||||||
|
result = await _wait_for(
|
||||||
|
lambda: _plays_is(store, session.id, 1),
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(result, "deadline never fired")
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_no_deadline_nothing_enqueued(self) -> None:
|
||||||
|
_, platform, _ = make_platform()
|
||||||
|
session = _started_session() # no deadline_in_seconds option
|
||||||
|
await platform.game_store.save(session)
|
||||||
|
await platform.scheduler.sync_deadline(session)
|
||||||
|
self.assertIsNone(await platform.game_store.next_deadline())
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_tick_finishing_match_persists_result_with_no_connections(self) -> None:
|
||||||
|
# A deadline that ends the match must persist the result from the
|
||||||
|
# consumer task, which has no request context of its own.
|
||||||
|
from tavolo.platform.models import Match
|
||||||
|
|
||||||
|
_, platform, tortoise_mixin = make_platform()
|
||||||
|
store = platform.game_store
|
||||||
|
scheduler = platform.scheduler
|
||||||
|
session = _started_session(target=1, deadline_in_seconds=0.05)
|
||||||
|
await store.save(session)
|
||||||
|
await scheduler.sync_deadline(session)
|
||||||
|
|
||||||
|
# Nobody ever connects: the consumer must finish the match and
|
||||||
|
# write it to Postgres.
|
||||||
|
result = await _wait_for(lambda: _finished(store, session.id))
|
||||||
|
self.assertIsNotNone(result, "deadline never finished the match")
|
||||||
|
|
||||||
|
ctx = await use_db(tortoise_mixin)
|
||||||
|
with ctx:
|
||||||
|
self.assertEqual(1, await Match.all().count())
|
||||||
|
|
||||||
|
|
||||||
|
async def _plays_is(store, game_id: str, count: int) -> Optional[GameSession]:
|
||||||
|
session = await store.load(game_id)
|
||||||
|
if session is not None and len(session.state["plays"]) == count:
|
||||||
|
return session
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _finished(store, game_id: str) -> Optional[GameSession]:
|
||||||
|
session = await store.load(game_id)
|
||||||
|
if session is not None and session.state["finished"]:
|
||||||
|
return session
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationTest(unittest.TestCase):
|
||||||
|
"""Deadline-enqueue notifications wake consumers on other workers."""
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_notification_wakes_a_long_heartbeat_consumer(self) -> None:
|
||||||
|
# The consumer's heartbeat is a minute away and nothing sets its
|
||||||
|
# local wake event: only the pub/sub notification can deliver the
|
||||||
|
# deadline in time. This is the cross-worker path.
|
||||||
|
_, platform, _ = make_platform(heartbeat_ms=60_000)
|
||||||
|
store = platform.game_store
|
||||||
|
scheduler = platform.scheduler
|
||||||
|
session = _started_session(deadline_in_seconds=0.1)
|
||||||
|
await store.save(session)
|
||||||
|
scheduler.ensure_consumer()
|
||||||
|
# Let the listener task subscribe before announcing.
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
engine = platform.registry.require(session.game_type)
|
||||||
|
deadline = engine.next_deadline(session)
|
||||||
|
assert deadline is not None
|
||||||
|
member = encode({
|
||||||
|
"game_id": session.id,
|
||||||
|
"kind": deadline.kind,
|
||||||
|
"token": deadline.token,
|
||||||
|
})
|
||||||
|
await store.add_deadline(member, deadline.due_at.timestamp())
|
||||||
|
await store.notify_deadline()
|
||||||
|
|
||||||
|
result = await _wait_for(lambda: _plays_is(store, session.id, 1))
|
||||||
|
self.assertIsNotNone(result, "notification did not wake the consumer")
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessDueTest(unittest.TestCase):
|
||||||
|
"""Direct ``process_due`` behaviour: engine revalidation and idempotency."""
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_processing_twice_is_a_no_op(self) -> None:
|
||||||
|
# Simulates a worker dying after firing but before removing the
|
||||||
|
# entry: another worker re-delivers the same entry. The engine's
|
||||||
|
# token has moved on, so the second delivery is stale.
|
||||||
|
_, platform, _ = make_platform()
|
||||||
|
store = platform.game_store
|
||||||
|
scheduler = platform.scheduler
|
||||||
|
session = _started_session(deadline_in_seconds=3600)
|
||||||
|
await store.save(session)
|
||||||
|
deadline = DummyEngine().next_deadline(session)
|
||||||
|
assert deadline is not None
|
||||||
|
member = encode({
|
||||||
|
"game_id": session.id,
|
||||||
|
"kind": deadline.kind,
|
||||||
|
"token": deadline.token,
|
||||||
|
})
|
||||||
|
|
||||||
|
await scheduler.process_due(member)
|
||||||
|
await scheduler.process_due(member)
|
||||||
|
|
||||||
|
result = await store.load(session.id)
|
||||||
|
assert result is not None
|
||||||
|
# Fired exactly once: one play, not two.
|
||||||
|
self.assertEqual(["alice"], result.state["plays"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_stale_entry_is_discarded(self) -> None:
|
||||||
|
# A tick enqueued before a play landed in time: the token has
|
||||||
|
# moved, so the entry must not fire.
|
||||||
|
_, platform, _ = make_platform()
|
||||||
|
scheduler = platform.scheduler
|
||||||
|
store = platform.game_store
|
||||||
|
session = _started_session(deadline_in_seconds=3600)
|
||||||
|
session.state["plays"].append("alice") # a play landed in time
|
||||||
|
await store.save(session)
|
||||||
|
member = encode({
|
||||||
|
"game_id": session.id,
|
||||||
|
"kind": "tick",
|
||||||
|
"token": "tick:0", # not the live token ("tick:1")
|
||||||
|
})
|
||||||
|
await store.add_deadline(member, due_at=0.0)
|
||||||
|
|
||||||
|
await scheduler.process_due(member)
|
||||||
|
|
||||||
|
result = await store.load(session.id)
|
||||||
|
assert result is not None
|
||||||
|
self.assertEqual(["alice"], result.state["plays"])
|
||||||
|
# The entry was removed after processing.
|
||||||
|
self.assertNotIn(member, await store.due_deadlines(float("inf")))
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_entry_for_expired_game_is_dropped(self) -> None:
|
||||||
|
_, platform, _ = make_platform()
|
||||||
|
scheduler = platform.scheduler
|
||||||
|
store = platform.game_store
|
||||||
|
member = encode({
|
||||||
|
"game_id": "dl-gone",
|
||||||
|
"kind": "tick",
|
||||||
|
"token": "tick:0",
|
||||||
|
})
|
||||||
|
await store.add_deadline(member, due_at=0.0)
|
||||||
|
await scheduler.process_due(member)
|
||||||
|
self.assertNotIn(member, await store.due_deadlines(float("inf")))
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_malformed_entry_is_dropped(self) -> None:
|
||||||
|
_, platform, _ = make_platform()
|
||||||
|
scheduler = platform.scheduler
|
||||||
|
store = platform.game_store
|
||||||
|
await store.add_deadline("not json", due_at=0.0)
|
||||||
|
await scheduler.process_due("not json")
|
||||||
|
self.assertNotIn("not json", await store.due_deadlines(float("inf")))
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_finished_match_is_persisted_on_tick(self) -> None:
|
||||||
|
# A tick that completes the match writes the result to Postgres.
|
||||||
|
# No context is bound in this task: the scheduler must bind its
|
||||||
|
# own, exactly like its consumer task at app startup.
|
||||||
|
from tavolo.platform.models import Match
|
||||||
|
|
||||||
|
_, platform, tortoise_mixin = make_platform()
|
||||||
|
scheduler = platform.scheduler
|
||||||
|
store = platform.game_store
|
||||||
|
session = _started_session(target=1, deadline_in_seconds=3600)
|
||||||
|
await store.save(session)
|
||||||
|
deadline = DummyEngine().next_deadline(session)
|
||||||
|
assert deadline is not None
|
||||||
|
member = encode({
|
||||||
|
"game_id": session.id,
|
||||||
|
"kind": deadline.kind,
|
||||||
|
"token": deadline.token,
|
||||||
|
})
|
||||||
|
await scheduler.process_due(member)
|
||||||
|
ctx = await use_db(tortoise_mixin)
|
||||||
|
with ctx:
|
||||||
|
self.assertEqual(1, await Match.all().count())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Unit tests for the chess-style Elo math in :mod:`tavolo.platform.elo`."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from tavolo.platform.elo import (
|
||||||
|
INITIAL_RATING,
|
||||||
|
K_FACTOR,
|
||||||
|
expected_score,
|
||||||
|
match_delta,
|
||||||
|
team_rating,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExpectedScoreTest(unittest.TestCase):
|
||||||
|
def test_equal_ratings_give_even_odds(self) -> None:
|
||||||
|
self.assertAlmostEqual(0.5, expected_score(1500, 1500))
|
||||||
|
|
||||||
|
def test_higher_rating_is_favoured(self) -> None:
|
||||||
|
self.assertGreater(expected_score(1700, 1500), 0.5)
|
||||||
|
self.assertLess(expected_score(1500, 1700), 0.5)
|
||||||
|
|
||||||
|
def test_scores_sum_to_one(self) -> None:
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
1.0, expected_score(1600, 1400) + expected_score(1400, 1600)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_four_hundred_points_is_ten_to_one(self) -> None:
|
||||||
|
self.assertAlmostEqual(10 / 11, expected_score(1900, 1500))
|
||||||
|
|
||||||
|
|
||||||
|
class TeamRatingTest(unittest.TestCase):
|
||||||
|
def test_mean_of_members(self) -> None:
|
||||||
|
self.assertEqual(1600, team_rating([1500, 1700]))
|
||||||
|
|
||||||
|
def test_empty_team_rejected(self) -> None:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
team_rating([])
|
||||||
|
|
||||||
|
|
||||||
|
class MatchDeltaTest(unittest.TestCase):
|
||||||
|
def test_equal_teams_exchange_half_k(self) -> None:
|
||||||
|
delta = match_delta([1500, 1500], [1500, 1500], winner_team=0)
|
||||||
|
self.assertEqual(K_FACTOR // 2, delta)
|
||||||
|
|
||||||
|
def test_favourite_gains_less_than_underdog(self) -> None:
|
||||||
|
favourite = match_delta([1700, 1700], [1500, 1500], winner_team=0)
|
||||||
|
underdog = match_delta([1500, 1500], [1700, 1700], winner_team=0)
|
||||||
|
self.assertGreater(underdog, favourite)
|
||||||
|
self.assertGreater(favourite, 0)
|
||||||
|
|
||||||
|
def test_losing_side_loses_the_winners_gain(self) -> None:
|
||||||
|
# Zero-sum: the losers' delta is the negation of the winners'.
|
||||||
|
win = match_delta([1600, 1500], [1400, 1500], winner_team=0)
|
||||||
|
loss = match_delta([1600, 1500], [1400, 1500], winner_team=1)
|
||||||
|
self.assertEqual(-win, -abs(win)) # winner gains
|
||||||
|
# Losing the same pairing costs K * E, winning gains K * (1 - E);
|
||||||
|
# both are computed from the same expectation, so loss = win - K.
|
||||||
|
self.assertEqual(win - K_FACTOR, loss)
|
||||||
|
|
||||||
|
def test_team_average_decides_not_individual_ratings(self) -> None:
|
||||||
|
# [1700, 1300] averages 1500, same as [1500, 1500].
|
||||||
|
mixed = match_delta([1700, 1300], [1500, 1500], winner_team=0)
|
||||||
|
even = match_delta([1500, 1500], [1500, 1500], winner_team=0)
|
||||||
|
self.assertEqual(even, mixed)
|
||||||
|
|
||||||
|
def test_initial_rating_constant(self) -> None:
|
||||||
|
self.assertEqual(1500, INITIAL_RATING)
|
||||||
|
self.assertEqual(32, K_FACTOR)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Keyset pagination tests: cursor values are restored to Python types.
|
||||||
|
|
||||||
|
Cursors are JSON, so a cursor carrying a datetime reaches the next
|
||||||
|
request as a string. Postgres/asyncpg rejects strings for timestamp
|
||||||
|
parameters (sqlite silently coerces them), so ``paginate`` must run the
|
||||||
|
decoded values through their model fields before building the WHERE
|
||||||
|
clause.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, List
|
||||||
|
|
||||||
|
from tavolo.platform.models import Match
|
||||||
|
from tavolo.platform.pagination import Cursor, decode_cursor, encode_cursor, paginate
|
||||||
|
|
||||||
|
from helpers import async_test
|
||||||
|
|
||||||
|
SORT = [("finished_at", "DESC"), ("id", "ASC")]
|
||||||
|
|
||||||
|
|
||||||
|
class _CapturingQuerySet:
|
||||||
|
"""Minimal stand-in for a Tortoise QuerySet, recording its filter."""
|
||||||
|
|
||||||
|
model = Match
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.queryset_filter: Any = None
|
||||||
|
|
||||||
|
def order_by(self, *orderings: str) -> "_CapturingQuerySet":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def filter(self, q: Any) -> "_CapturingQuerySet":
|
||||||
|
self.queryset_filter = q
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def limit(self, count: int) -> List[Any]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _flatten_q(q: Any) -> List[Any]:
|
||||||
|
nodes = [q]
|
||||||
|
for child in q.children:
|
||||||
|
nodes.extend(_flatten_q(child))
|
||||||
|
return nodes
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_values(q: Any, key: str) -> List[Any]:
|
||||||
|
return [node.filters[key] for node in _flatten_q(q) if key in node.filters]
|
||||||
|
|
||||||
|
|
||||||
|
class PaginateCursorTypesTest(unittest.TestCase):
|
||||||
|
@async_test
|
||||||
|
async def test_cursor_values_are_restored_before_filtering(self) -> None:
|
||||||
|
match_id = uuid.uuid4()
|
||||||
|
finished_at = datetime(2026, 9, 19, 5, 2, 50, 547904, tzinfo=timezone.utc)
|
||||||
|
# Round-trip through the wire format, exactly as a request does.
|
||||||
|
after = decode_cursor(
|
||||||
|
encode_cursor({"finished_at": finished_at, "id": match_id})
|
||||||
|
)
|
||||||
|
assert after is not None
|
||||||
|
|
||||||
|
qs = _CapturingQuerySet()
|
||||||
|
await paginate(qs, SORT, Cursor(limit=1, after=after))
|
||||||
|
|
||||||
|
assert qs.queryset_filter is not None
|
||||||
|
for key in ("finished_at", "finished_at__lt"):
|
||||||
|
values = _filter_values(qs.queryset_filter, key)
|
||||||
|
self.assertEqual(1, len(values))
|
||||||
|
self.assertIsInstance(values[0], datetime)
|
||||||
|
self.assertEqual(finished_at, values[0])
|
||||||
|
id_values = _filter_values(qs.queryset_filter, "id__gt")
|
||||||
|
self.assertEqual(1, len(id_values))
|
||||||
|
self.assertIsInstance(id_values[0], uuid.UUID)
|
||||||
|
self.assertEqual(match_id, id_values[0])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
"""Game lobby route tests via kaya's ASGI transport, on the DummyEngine."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from helpers import async_test, make_platform, oidc_user
|
||||||
|
|
||||||
|
|
||||||
|
class GamesRouteTest(unittest.TestCase):
|
||||||
|
@async_test
|
||||||
|
async def test_create_requires_auth(self) -> None:
|
||||||
|
app, _, _ = make_platform()
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
response = await client.post("/api/games", json={})
|
||||||
|
self.assertEqual(401, response.status_code)
|
||||||
|
self.assertEqual({"error": "unauthenticated"}, response.json())
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_create_and_read_lobby(self) -> None:
|
||||||
|
app, platform, _ = make_platform()
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
with oidc_user(platform.oidc, "alice"):
|
||||||
|
created = await client.post(
|
||||||
|
"/api/games", json={"options": {"target": 5}}
|
||||||
|
)
|
||||||
|
self.assertEqual(201, created.status_code)
|
||||||
|
body = created.json()
|
||||||
|
self.assertEqual("lobby", body["phase"])
|
||||||
|
self.assertEqual(1, body["seats_open"])
|
||||||
|
self.assertEqual(5, body["target"])
|
||||||
|
self.assertEqual("dummy", body["game_type"])
|
||||||
|
self.assertEqual("A", body["players"][0]["team"])
|
||||||
|
self.assertEqual(6, len(body["join_code"]))
|
||||||
|
game_id = body["id"]
|
||||||
|
|
||||||
|
with oidc_user(platform.oidc, "alice"):
|
||||||
|
snapshot = await client.get(f"/api/games/{game_id}")
|
||||||
|
self.assertEqual(200, snapshot.status_code)
|
||||||
|
snap = snapshot.json()
|
||||||
|
self.assertEqual(game_id, snap["id"])
|
||||||
|
self.assertEqual("dummy", snap["game_type"])
|
||||||
|
self.assertTrue(snap["viewer_seated"])
|
||||||
|
self.assertFalse(snap["spectator"])
|
||||||
|
self.assertIn("join_code", snap)
|
||||||
|
|
||||||
|
# A logged-in non-player is a read-only spectator: the view
|
||||||
|
# marks it and the join code is withheld.
|
||||||
|
with oidc_user(platform.oidc, "mallory"):
|
||||||
|
watched = await client.get(f"/api/games/{game_id}")
|
||||||
|
self.assertEqual(200, watched.status_code)
|
||||||
|
spect = watched.json()
|
||||||
|
self.assertFalse(spect["viewer_seated"])
|
||||||
|
self.assertTrue(spect["spectator"])
|
||||||
|
self.assertNotIn("join_code", spect)
|
||||||
|
|
||||||
|
# Anonymous users may watch too.
|
||||||
|
anonymous = await client.get(f"/api/games/{game_id}")
|
||||||
|
self.assertEqual(200, anonymous.status_code)
|
||||||
|
self.assertTrue(anonymous.json()["spectator"])
|
||||||
|
self.assertNotIn("join_code", anonymous.json())
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_join_starts_game(self) -> None:
|
||||||
|
app, platform, _ = make_platform()
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
with oidc_user(platform.oidc, "alice"):
|
||||||
|
created = await client.post("/api/games", json={})
|
||||||
|
code = created.json()["join_code"]
|
||||||
|
|
||||||
|
with oidc_user(platform.oidc, "bob"):
|
||||||
|
started = await client.post("/api/games/join", json={"code": code})
|
||||||
|
self.assertEqual(200, started.status_code)
|
||||||
|
state = started.json()
|
||||||
|
# The second join started the match, so the response is the
|
||||||
|
# personalized view rather than the lobby payload.
|
||||||
|
self.assertTrue(state["started"])
|
||||||
|
self.assertFalse(state["finished"])
|
||||||
|
self.assertEqual(0, state["plays"])
|
||||||
|
self.assertTrue(state["viewer_seated"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_preview_requires_auth(self) -> None:
|
||||||
|
app, _, _ = make_platform()
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
response = await client.get("/api/games/preview?code=ABC123")
|
||||||
|
self.assertEqual(401, response.status_code)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_preview_lobby_and_started_game(self) -> None:
|
||||||
|
app, platform, _ = make_platform()
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
with oidc_user(platform.oidc, "alice"):
|
||||||
|
created = await client.post(
|
||||||
|
"/api/games", json={"options": {"target": 5}}
|
||||||
|
)
|
||||||
|
code = created.json()["join_code"]
|
||||||
|
|
||||||
|
with oidc_user(platform.oidc, "mallory"):
|
||||||
|
missing = await client.get("/api/games/preview")
|
||||||
|
unknown = await client.get("/api/games/preview?code=ZZZZZZ")
|
||||||
|
self.assertEqual(400, missing.status_code)
|
||||||
|
self.assertEqual(404, unknown.status_code)
|
||||||
|
|
||||||
|
with oidc_user(platform.oidc, "mallory"):
|
||||||
|
preview = await client.get(f"/api/games/preview?code={code}")
|
||||||
|
self.assertEqual(200, preview.status_code)
|
||||||
|
body = preview.json()
|
||||||
|
self.assertEqual("dummy", body["game_type"])
|
||||||
|
self.assertEqual("Dummy game", body["game_name"])
|
||||||
|
self.assertTrue(body["game_description"])
|
||||||
|
self.assertEqual("lobby", body["phase"])
|
||||||
|
self.assertEqual(5, body["target"])
|
||||||
|
self.assertEqual(1, body["seats_open"])
|
||||||
|
self.assertEqual("alice", body["players"][0]["sub"])
|
||||||
|
|
||||||
|
# Join codes are case-insensitive.
|
||||||
|
with oidc_user(platform.oidc, "mallory"):
|
||||||
|
lower = await client.get(f"/api/games/preview?code={code.lower()}")
|
||||||
|
self.assertEqual(200, lower.status_code)
|
||||||
|
|
||||||
|
with oidc_user(platform.oidc, "bob"):
|
||||||
|
await client.post("/api/games/join", json={"code": code})
|
||||||
|
with oidc_user(platform.oidc, "mallory"):
|
||||||
|
started = await client.get(f"/api/games/preview?code={code}")
|
||||||
|
self.assertEqual(200, started.status_code)
|
||||||
|
self.assertEqual("playing", started.json()["phase"])
|
||||||
|
self.assertEqual(0, started.json()["seats_open"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_create_rejects_bad_options(self) -> None:
|
||||||
|
app, platform, _ = make_platform()
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
with oidc_user(platform.oidc, "alice"):
|
||||||
|
zero = await client.post(
|
||||||
|
"/api/games", json={"options": {"target": 0}}
|
||||||
|
)
|
||||||
|
text = await client.post(
|
||||||
|
"/api/games", json={"options": {"target": "three"}}
|
||||||
|
)
|
||||||
|
non_object = await client.post(
|
||||||
|
"/api/games", json={"options": [1, 2]}
|
||||||
|
)
|
||||||
|
self.assertEqual(400, zero.status_code)
|
||||||
|
self.assertEqual(400, text.status_code)
|
||||||
|
self.assertEqual(400, non_object.status_code)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_join_errors(self) -> None:
|
||||||
|
app, platform, _ = make_platform()
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
with oidc_user(platform.oidc, "alice"):
|
||||||
|
created = await client.post("/api/games", json={})
|
||||||
|
code = created.json()["join_code"]
|
||||||
|
|
||||||
|
with oidc_user(platform.oidc, "bob"):
|
||||||
|
unknown = await client.post("/api/games/join", json={"code": "ZZZZZZ"})
|
||||||
|
self.assertEqual(404, unknown.status_code)
|
||||||
|
|
||||||
|
with oidc_user(platform.oidc, "alice"):
|
||||||
|
duplicate = await client.post("/api/games/join", json={"code": code})
|
||||||
|
self.assertEqual(409, duplicate.status_code)
|
||||||
|
|
||||||
|
with oidc_user(platform.oidc, "bob"):
|
||||||
|
missing = await client.post("/api/games/join", json={})
|
||||||
|
self.assertEqual(400, missing.status_code)
|
||||||
|
|
||||||
|
with oidc_user(platform.oidc, "bob"):
|
||||||
|
await client.post("/api/games/join", json={"code": code})
|
||||||
|
with oidc_user(platform.oidc, "erin"):
|
||||||
|
late = await client.post("/api/games/join", json={"code": code})
|
||||||
|
self.assertEqual(409, late.status_code)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_get_unknown_game(self) -> None:
|
||||||
|
app, platform, _ = make_platform()
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
with oidc_user(platform.oidc, "alice"):
|
||||||
|
response = await client.get("/api/games/does-not-exist")
|
||||||
|
self.assertEqual(404, response.status_code)
|
||||||
|
|
||||||
|
|
||||||
|
class GameTypesRouteTest(unittest.TestCase):
|
||||||
|
@async_test
|
||||||
|
async def test_lists_available_game_types(self) -> None:
|
||||||
|
app, _, _ = make_platform()
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
response = await client.get("/api/game-types")
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
results = response.json()["results"]
|
||||||
|
self.assertEqual(["dummy"], [g["id"] for g in results])
|
||||||
|
self.assertEqual("Dummy game", results[0]["name"])
|
||||||
|
self.assertTrue(results[0]["description"])
|
||||||
|
self.assertEqual(2, results[0]["min_players"])
|
||||||
|
self.assertEqual(2, results[0]["max_players"])
|
||||||
|
self.assertIn("target", results[0]["options_schema"]["properties"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_create_defaults_game_type(self) -> None:
|
||||||
|
app, platform, _ = make_platform()
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
with oidc_user(platform.oidc, "alice"):
|
||||||
|
created = await client.post("/api/games", json={})
|
||||||
|
self.assertEqual(201, created.status_code)
|
||||||
|
self.assertEqual("dummy", created.json()["game_type"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_create_with_explicit_game_type(self) -> None:
|
||||||
|
app, platform, _ = make_platform()
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
with oidc_user(platform.oidc, "alice"):
|
||||||
|
created = await client.post(
|
||||||
|
"/api/games", json={"game_type": "dummy"}
|
||||||
|
)
|
||||||
|
self.assertEqual(201, created.status_code)
|
||||||
|
body = created.json()
|
||||||
|
self.assertEqual("dummy", body["game_type"])
|
||||||
|
|
||||||
|
with oidc_user(platform.oidc, "alice"):
|
||||||
|
snapshot = await client.get(f"/api/games/{body['id']}")
|
||||||
|
self.assertEqual("dummy", snapshot.json()["game_type"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_create_rejects_unknown_game_type(self) -> None:
|
||||||
|
app, platform, _ = make_platform()
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
with oidc_user(platform.oidc, "alice"):
|
||||||
|
unknown = await client.post("/api/games", json={"game_type": "briscola"})
|
||||||
|
non_string = await client.post("/api/games", json={"game_type": 42})
|
||||||
|
self.assertEqual(400, unknown.status_code)
|
||||||
|
self.assertEqual(400, non_string.status_code)
|
||||||
|
|
||||||
|
|
||||||
|
class MeRouteTest(unittest.TestCase):
|
||||||
|
@async_test
|
||||||
|
async def test_me_authenticated(self) -> None:
|
||||||
|
app, platform, _ = make_platform()
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
with oidc_user(platform.oidc, "alice"):
|
||||||
|
response = await client.get("/api/me")
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
self.assertEqual({"sub": "alice", "name": "alice"}, response.json())
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_me_unauthenticated(self) -> None:
|
||||||
|
app, _, _ = make_platform()
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
response = await client.get("/api/me")
|
||||||
|
self.assertEqual(401, response.status_code)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Tests for the provider-agnostic auth routes.
|
||||||
|
|
||||||
|
``GET /api/auth/providers`` feeds the SPA's login buttons; ``/auth/login``
|
||||||
|
and ``/auth/logout`` are the generic aliases that redirect to the right
|
||||||
|
per-provider kaya route.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from helpers import async_test, make_platform
|
||||||
|
|
||||||
|
|
||||||
|
class AuthProvidersRouteTest(unittest.TestCase):
|
||||||
|
@async_test
|
||||||
|
async def test_lists_providers_in_configuration_order(self) -> None:
|
||||||
|
app, _platform, _tortoise = make_platform(oidc_providers=("default", "google"))
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
response = await client.get("/api/auth/providers")
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"providers": [
|
||||||
|
{"name": "default", "login_url": "/auth/default/login"},
|
||||||
|
{"name": "google", "login_url": "/auth/google/login"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
response.json(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GenericAuthRedirectTest(unittest.TestCase):
|
||||||
|
@async_test
|
||||||
|
async def test_login_redirects_to_the_first_provider(self) -> None:
|
||||||
|
app, _platform, _tortoise = make_platform(oidc_providers=("google", "github"))
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
response = await client.get("/auth/login", follow_redirects=False)
|
||||||
|
self.assertEqual(302, response.status_code)
|
||||||
|
self.assertEqual("/auth/google/login", response.headers["location"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_logout_redirects_to_the_logged_in_provider(self) -> None:
|
||||||
|
app, platform, _tortoise = make_platform()
|
||||||
|
with mock.patch.object(
|
||||||
|
platform.oidc, "logged_in_providers", return_value=["default"]
|
||||||
|
):
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(
|
||||||
|
transport=transport, base_url="http://127.0.0.1"
|
||||||
|
) as client:
|
||||||
|
response = await client.get("/auth/logout", follow_redirects=False)
|
||||||
|
self.assertEqual(302, response.status_code)
|
||||||
|
self.assertEqual("/auth/default/logout", response.headers["location"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_logout_without_a_session_redirects_home(self) -> None:
|
||||||
|
app, _platform, _tortoise = make_platform()
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
response = await client.get("/auth/logout", follow_redirects=False)
|
||||||
|
self.assertEqual(302, response.status_code)
|
||||||
|
self.assertEqual("/", response.headers["location"])
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
"""Match-statistics tests: Postgres persistence and the stats endpoints."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from tavolo.platform.elo import INITIAL_RATING
|
||||||
|
from tavolo.platform.models import Match, MatchPlayer, PlayerRating
|
||||||
|
from tavolo.platform.stats import save_match_result
|
||||||
|
from helpers import DummyEngine, async_test, make_platform, oidc_user, use_db
|
||||||
|
|
||||||
|
|
||||||
|
def _finished_session(target: int = 2):
|
||||||
|
"""A started dummy session one play short of completion."""
|
||||||
|
from tavolo.platform import GameSession, Seat
|
||||||
|
|
||||||
|
engine = DummyEngine()
|
||||||
|
session = GameSession(
|
||||||
|
id="stats-game",
|
||||||
|
game_type=engine.id,
|
||||||
|
join_code="STATS1",
|
||||||
|
creator_sub="alice",
|
||||||
|
players=[Seat(user_sub="alice", display_name="alice", team="A")],
|
||||||
|
)
|
||||||
|
engine.create(session, {"target": target})
|
||||||
|
engine.join(session, "bob", "bob")
|
||||||
|
engine.handle_action(session, "alice", "play", {})
|
||||||
|
return engine, session
|
||||||
|
|
||||||
|
|
||||||
|
class SaveMatchResultTest(unittest.TestCase):
|
||||||
|
@async_test
|
||||||
|
async def test_finished_match_is_persisted_once(self) -> None:
|
||||||
|
_, _, tortoise_mixin = make_platform()
|
||||||
|
ctx = await use_db(tortoise_mixin)
|
||||||
|
engine, session = _finished_session()
|
||||||
|
engine.handle_action(session, "alice", "play", {})
|
||||||
|
self.assertTrue(engine.is_finished(session))
|
||||||
|
|
||||||
|
with ctx:
|
||||||
|
await save_match_result(session, engine)
|
||||||
|
await save_match_result(session, engine) # idempotent
|
||||||
|
self.assertEqual(1, await Match.all().count())
|
||||||
|
self.assertEqual(2, await MatchPlayer.all().count())
|
||||||
|
|
||||||
|
match = await Match.all().first()
|
||||||
|
assert match is not None
|
||||||
|
# The game type travels from the session onto the row; the
|
||||||
|
# engine's summary is stored verbatim as the result.
|
||||||
|
self.assertEqual("dummy", match.game_type)
|
||||||
|
self.assertEqual("alice", match.result["winner"])
|
||||||
|
self.assertEqual(2, match.result["plays"])
|
||||||
|
winners = await MatchPlayer.filter(won=True)
|
||||||
|
self.assertEqual({"alice"}, {p.user_sub for p in winners})
|
||||||
|
scores = {p.user_sub: p.score for p in await MatchPlayer.all()}
|
||||||
|
self.assertEqual({"alice": 2.0, "bob": 0.0}, scores)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_finished_match_updates_elo_ratings(self) -> None:
|
||||||
|
_, _, tortoise_mixin = make_platform()
|
||||||
|
ctx = await use_db(tortoise_mixin)
|
||||||
|
engine, session = _finished_session()
|
||||||
|
engine.handle_action(session, "alice", "play", {})
|
||||||
|
|
||||||
|
with ctx:
|
||||||
|
await save_match_result(session, engine)
|
||||||
|
|
||||||
|
ratings = {
|
||||||
|
row.user_sub: row for row in await PlayerRating.all()
|
||||||
|
}
|
||||||
|
self.assertEqual(2, len(ratings))
|
||||||
|
# Two players at 1500: winner gains K/2, loser loses it.
|
||||||
|
self.assertEqual(INITIAL_RATING + 16, ratings["alice"].rating)
|
||||||
|
self.assertEqual(1, ratings["alice"].matches_played)
|
||||||
|
self.assertEqual(INITIAL_RATING - 16, ratings["bob"].rating)
|
||||||
|
self.assertEqual(1, ratings["bob"].matches_played)
|
||||||
|
|
||||||
|
# The per-match delta is recorded on each participation row.
|
||||||
|
deltas = {
|
||||||
|
p.user_sub: p.elo_delta for p in await MatchPlayer.all()
|
||||||
|
}
|
||||||
|
self.assertEqual({"alice": 16, "bob": -16}, deltas)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_elo_ratings_accumulate_across_matches(self) -> None:
|
||||||
|
_, _, tortoise_mixin = make_platform()
|
||||||
|
ctx = await use_db(tortoise_mixin)
|
||||||
|
engine, session = _finished_session()
|
||||||
|
engine.handle_action(session, "alice", "play", {})
|
||||||
|
|
||||||
|
from tavolo.platform import GameSession, Seat
|
||||||
|
|
||||||
|
engine2, session2 = DummyEngine(), GameSession(
|
||||||
|
id="stats-game-2",
|
||||||
|
game_type="dummy",
|
||||||
|
join_code="STATS2",
|
||||||
|
creator_sub="alice",
|
||||||
|
players=[Seat(user_sub="alice", display_name="alice", team="A")],
|
||||||
|
)
|
||||||
|
engine2.create(session2, {"target": 2})
|
||||||
|
engine2.join(session2, "bob", "bob")
|
||||||
|
# Bob wins the second match.
|
||||||
|
engine2.handle_action(session2, "bob", "play", {})
|
||||||
|
engine2.handle_action(session2, "bob", "play", {})
|
||||||
|
|
||||||
|
with ctx:
|
||||||
|
await save_match_result(session, engine)
|
||||||
|
await save_match_result(session2, engine2)
|
||||||
|
|
||||||
|
ratings = {
|
||||||
|
row.user_sub: row.rating for row in await PlayerRating.all()
|
||||||
|
}
|
||||||
|
# Match 1: even teams, alice wins (+16/-16). Match 2: alice
|
||||||
|
# is now the favourite (1516 vs 1484), so losing costs 17.
|
||||||
|
self.assertEqual(INITIAL_RATING - 1, ratings["alice"])
|
||||||
|
self.assertEqual(INITIAL_RATING + 1, ratings["bob"])
|
||||||
|
bob = await PlayerRating.get(user_sub="bob")
|
||||||
|
self.assertEqual(2, bob.matches_played)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_unfinished_match_is_not_persisted(self) -> None:
|
||||||
|
_, _, tortoise_mixin = make_platform()
|
||||||
|
ctx = await use_db(tortoise_mixin)
|
||||||
|
engine, session = _finished_session()
|
||||||
|
with ctx:
|
||||||
|
await save_match_result(session, engine)
|
||||||
|
self.assertEqual(0, await Match.all().count())
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_two_matches(
|
||||||
|
tortoise_mixin, game_types: tuple = ("dummy", "dummy")
|
||||||
|
) -> None:
|
||||||
|
ctx = await use_db(tortoise_mixin)
|
||||||
|
with ctx:
|
||||||
|
for index, (winner, finished) in enumerate(
|
||||||
|
[
|
||||||
|
("alice", datetime(2026, 1, 1, 10, tzinfo=timezone.utc)),
|
||||||
|
("bob", datetime(2026, 1, 2, 10, tzinfo=timezone.utc)),
|
||||||
|
]
|
||||||
|
):
|
||||||
|
match = await Match.create(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
game_type=game_types[index],
|
||||||
|
started_at=datetime(2025, 12, 31, tzinfo=timezone.utc),
|
||||||
|
finished_at=finished,
|
||||||
|
result={"winner": winner, "plays": 3 + index},
|
||||||
|
)
|
||||||
|
for seat, sub in enumerate(("alice", "bob")):
|
||||||
|
await MatchPlayer.create(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
match=match,
|
||||||
|
user_sub=sub,
|
||||||
|
display_name=sub,
|
||||||
|
seat=seat,
|
||||||
|
team="A" if seat == 0 else "B",
|
||||||
|
won=(sub == winner),
|
||||||
|
score=2.0 + index if sub == winner else 1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StatsRouteTest(unittest.TestCase):
|
||||||
|
@async_test
|
||||||
|
async def test_my_matches_newest_first(self) -> None:
|
||||||
|
app, platform, tortoise_mixin = make_platform()
|
||||||
|
await _seed_two_matches(tortoise_mixin)
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
with oidc_user(platform.oidc, "alice"):
|
||||||
|
response = await client.get("/api/me/matches")
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
results = response.json()["results"]
|
||||||
|
self.assertEqual(2, len(results))
|
||||||
|
self.assertEqual("bob", results[0]["result"]["winner"]) # newest first
|
||||||
|
self.assertFalse(results[0]["you_won"])
|
||||||
|
self.assertTrue(results[1]["you_won"])
|
||||||
|
self.assertEqual(2, len(results[0]["players"]))
|
||||||
|
self.assertIn("next_cursor", response.json())
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_my_matches_pagination(self) -> None:
|
||||||
|
app, platform, tortoise_mixin = make_platform()
|
||||||
|
await _seed_two_matches(tortoise_mixin)
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
with oidc_user(platform.oidc, "alice"):
|
||||||
|
first = await client.get("/api/me/matches?limit=1")
|
||||||
|
cursor = first.json()["next_cursor"]
|
||||||
|
self.assertIsNotNone(cursor)
|
||||||
|
second = await client.get(f"/api/me/matches?limit=1&cursor={cursor}")
|
||||||
|
self.assertEqual(1, len(first.json()["results"]))
|
||||||
|
self.assertEqual(1, len(second.json()["results"]))
|
||||||
|
self.assertNotEqual(
|
||||||
|
first.json()["results"][0]["id"],
|
||||||
|
second.json()["results"][0]["id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_my_matches_fetches_participants_in_one_query(self) -> None:
|
||||||
|
app, platform, tortoise_mixin = make_platform()
|
||||||
|
await _seed_two_matches(tortoise_mixin)
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
with mock.patch.object(
|
||||||
|
MatchPlayer, "filter", wraps=MatchPlayer.filter
|
||||||
|
) as spy:
|
||||||
|
async with AsyncClient(
|
||||||
|
transport=transport, base_url="http://127.0.0.1"
|
||||||
|
) as client:
|
||||||
|
with oidc_user(platform.oidc, "alice"):
|
||||||
|
response = await client.get("/api/me/matches")
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
# One query for all matches, not one per match row.
|
||||||
|
self.assertEqual(1, spy.call_count)
|
||||||
|
results = response.json()["results"]
|
||||||
|
self.assertEqual(2, len(results))
|
||||||
|
for result in results:
|
||||||
|
seats = [player["seat"] for player in result["players"]]
|
||||||
|
self.assertEqual([0, 1], seats)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_my_matches_requires_auth(self) -> None:
|
||||||
|
app, _, _ = make_platform()
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
response = await client.get("/api/me/matches")
|
||||||
|
self.assertEqual(401, response.status_code)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_leaderboard_aggregates(self) -> None:
|
||||||
|
app, _, tortoise_mixin = make_platform()
|
||||||
|
await _seed_two_matches(tortoise_mixin)
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
response = await client.get("/api/leaderboard")
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
by_sub = {row["user_sub"]: row for row in response.json()["results"]}
|
||||||
|
self.assertEqual(2, by_sub["alice"]["matches"])
|
||||||
|
self.assertEqual(1, by_sub["alice"]["wins"]) # alice won match 1
|
||||||
|
self.assertEqual(3.0, by_sub["alice"]["points"]) # 2.0 + 1.0
|
||||||
|
self.assertEqual(1, by_sub["bob"]["wins"]) # bob won match 2
|
||||||
|
self.assertEqual(4.0, by_sub["bob"]["points"]) # 1.0 + 3.0
|
||||||
|
# Bob leads on points after tying Alice on wins.
|
||||||
|
self.assertEqual("bob", response.json()["results"][0]["user_sub"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_leaderboard_includes_elo_and_sorts_by_it(self) -> None:
|
||||||
|
app, _, tortoise_mixin = make_platform()
|
||||||
|
await _seed_two_matches(tortoise_mixin)
|
||||||
|
ctx = await use_db(tortoise_mixin)
|
||||||
|
with ctx:
|
||||||
|
# Alice outranks everyone despite Bob leading on points.
|
||||||
|
await PlayerRating.create(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_sub="alice",
|
||||||
|
game_type="dummy",
|
||||||
|
rating=1600,
|
||||||
|
matches_played=2,
|
||||||
|
)
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
response = await client.get("/api/leaderboard")
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
results = response.json()["results"]
|
||||||
|
by_sub = {row["user_sub"]: row for row in results}
|
||||||
|
self.assertEqual(1600, by_sub["alice"]["elo"])
|
||||||
|
# Players without a rating row report the initial rating.
|
||||||
|
self.assertEqual(INITIAL_RATING, by_sub["bob"]["elo"])
|
||||||
|
# Elo outranks wins/points.
|
||||||
|
self.assertEqual("alice", results[0]["user_sub"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_leaderboard_elo_scoped_by_game_type(self) -> None:
|
||||||
|
app, platform, tortoise_mixin = make_platform(
|
||||||
|
engines=(DummyEngine(), SecondEngine())
|
||||||
|
)
|
||||||
|
await _seed_two_matches(tortoise_mixin)
|
||||||
|
ctx = await use_db(tortoise_mixin)
|
||||||
|
with ctx:
|
||||||
|
await PlayerRating.create(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_sub="bob",
|
||||||
|
game_type="dummy",
|
||||||
|
rating=1516,
|
||||||
|
matches_played=1,
|
||||||
|
)
|
||||||
|
# Bob's rating in another game must not leak into the
|
||||||
|
# dummy leaderboard.
|
||||||
|
await PlayerRating.create(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_sub="bob",
|
||||||
|
game_type="second",
|
||||||
|
rating=1800,
|
||||||
|
matches_played=1,
|
||||||
|
)
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
response = await client.get("/api/leaderboard?game_type=dummy")
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
results = response.json()["results"]
|
||||||
|
by_sub = {row["user_sub"]: row for row in results}
|
||||||
|
self.assertEqual(1516, by_sub["bob"]["elo"])
|
||||||
|
self.assertEqual(INITIAL_RATING, by_sub["alice"]["elo"])
|
||||||
|
self.assertEqual("second", platform.registry.all()[1].id)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_my_matches_include_elo_delta(self) -> None:
|
||||||
|
app, platform, tortoise_mixin = make_platform()
|
||||||
|
ctx = await use_db(tortoise_mixin)
|
||||||
|
engine, session = _finished_session()
|
||||||
|
engine.handle_action(session, "alice", "play", {})
|
||||||
|
with ctx:
|
||||||
|
await save_match_result(session, engine)
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
with oidc_user(platform.oidc, "alice"):
|
||||||
|
response = await client.get("/api/me/matches")
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
players = {
|
||||||
|
p["user_sub"]: p
|
||||||
|
for p in response.json()["results"][0]["players"]
|
||||||
|
}
|
||||||
|
self.assertEqual(16, players["alice"]["elo_delta"])
|
||||||
|
self.assertEqual(-16, players["bob"]["elo_delta"])
|
||||||
|
self.assertEqual(16, response.json()["results"][0]["your_elo_delta"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_my_ratings_requires_auth(self) -> None:
|
||||||
|
app, _, _ = make_platform()
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
response = await client.get("/api/me/ratings")
|
||||||
|
self.assertEqual(401, response.status_code)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_my_ratings_returns_only_own_rows(self) -> None:
|
||||||
|
app, platform, tortoise_mixin = make_platform()
|
||||||
|
ctx = await use_db(tortoise_mixin)
|
||||||
|
with ctx:
|
||||||
|
await PlayerRating.create(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_sub="alice",
|
||||||
|
game_type="dummy",
|
||||||
|
rating=1516,
|
||||||
|
matches_played=1,
|
||||||
|
)
|
||||||
|
await PlayerRating.create(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_sub="bob",
|
||||||
|
game_type="dummy",
|
||||||
|
rating=1484,
|
||||||
|
matches_played=1,
|
||||||
|
)
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
with oidc_user(platform.oidc, "alice"):
|
||||||
|
response = await client.get("/api/me/ratings")
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
self.assertEqual(
|
||||||
|
[{"game_type": "dummy", "rating": 1516, "matches_played": 1}],
|
||||||
|
response.json()["results"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SecondEngine(DummyEngine):
|
||||||
|
id = "second"
|
||||||
|
name = "Second game"
|
||||||
|
|
||||||
|
|
||||||
|
class GameTypeFilterTest(unittest.TestCase):
|
||||||
|
"""Stats endpoints scope results by the match's game type."""
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_my_matches_filter_by_game_type(self) -> None:
|
||||||
|
# The second seed names a game the registry does not know; rows are
|
||||||
|
# written directly, so this only exercises the SQL filter.
|
||||||
|
app, platform, tortoise_mixin = make_platform()
|
||||||
|
await _seed_two_matches(tortoise_mixin, game_types=("dummy", "other_game"))
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
with oidc_user(platform.oidc, "alice"):
|
||||||
|
all_matches = await client.get("/api/me/matches")
|
||||||
|
scoped = await client.get("/api/me/matches?game_type=dummy")
|
||||||
|
unknown = await client.get("/api/me/matches?game_type=briscola")
|
||||||
|
self.assertEqual(2, len(all_matches.json()["results"]))
|
||||||
|
self.assertEqual(
|
||||||
|
{"dummy", "other_game"},
|
||||||
|
{m["game_type"] for m in all_matches.json()["results"]},
|
||||||
|
)
|
||||||
|
scoped_results = scoped.json()["results"]
|
||||||
|
self.assertEqual(1, len(scoped_results))
|
||||||
|
self.assertEqual("dummy", scoped_results[0]["game_type"])
|
||||||
|
self.assertEqual(400, unknown.status_code)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_leaderboard_filter_by_game_type(self) -> None:
|
||||||
|
app, platform, tortoise_mixin = make_platform()
|
||||||
|
await _seed_two_matches(tortoise_mixin, game_types=("dummy", "other_game"))
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
scoped = await client.get("/api/leaderboard?game_type=dummy")
|
||||||
|
unknown = await client.get("/api/leaderboard?game_type=briscola")
|
||||||
|
self.assertEqual(200, scoped.status_code)
|
||||||
|
by_sub = {row["user_sub"]: row for row in scoped.json()["results"]}
|
||||||
|
# Only the first match counts: one match per player, alice won.
|
||||||
|
self.assertEqual(1, by_sub["alice"]["matches"])
|
||||||
|
self.assertEqual(1, by_sub["alice"]["wins"])
|
||||||
|
self.assertEqual(0, by_sub["bob"]["wins"])
|
||||||
|
self.assertEqual(400, unknown.status_code)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
"""In-memory game store behaviour (the Redis store shares this interface)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from typing import Any, Optional, cast
|
||||||
|
|
||||||
|
from tavolo.platform import GameSession, Seat
|
||||||
|
from tavolo.platform.registry import GameRegistry
|
||||||
|
from tavolo.platform.store import (
|
||||||
|
DEADLINES_KEY,
|
||||||
|
ONGOING_GAMES_KEY,
|
||||||
|
InMemoryGameStore,
|
||||||
|
RedisGameStore,
|
||||||
|
)
|
||||||
|
from helpers import DummyEngine, async_test
|
||||||
|
|
||||||
|
|
||||||
|
def _registry() -> GameRegistry:
|
||||||
|
return GameRegistry([DummyEngine()])
|
||||||
|
|
||||||
|
|
||||||
|
def _session(game_id: str = "g1", code: str = "CODE01") -> GameSession:
|
||||||
|
engine = DummyEngine()
|
||||||
|
session = GameSession(
|
||||||
|
id=game_id,
|
||||||
|
game_type=engine.id,
|
||||||
|
join_code=code,
|
||||||
|
creator_sub="alice",
|
||||||
|
players=[Seat(user_sub="alice", display_name="alice", team="A")],
|
||||||
|
)
|
||||||
|
engine.create(session, {"target": 5})
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
class _FakePipeline:
|
||||||
|
"""Records commands and applies them to the fake Redis on execute."""
|
||||||
|
|
||||||
|
def __init__(self, redis: "_FakeRedis") -> None:
|
||||||
|
self._redis = redis
|
||||||
|
self._ops: list = []
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "_FakePipeline":
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc: object) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def set(self, key: str, value: str, ex: object = None) -> None:
|
||||||
|
self._ops.append(("set", key))
|
||||||
|
|
||||||
|
def zadd(self, key: str, mapping: dict) -> None:
|
||||||
|
self._ops.append(("zadd", key, mapping))
|
||||||
|
|
||||||
|
def zrem(self, key: str, member: str) -> None:
|
||||||
|
self._ops.append(("zrem", key, member))
|
||||||
|
|
||||||
|
def expire(self, key: str, ttl: int) -> None:
|
||||||
|
self._ops.append(("expire", key))
|
||||||
|
|
||||||
|
def zremrangebyscore(self, key: str, minimum: object, maximum: object) -> None:
|
||||||
|
self._ops.append(("zremrangebyscore", key))
|
||||||
|
|
||||||
|
def zcard(self, key: str) -> None:
|
||||||
|
self._ops.append(("zcard", key))
|
||||||
|
|
||||||
|
async def execute(self) -> list:
|
||||||
|
results = [self._redis.apply(op) for op in self._ops]
|
||||||
|
self._ops = []
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeRedis:
|
||||||
|
"""The slice of the Redis client the game store uses."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.keys: dict = {}
|
||||||
|
self.zsets: dict = {}
|
||||||
|
self.zrange_calls: list = []
|
||||||
|
|
||||||
|
def pipeline(self, transaction: bool = True) -> _FakePipeline:
|
||||||
|
return _FakePipeline(self)
|
||||||
|
|
||||||
|
async def zadd(self, key: str, mapping: dict) -> int:
|
||||||
|
return cast(int, self.apply(("zadd", key, mapping)))
|
||||||
|
|
||||||
|
async def zrem(self, key: str, member: str) -> int:
|
||||||
|
return cast(int, self.apply(("zrem", key, member)))
|
||||||
|
|
||||||
|
async def zrange(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
start: Any,
|
||||||
|
end: Any,
|
||||||
|
byscore: bool = False,
|
||||||
|
offset: Optional[int] = None,
|
||||||
|
num: Optional[int] = None,
|
||||||
|
**kwargs: object,
|
||||||
|
) -> list:
|
||||||
|
self.zrange_calls.append((name, start, end, byscore, offset, num))
|
||||||
|
members = sorted(
|
||||||
|
self.zsets.get(name, {}).items(), key=lambda item: item[1]
|
||||||
|
)
|
||||||
|
if byscore:
|
||||||
|
low = float("-inf") if start == "-inf" else float(start)
|
||||||
|
high = float("inf") if end == "+inf" else float(end)
|
||||||
|
members = [item for item in members if low <= item[1] <= high]
|
||||||
|
if offset is not None and num is not None:
|
||||||
|
members = members[offset : offset + num]
|
||||||
|
return [member for member, _ in members]
|
||||||
|
|
||||||
|
def apply(self, op: tuple) -> object:
|
||||||
|
name, *args = op
|
||||||
|
if name == "set":
|
||||||
|
self.keys[args[0]] = "payload"
|
||||||
|
return True
|
||||||
|
if name == "zadd":
|
||||||
|
self.zsets.setdefault(args[0], {}).update(args[1])
|
||||||
|
return 1
|
||||||
|
if name == "zrem":
|
||||||
|
self.zsets.get(args[0], {}).pop(args[1], None)
|
||||||
|
return 1
|
||||||
|
if name == "expire":
|
||||||
|
return True
|
||||||
|
if name == "zremrangebyscore":
|
||||||
|
zset = self.zsets.get(args[0], {})
|
||||||
|
for member in [
|
||||||
|
member
|
||||||
|
for member, score in zset.items()
|
||||||
|
if score <= time.time()
|
||||||
|
]:
|
||||||
|
del zset[member]
|
||||||
|
return 0
|
||||||
|
if name == "zcard":
|
||||||
|
return len(self.zsets.get(args[0], {}))
|
||||||
|
raise AssertionError(f"unexpected command: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryGameStoreTest(unittest.TestCase):
|
||||||
|
@async_test
|
||||||
|
async def test_save_load_roundtrip(self) -> None:
|
||||||
|
store = InMemoryGameStore(_registry())
|
||||||
|
session = _session()
|
||||||
|
await store.save(session)
|
||||||
|
|
||||||
|
loaded = await store.load("g1")
|
||||||
|
self.assertIsNotNone(loaded)
|
||||||
|
assert loaded is not None
|
||||||
|
self.assertEqual("CODE01", loaded.join_code)
|
||||||
|
self.assertEqual("dummy", loaded.game_type)
|
||||||
|
self.assertEqual(5, loaded.state["target"])
|
||||||
|
self.assertEqual(["alice"], [p.user_sub for p in loaded.players])
|
||||||
|
# The loaded state is a deserialized copy, not the same object.
|
||||||
|
self.assertIsNot(loaded.state, session.state)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_unknown_game_type_rejected(self) -> None:
|
||||||
|
store = InMemoryGameStore(_registry())
|
||||||
|
session = _session()
|
||||||
|
session.game_type = "nope"
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
await store.save(session)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_load_missing_returns_none(self) -> None:
|
||||||
|
store = InMemoryGameStore(_registry())
|
||||||
|
self.assertIsNone(await store.load("nope"))
|
||||||
|
self.assertIsNone(await store.find_by_code("NOPE01"))
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_find_by_code(self) -> None:
|
||||||
|
store = InMemoryGameStore(_registry())
|
||||||
|
await store.save(_session())
|
||||||
|
found = await store.find_by_code("code01") # case-insensitive
|
||||||
|
self.assertIsNotNone(found)
|
||||||
|
assert found is not None
|
||||||
|
self.assertEqual("g1", found.id)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_load_returns_a_copy(self) -> None:
|
||||||
|
store = InMemoryGameStore(_registry())
|
||||||
|
await store.save(_session())
|
||||||
|
first = await store.load("g1")
|
||||||
|
assert first is not None
|
||||||
|
first.state["target"] = 999
|
||||||
|
second = await store.load("g1")
|
||||||
|
assert second is not None
|
||||||
|
self.assertEqual(5, second.state["target"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_publish_reaches_subscriber(self) -> None:
|
||||||
|
store = InMemoryGameStore(_registry())
|
||||||
|
await store.save(_session())
|
||||||
|
|
||||||
|
received = []
|
||||||
|
|
||||||
|
async with store.subscribe("g1") as events:
|
||||||
|
await store.publish("g1")
|
||||||
|
async for _ in events:
|
||||||
|
received.append(True)
|
||||||
|
break
|
||||||
|
|
||||||
|
self.assertEqual([True], received)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_lock_serializes_concurrent_mutations(self) -> None:
|
||||||
|
store = InMemoryGameStore(_registry())
|
||||||
|
order = []
|
||||||
|
|
||||||
|
async def holder() -> None:
|
||||||
|
async with store.lock("g5"):
|
||||||
|
order.append("holder-enter")
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
order.append("holder-exit")
|
||||||
|
|
||||||
|
async def contender() -> None:
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
async with store.lock("g5"):
|
||||||
|
order.append("contender")
|
||||||
|
|
||||||
|
await asyncio.gather(holder(), contender())
|
||||||
|
self.assertEqual(
|
||||||
|
["holder-enter", "holder-exit", "contender"], order
|
||||||
|
)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_count_ongoing_tracks_unfinished_games(self) -> None:
|
||||||
|
store = InMemoryGameStore(_registry())
|
||||||
|
self.assertEqual(0, await store.count_ongoing())
|
||||||
|
|
||||||
|
session = _session()
|
||||||
|
await store.save(session)
|
||||||
|
self.assertEqual(1, await store.count_ongoing())
|
||||||
|
|
||||||
|
other = _session(game_id="g2", code="CODE02")
|
||||||
|
await store.save(other)
|
||||||
|
self.assertEqual(2, await store.count_ongoing())
|
||||||
|
|
||||||
|
# A finished match is retained for the final board but no longer
|
||||||
|
# counts as ongoing.
|
||||||
|
session.state["finished"] = True
|
||||||
|
await store.save(session)
|
||||||
|
self.assertEqual(1, await store.count_ongoing())
|
||||||
|
other.state["finished"] = True
|
||||||
|
await store.save(other)
|
||||||
|
self.assertEqual(0, await store.count_ongoing())
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_deadline_queue(self) -> None:
|
||||||
|
store = InMemoryGameStore(_registry())
|
||||||
|
self.assertIsNone(await store.next_deadline())
|
||||||
|
self.assertEqual([], await store.due_deadlines(now=100.0))
|
||||||
|
|
||||||
|
await store.add_deadline("b", due_at=50.0)
|
||||||
|
await store.add_deadline("a", due_at=10.0)
|
||||||
|
await store.add_deadline("c", due_at=200.0)
|
||||||
|
# Re-adding an existing member only updates its due time.
|
||||||
|
await store.add_deadline("b", due_at=60.0)
|
||||||
|
|
||||||
|
self.assertEqual(10.0, await store.next_deadline())
|
||||||
|
self.assertEqual(["a"], await store.due_deadlines(now=10.0))
|
||||||
|
self.assertEqual(["a", "b"], await store.due_deadlines(now=100.0))
|
||||||
|
# Due entries come out in due-time order and stay queued until removed.
|
||||||
|
self.assertEqual(["a", "b"], await store.due_deadlines(now=100.0))
|
||||||
|
|
||||||
|
await store.remove_deadline("a")
|
||||||
|
await store.remove_deadline("a") # removing twice is a no-op
|
||||||
|
self.assertEqual(60.0, await store.next_deadline())
|
||||||
|
self.assertEqual(["b"], await store.due_deadlines(now=100.0))
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_deadline_notifications_reach_subscribers(self) -> None:
|
||||||
|
store = InMemoryGameStore(_registry())
|
||||||
|
async with store.subscribe_deadlines() as events:
|
||||||
|
await store.notify_deadline()
|
||||||
|
signal = await asyncio.wait_for(anext(events), timeout=1.0)
|
||||||
|
self.assertIsNone(signal)
|
||||||
|
# The subscription is gone after the context exits: notifying
|
||||||
|
# again is a no-op rather than an error.
|
||||||
|
await store.notify_deadline()
|
||||||
|
|
||||||
|
|
||||||
|
class RedisGameStoreTest(unittest.TestCase):
|
||||||
|
@async_test
|
||||||
|
async def test_save_marks_games_ongoing_and_finished_ones_stop_counting(self) -> None:
|
||||||
|
redis = _FakeRedis()
|
||||||
|
store = RedisGameStore(redis, _registry(), ttl_seconds=3600) # type: ignore[arg-type]
|
||||||
|
session = _session()
|
||||||
|
|
||||||
|
await store.save(session)
|
||||||
|
self.assertEqual(1, await store.count_ongoing())
|
||||||
|
score = redis.zsets[ONGOING_GAMES_KEY][session.id]
|
||||||
|
self.assertGreater(score, time.time())
|
||||||
|
self.assertLessEqual(score, time.time() + 3600)
|
||||||
|
|
||||||
|
session.state["finished"] = True
|
||||||
|
await store.save(session)
|
||||||
|
self.assertNotIn(session.id, redis.zsets[ONGOING_GAMES_KEY])
|
||||||
|
self.assertEqual(0, await store.count_ongoing())
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_count_ongoing_prunes_expired_entries(self) -> None:
|
||||||
|
redis = _FakeRedis()
|
||||||
|
redis.zsets[ONGOING_GAMES_KEY] = {
|
||||||
|
"stale": time.time() - 10,
|
||||||
|
"live": time.time() + 100,
|
||||||
|
}
|
||||||
|
store = RedisGameStore(redis, _registry()) # type: ignore[arg-type]
|
||||||
|
self.assertEqual(1, await store.count_ongoing())
|
||||||
|
self.assertNotIn("stale", redis.zsets[ONGOING_GAMES_KEY])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_due_deadlines_uses_zrange_byscore(self) -> None:
|
||||||
|
redis = _FakeRedis()
|
||||||
|
store = RedisGameStore(redis, _registry()) # type: ignore[arg-type]
|
||||||
|
await store.add_deadline("b", due_at=50.0)
|
||||||
|
await store.add_deadline("a", due_at=10.0)
|
||||||
|
await store.add_deadline("c", due_at=200.0)
|
||||||
|
# Re-adding an existing member only updates its due time.
|
||||||
|
await store.add_deadline("b", due_at=60.0)
|
||||||
|
|
||||||
|
self.assertEqual(["a"], await store.due_deadlines(now=20.0))
|
||||||
|
self.assertEqual(
|
||||||
|
[(DEADLINES_KEY, "-inf", 20.0, True, 0, 32)], redis.zrange_calls
|
||||||
|
)
|
||||||
|
# Matches come back in due-time order and honour the limit.
|
||||||
|
self.assertEqual(["a"], await store.due_deadlines(now=100.0, limit=1))
|
||||||
|
self.assertEqual(["a", "b"], await store.due_deadlines(now=100.0))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
"""WebSocket live-play tests via kaya's ASGI websocket transport."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from typing import Any, Dict, Mapping, Sequence
|
||||||
|
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from httpx_ws import WebSocketDisconnect as HttpxWebSocketDisconnect, aconnect_ws
|
||||||
|
from httpx_ws.transport import ASGIWebSocketTransport
|
||||||
|
from kaya.core import WebSocket, WebSocketDisconnect, WebSocketMessage
|
||||||
|
|
||||||
|
from helpers import async_test, make_platform, make_user, oidc_user, ws_users
|
||||||
|
|
||||||
|
|
||||||
|
class AbruptCloseSocket(WebSocket):
|
||||||
|
"""A socket whose peer vanished without a close handshake.
|
||||||
|
|
||||||
|
kaya surfaces this from the transport: ``receive()`` reports a
|
||||||
|
``close`` message so iteration ends normally, while sends raise
|
||||||
|
``WebSocketDisconnect``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, path: str, gone: bool = False) -> None:
|
||||||
|
self.path = path
|
||||||
|
self.query_string = ""
|
||||||
|
self.scheme = "ws"
|
||||||
|
self.headers: Mapping[str, Sequence[str]] = {}
|
||||||
|
self.client = ("127.0.0.1", 12345)
|
||||||
|
self.server = ("127.0.0.1", 80)
|
||||||
|
self.sent: list[Dict[str, Any]] = []
|
||||||
|
self.close_codes: list[int] = []
|
||||||
|
self.gone = gone
|
||||||
|
|
||||||
|
async def accept(self, headers: Any = None) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def receive(self) -> WebSocketMessage:
|
||||||
|
self.gone = True
|
||||||
|
return WebSocketMessage(kind="close")
|
||||||
|
|
||||||
|
async def send_text(self, data: str) -> None:
|
||||||
|
if self.gone:
|
||||||
|
raise WebSocketDisconnect(code=1006)
|
||||||
|
self.sent.append(json.loads(data))
|
||||||
|
|
||||||
|
async def send_bytes(self, data: bytes) -> None:
|
||||||
|
if self.gone:
|
||||||
|
raise WebSocketDisconnect(code=1006)
|
||||||
|
|
||||||
|
async def close(self, code: int = 1000) -> None:
|
||||||
|
self.close_codes.append(code)
|
||||||
|
|
||||||
|
async def __anext__(self) -> WebSocketMessage:
|
||||||
|
await self.receive()
|
||||||
|
raise StopAsyncIteration
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingSocket(WebSocket):
|
||||||
|
"""A socket that only records pre-accept rejections."""
|
||||||
|
|
||||||
|
def __init__(self, path: str) -> None:
|
||||||
|
self.path = path
|
||||||
|
self.query_string = ""
|
||||||
|
self.scheme = "ws"
|
||||||
|
self.headers: Mapping[str, Sequence[str]] = {}
|
||||||
|
self.client = ("127.0.0.1", 12345)
|
||||||
|
self.server = ("127.0.0.1", 80)
|
||||||
|
self.close_codes: list[int] = []
|
||||||
|
|
||||||
|
async def accept(self, headers: Any = None) -> None:
|
||||||
|
raise AssertionError("a rejected socket must not be accepted")
|
||||||
|
|
||||||
|
async def receive(self) -> WebSocketMessage:
|
||||||
|
raise AssertionError("a rejected socket must not receive")
|
||||||
|
|
||||||
|
async def send_text(self, data: str) -> None:
|
||||||
|
raise AssertionError("a rejected socket must not send")
|
||||||
|
|
||||||
|
async def send_bytes(self, data: bytes) -> None:
|
||||||
|
raise AssertionError("a rejected socket must not send")
|
||||||
|
|
||||||
|
async def close(self, code: int = 1000) -> None:
|
||||||
|
self.close_codes.append(code)
|
||||||
|
|
||||||
|
async def __anext__(self) -> WebSocketMessage:
|
||||||
|
raise AssertionError("a rejected socket must not receive")
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketTest(unittest.TestCase):
|
||||||
|
async def _started_game(self, client: AsyncClient, oidc, target: int = 3) -> dict:
|
||||||
|
"""Create a game and seat both players; return the started state."""
|
||||||
|
with oidc_user(oidc, "alice"):
|
||||||
|
created = await client.post(
|
||||||
|
"/api/games", json={"options": {"target": target}}
|
||||||
|
)
|
||||||
|
code = created.json()["join_code"]
|
||||||
|
with oidc_user(oidc, "bob"):
|
||||||
|
response = await client.post("/api/games/join", json={"code": code})
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_move_updates_all_connections(self) -> None:
|
||||||
|
app, platform, _ = make_platform()
|
||||||
|
api_transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||||
|
state = await self._started_game(client, platform.oidc)
|
||||||
|
game_id = state["id"]
|
||||||
|
|
||||||
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
with ws_users([make_user("bob"), make_user("alice")]):
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
|
||||||
|
first = await bob_ws.receive_json()
|
||||||
|
self.assertEqual("state", first["type"])
|
||||||
|
self.assertEqual("dummy", first["game"]["game_type"])
|
||||||
|
self.assertEqual(0, first["game"]["plays"])
|
||||||
|
self.assertEqual(game_id, first["game"]["id"])
|
||||||
|
# Seated players get the join code; spectators don't.
|
||||||
|
self.assertIn("join_code", first["game"])
|
||||||
|
self.assertFalse(first["game"]["spectator"])
|
||||||
|
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
|
||||||
|
alice_first = await alice_ws.receive_json()
|
||||||
|
self.assertEqual("state", alice_first["type"])
|
||||||
|
|
||||||
|
await bob_ws.send_json({"action": "play"})
|
||||||
|
bob_update = await bob_ws.receive_json()
|
||||||
|
alice_update = await alice_ws.receive_json()
|
||||||
|
|
||||||
|
for update in (bob_update, alice_update):
|
||||||
|
self.assertEqual("state", update["type"])
|
||||||
|
self.assertEqual(1, update["game"]["plays"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_state_reports_open_seats(self) -> None:
|
||||||
|
app, platform, _ = make_platform()
|
||||||
|
api_transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||||
|
with oidc_user(platform.oidc, "alice"):
|
||||||
|
created = await client.post("/api/games", json={})
|
||||||
|
game_id = created.json()["id"]
|
||||||
|
code = created.json()["join_code"]
|
||||||
|
|
||||||
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
with ws_users([make_user("alice")]):
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||||
|
first = await ws.receive_json()
|
||||||
|
self.assertEqual(1, first["game"]["seats_open"])
|
||||||
|
|
||||||
|
# Once the second player joins the match starts and no seat is
|
||||||
|
# left open.
|
||||||
|
with oidc_user(platform.oidc, "bob"):
|
||||||
|
await client.post("/api/games/join", json={"code": code})
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
with ws_users([make_user("alice")]):
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||||
|
first = await ws.receive_json()
|
||||||
|
self.assertEqual(0, first["game"]["seats_open"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_unknown_action_returns_error(self) -> None:
|
||||||
|
app, platform, _ = make_platform()
|
||||||
|
api_transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||||
|
state = await self._started_game(client, platform.oidc)
|
||||||
|
game_id = state["id"]
|
||||||
|
|
||||||
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
with ws_users([make_user("bob")]):
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
|
||||||
|
await bob_ws.receive_json()
|
||||||
|
await bob_ws.send_json({"action": "dance"})
|
||||||
|
error = await bob_ws.receive_json()
|
||||||
|
self.assertEqual("error", error["type"])
|
||||||
|
self.assertEqual("illegal_move", error["code"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_state_action_resyncs(self) -> None:
|
||||||
|
app, platform, _ = make_platform()
|
||||||
|
api_transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||||
|
state = await self._started_game(client, platform.oidc)
|
||||||
|
game_id = state["id"]
|
||||||
|
|
||||||
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
with ws_users([make_user("bob")]):
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
|
||||||
|
await bob_ws.receive_json()
|
||||||
|
await bob_ws.send_json({"action": "state"})
|
||||||
|
resent = await bob_ws.receive_json()
|
||||||
|
self.assertEqual("state", resent["type"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_abrupt_disconnect_is_not_an_error(self) -> None:
|
||||||
|
app, platform, _ = make_platform()
|
||||||
|
api_transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||||
|
state = await self._started_game(client, platform.oidc)
|
||||||
|
game_id = state["id"]
|
||||||
|
|
||||||
|
ws = AbruptCloseSocket(f"/ws/games/{game_id}")
|
||||||
|
with ws_users([make_user("bob")]):
|
||||||
|
# kaya reports a peer that vanished without a close handshake as
|
||||||
|
# a close message; the handler must exit cleanly.
|
||||||
|
await app.handle_websocket(ws)
|
||||||
|
self.assertEqual("state", ws.sent[0]["type"])
|
||||||
|
self.assertEqual([], ws.close_codes)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_send_on_vanished_peer_is_ignored(self) -> None:
|
||||||
|
app, platform, _ = make_platform()
|
||||||
|
api_transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||||
|
state = await self._started_game(client, platform.oidc)
|
||||||
|
game_id = state["id"]
|
||||||
|
|
||||||
|
ws = AbruptCloseSocket(f"/ws/games/{game_id}", gone=True)
|
||||||
|
with ws_users([make_user("bob")]):
|
||||||
|
# Sending raises WebSocketDisconnect; the handler swallows it
|
||||||
|
# rather than letting it escape the forward task.
|
||||||
|
await app.handle_websocket(ws)
|
||||||
|
self.assertEqual([], ws.sent)
|
||||||
|
self.assertEqual([], ws.close_codes)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_game_over_broadcast(self) -> None:
|
||||||
|
app, platform, _ = make_platform()
|
||||||
|
api_transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||||
|
# target 1: the first play ends the match.
|
||||||
|
state = await self._started_game(client, platform.oidc, target=1)
|
||||||
|
game_id = state["id"]
|
||||||
|
|
||||||
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
with ws_users([make_user("alice"), make_user("bob")]):
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
|
||||||
|
await alice_ws.receive_json()
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
|
||||||
|
await bob_ws.receive_json()
|
||||||
|
await bob_ws.send_json({"action": "play"})
|
||||||
|
# Both connections see the final state...
|
||||||
|
alice_final = await alice_ws.receive_json()
|
||||||
|
bob_final = await bob_ws.receive_json()
|
||||||
|
self.assertTrue(alice_final["game"]["finished"])
|
||||||
|
self.assertTrue(bob_final["game"]["finished"])
|
||||||
|
# ...followed by the game_over announcement.
|
||||||
|
alice_over = await alice_ws.receive_json()
|
||||||
|
bob_over = await bob_ws.receive_json()
|
||||||
|
self.assertEqual("game_over", alice_over["type"])
|
||||||
|
self.assertEqual("game_over", bob_over["type"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_unknown_game_is_closed(self) -> None:
|
||||||
|
app, _, _ = make_platform()
|
||||||
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
with ws_users([make_user("alice")]):
|
||||||
|
with self.assertRaises(HttpxWebSocketDisconnect) as caught:
|
||||||
|
async with aconnect_ws("/ws/games/no-such-game", ws_client):
|
||||||
|
pass
|
||||||
|
self.assertEqual(404, caught.exception.code)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_non_player_watches_as_spectator(self) -> None:
|
||||||
|
app, platform, _ = make_platform()
|
||||||
|
api_transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||||
|
state = await self._started_game(client, platform.oidc)
|
||||||
|
game_id = state["id"]
|
||||||
|
|
||||||
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
with ws_users([make_user("mallory")]):
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||||
|
first = await ws.receive_json()
|
||||||
|
self.assertEqual("state", first["type"])
|
||||||
|
self.assertTrue(first["game"]["spectator"])
|
||||||
|
self.assertFalse(first["game"]["viewer_seated"])
|
||||||
|
self.assertNotIn("join_code", first["game"])
|
||||||
|
# Spectators may not act.
|
||||||
|
await ws.send_json({"action": "play"})
|
||||||
|
error = await ws.receive_json()
|
||||||
|
self.assertEqual("error", error["type"])
|
||||||
|
self.assertEqual("forbidden", error["code"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_anonymous_user_watches_as_spectator(self) -> None:
|
||||||
|
app, platform, _ = make_platform()
|
||||||
|
api_transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||||
|
state = await self._started_game(client, platform.oidc)
|
||||||
|
game_id = state["id"]
|
||||||
|
|
||||||
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
with ws_users([]):
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||||
|
first = await ws.receive_json()
|
||||||
|
self.assertEqual("state", first["type"])
|
||||||
|
self.assertTrue(first["game"]["spectator"])
|
||||||
|
self.assertNotIn("join_code", first["game"])
|
||||||
|
await ws.send_json({"action": "play"})
|
||||||
|
error = await ws.receive_json()
|
||||||
|
self.assertEqual("error", error["type"])
|
||||||
|
self.assertEqual("forbidden", error["code"])
|
||||||
|
|
||||||
|
|
||||||
|
class RejectionStatusTest(unittest.TestCase):
|
||||||
|
"""Pre-accept rejections must carry HTTP statuses, not close codes.
|
||||||
|
|
||||||
|
Granian's RSGI layer uses the value passed to ``close()`` before
|
||||||
|
``accept()`` as the HTTP status of the refused upgrade; a WebSocket
|
||||||
|
close code like 4401 is not a valid status and panics the worker
|
||||||
|
(``InvalidStatusCode``, and with ``panic = "abort"`` the whole app
|
||||||
|
dies). Drive the rejection through the app with a recording socket
|
||||||
|
so the invariant is checked without a real server. Spectators are
|
||||||
|
accepted, so the only pre-accept rejection left is an unknown game.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_unknown_game_rejection_is_an_http_status(self) -> None:
|
||||||
|
app, _, _ = make_platform()
|
||||||
|
for label, users in (("anonymous", []), ("logged in", [make_user("alice")])):
|
||||||
|
ws = RecordingSocket("/ws/games/no-such-game")
|
||||||
|
with ws_users(users):
|
||||||
|
await app.handle_websocket(ws)
|
||||||
|
self.assertEqual([404], ws.close_codes, label)
|
||||||
|
for code in ws.close_codes:
|
||||||
|
self.assertTrue(100 <= code <= 599, f"{label}: {code} is not an HTTP status")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# tavolo-scopone
|
||||||
|
|
||||||
|
Scopone scientifico — the four-player, fixed-partnership Italian card
|
||||||
|
game — as a [`tavolo-platform`](../tavolo-platform/README.md) game
|
||||||
|
implementation.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- `state.py` — `ScoponeState` (pure game data: phases, players, hands,
|
||||||
|
table, scores, deadlines), `PlayerState`, `Card`, `Move`, with JSON
|
||||||
|
(de)serialization. No session envelope, no transport, no I/O.
|
||||||
|
- `engine.py` — the pure rules engine: deck, legal captures, plays,
|
||||||
|
auto-play, hand scoring (carte, denara, settebello, primiera, scope,
|
||||||
|
napola), the optional asso pigliatutto capture, hand-end
|
||||||
|
acknowledgements. Deterministic and I/O-free apart from logging, so the
|
||||||
|
whole rule set is unit-testable.
|
||||||
|
- `errors.py` — scopone-specific errors (`CardNotInHand`); every other
|
||||||
|
failure mode is a shared `tavolo.platform.errors` subclass.
|
||||||
|
- `plugin.py` — `ScoponeEngine(GameEngine)`: the platform-facing adapter.
|
||||||
|
It translates create/join/websocket actions/deadlines into rules-engine
|
||||||
|
calls and back, validates the `target_score`/`napola`/`asso_pigliatutto`
|
||||||
|
creation options, and extracts the `MatchResult` (teams, winner,
|
||||||
|
per-player scores and the match summary persisted as the match's JSON
|
||||||
|
`result`).
|
||||||
|
|
||||||
|
Timeouts are constructor arguments (`turn_timeout_seconds`,
|
||||||
|
`hand_ack_timeout_seconds`), wired from the environment by the
|
||||||
|
application composition root.
|
||||||
|
|
||||||
|
## Development (from `server/`)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
.venv/bin/python -m unittest discover -s packages/tavolo-scopone/tests
|
||||||
|
.venv/bin/python -m mypy -p tavolo.scopone
|
||||||
|
```
|
||||||
|
|
||||||
|
`test_engine.py` covers the pure rules; `test_plugin.py` covers the
|
||||||
|
platform contract (actions, deadlines, serialization, results).
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "tavolo-scopone"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Scopone scientifico game implementation for the tavolo platform"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"tavolo-platform",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
namespaces = true
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.12"
|
||||||
|
ignore_missing_imports = true
|
||||||
|
plugins = []
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""Scopone scientifico: tavolo's first game implementation.
|
||||||
|
|
||||||
|
The pure rules (:mod:`tavolo.scopone.engine`, :mod:`tavolo.scopone.state`)
|
||||||
|
know nothing about HTTP, Redis or Postgres; :mod:`tavolo.scopone.plugin`
|
||||||
|
adapts them to the platform's
|
||||||
|
:class:`~tavolo.platform.engine.GameEngine` contract so the
|
||||||
|
game-independent platform can host them.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .plugin import ScoponeEngine
|
||||||
|
|
||||||
|
__all__ = ["ScoponeEngine"]
|
||||||
+82
-59
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
Every function here is deterministic and I/O-free (the only side effect is
|
Every function here is deterministic and I/O-free (the only side effect is
|
||||||
debug logging): it mutates (or reads)
|
debug logging): it mutates (or reads)
|
||||||
:class:`~tavolo.game.state.GameState` and raises
|
:class:`~tavolo.scopone.state.ScoponeState` and raises
|
||||||
:class:`~tavolo.game.errors.GameError` subclasses on rule violations. This
|
:class:`~tavolo.platform.errors.GameError` subclasses on rule violations.
|
||||||
makes the whole rule set unit-testable without Redis, Postgres or HTTP.
|
This makes the whole rule set unit-testable without Redis, Postgres or
|
||||||
|
HTTP. The platform-facing adapter is
|
||||||
|
:class:`~tavolo.scopone.plugin.ScoponeEngine`.
|
||||||
|
|
||||||
Rules implemented
|
Rules implemented
|
||||||
-----------------
|
-----------------
|
||||||
@@ -26,6 +28,12 @@ Rules implemented
|
|||||||
consecutive denari starting from the ace scores one point per card when
|
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
|
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.
|
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
|
* 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.
|
tie at or above the target is broken by playing another hand.
|
||||||
"""
|
"""
|
||||||
@@ -37,15 +45,17 @@ from itertools import combinations
|
|||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from typing import Dict, List, Optional, Sequence, Tuple
|
from typing import Dict, List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
from .errors import (
|
from tavolo.platform.errors import (
|
||||||
AlreadyJoined,
|
AlreadyJoined,
|
||||||
CardNotInHand,
|
|
||||||
GameFinished,
|
GameFinished,
|
||||||
GameNotStarted,
|
GameNotStarted,
|
||||||
IllegalMove,
|
IllegalMove,
|
||||||
LobbyFull,
|
LobbyFull,
|
||||||
NotYourTurn,
|
NotYourTurn,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .errors import CardNotInHand
|
||||||
|
|
||||||
from .state import (
|
from .state import (
|
||||||
DEFAULT_TARGET_SCORE,
|
DEFAULT_TARGET_SCORE,
|
||||||
PHASE_FINISHED,
|
PHASE_FINISHED,
|
||||||
@@ -55,9 +65,9 @@ from .state import (
|
|||||||
SUITS,
|
SUITS,
|
||||||
TEAM_NAMES,
|
TEAM_NAMES,
|
||||||
Card,
|
Card,
|
||||||
GameState,
|
|
||||||
Move,
|
Move,
|
||||||
PlayerState,
|
PlayerState,
|
||||||
|
ScoponeState,
|
||||||
parse_card,
|
parse_card,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,13 +78,12 @@ HAND_SIZE = 10
|
|||||||
PLAYERS = 4
|
PLAYERS = 4
|
||||||
|
|
||||||
# Default seconds the hand-end summary waits before dealing anyway. Games
|
# Default seconds the hand-end summary waits before dealing anyway. Games
|
||||||
# carry their own copy in ``GameState.hand_ack_timeout`` (configurable via
|
# carry their own copy in ``ScoponeState.hand_ack_timeout``.
|
||||||
# the HAND_ACK_TIMEOUT_SECONDS environment variable).
|
|
||||||
DEFAULT_HAND_ACK_TIMEOUT_SECONDS = 30
|
DEFAULT_HAND_ACK_TIMEOUT_SECONDS = 30
|
||||||
|
|
||||||
# Default seconds a player has to play before the server plays a random
|
# Default seconds a player has to play before the server plays a random
|
||||||
# legal card for them. Games carry their own copy in
|
# legal card for them. Games carry their own copy in
|
||||||
# ``GameState.turn_timeout`` (configurable via TURN_TIMEOUT_SECONDS).
|
# ``ScoponeState.turn_timeout``.
|
||||||
DEFAULT_TURN_TIMEOUT_SECONDS = 30
|
DEFAULT_TURN_TIMEOUT_SECONDS = 30
|
||||||
|
|
||||||
# Primiera card values: sevens are best, then sixes, then aces, then the
|
# Primiera card values: sevens are best, then sixes, then aces, then the
|
||||||
@@ -107,17 +116,23 @@ def shuffled_deck(rng: Optional[random.Random] = None) -> List[Card]:
|
|||||||
return deck
|
return deck
|
||||||
|
|
||||||
|
|
||||||
def legal_captures(table: Sequence[Card], card: Card) -> List[List[Card]]:
|
def legal_captures(
|
||||||
|
table: Sequence[Card], card: Card, *, asso_pigliatutto: bool = False
|
||||||
|
) -> List[List[Card]]:
|
||||||
"""Return every legal capture (a list of card sets) for ``card``.
|
"""Return every legal capture (a list of card sets) for ``card``.
|
||||||
|
|
||||||
If an equal-ranked card is on the table, only those single-card
|
If an equal-ranked card is on the table, only those single-card
|
||||||
captures are returned (the rule forbids taking a combination instead).
|
captures are returned (the rule forbids taking a combination instead).
|
||||||
Otherwise every subset of the table whose ranks sum to ``card.rank`` is
|
Otherwise, when ``asso_pigliatutto`` is enabled and ``card`` is an ace,
|
||||||
returned.
|
the only capture is the whole table — or an empty capture when the
|
||||||
|
table is bare, the ace taking itself. Without the option every subset
|
||||||
|
of the table whose ranks sum to ``card.rank`` is returned.
|
||||||
"""
|
"""
|
||||||
equal = [c for c in table if c.rank == card.rank]
|
equal = [c for c in table if c.rank == card.rank]
|
||||||
if equal:
|
if equal:
|
||||||
return [[c] for c in equal]
|
return [[c] for c in equal]
|
||||||
|
if asso_pigliatutto and card.rank == 1:
|
||||||
|
return [list(table)]
|
||||||
|
|
||||||
candidates = [c for c in table if c.rank <= card.rank]
|
candidates = [c for c in table if c.rank <= card.rank]
|
||||||
captures: List[List[Card]] = []
|
captures: List[List[Card]] = []
|
||||||
@@ -131,35 +146,29 @@ def legal_captures(table: Sequence[Card], card: Card) -> List[List[Card]]:
|
|||||||
|
|
||||||
|
|
||||||
def create_game(
|
def create_game(
|
||||||
game_id: str,
|
|
||||||
join_code: str,
|
|
||||||
creator_sub: str,
|
creator_sub: str,
|
||||||
creator_name: str,
|
creator_name: str,
|
||||||
target_score: int = DEFAULT_TARGET_SCORE,
|
target_score: int = DEFAULT_TARGET_SCORE,
|
||||||
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||||
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
|
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||||
game_type: str = "scopone_scientifico",
|
|
||||||
napola: bool = True,
|
napola: bool = True,
|
||||||
) -> GameState:
|
asso_pigliatutto: bool = False,
|
||||||
|
) -> ScoponeState:
|
||||||
"""Create a lobby game with the creator seated first."""
|
"""Create a lobby game with the creator seated first."""
|
||||||
if target_score < 1 or target_score > 100:
|
if target_score < 1 or target_score > 100:
|
||||||
raise IllegalMove("target_score must be between 1 and 100")
|
raise IllegalMove("target_score must be between 1 and 100")
|
||||||
return GameState(
|
return ScoponeState(
|
||||||
id=game_id,
|
|
||||||
join_code=join_code,
|
|
||||||
creator_sub=creator_sub,
|
|
||||||
game_type=game_type,
|
|
||||||
target_score=target_score,
|
target_score=target_score,
|
||||||
napola=napola,
|
napola=napola,
|
||||||
|
asso_pigliatutto=asso_pigliatutto,
|
||||||
phase=PHASE_LOBBY,
|
phase=PHASE_LOBBY,
|
||||||
players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)],
|
players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)],
|
||||||
hand_ack_timeout=hand_ack_timeout,
|
hand_ack_timeout=hand_ack_timeout,
|
||||||
turn_timeout=turn_timeout,
|
turn_timeout=turn_timeout,
|
||||||
created_at=datetime.now(timezone.utc).isoformat(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def join_game(state: GameState, sub: str, name: str) -> None:
|
def join_game(state: ScoponeState, sub: str, name: str) -> None:
|
||||||
"""Seat ``sub`` in the next free chair, starting the match when full."""
|
"""Seat ``sub`` in the next free chair, starting the match when full."""
|
||||||
if state.phase != PHASE_LOBBY:
|
if state.phase != PHASE_LOBBY:
|
||||||
raise GameNotStarted("game has already started")
|
raise GameNotStarted("game has already started")
|
||||||
@@ -173,7 +182,7 @@ def join_game(state: GameState, sub: str, name: str) -> None:
|
|||||||
start_game(state)
|
start_game(state)
|
||||||
|
|
||||||
|
|
||||||
def start_game(state: GameState) -> None:
|
def start_game(state: ScoponeState) -> None:
|
||||||
"""Deal the first hand and switch the game to playing."""
|
"""Deal the first hand and switch the game to playing."""
|
||||||
if len(state.players) != PLAYERS:
|
if len(state.players) != PLAYERS:
|
||||||
raise GameNotStarted("need exactly four players to start")
|
raise GameNotStarted("need exactly four players to start")
|
||||||
@@ -181,13 +190,13 @@ def start_game(state: GameState) -> None:
|
|||||||
_deal_hand(state)
|
_deal_hand(state)
|
||||||
|
|
||||||
|
|
||||||
def _set_turn_deadline(state: GameState) -> None:
|
def _set_turn_deadline(state: ScoponeState) -> None:
|
||||||
"""Arm the auto-play deadline for whoever is on turn."""
|
"""Arm the auto-play deadline for whoever is on turn."""
|
||||||
deadline = datetime.now(timezone.utc) + timedelta(seconds=state.turn_timeout)
|
deadline = datetime.now(timezone.utc) + timedelta(seconds=state.turn_timeout)
|
||||||
state.turn_deadline = deadline.isoformat()
|
state.turn_deadline = deadline.isoformat()
|
||||||
|
|
||||||
|
|
||||||
def _deal_hand(state: GameState) -> None:
|
def _deal_hand(state: ScoponeState) -> None:
|
||||||
deck = shuffled_deck()
|
deck = shuffled_deck()
|
||||||
for player in state.players:
|
for player in state.players:
|
||||||
player.hand = []
|
player.hand = []
|
||||||
@@ -203,10 +212,10 @@ def _deal_hand(state: GameState) -> None:
|
|||||||
for seat in range(PLAYERS):
|
for seat in range(PLAYERS):
|
||||||
player = _player_at(state, (state.dealer + 1 + seat) % PLAYERS)
|
player = _player_at(state, (state.dealer + 1 + seat) % PLAYERS)
|
||||||
player.hand.append(deck.pop())
|
player.hand.append(deck.pop())
|
||||||
log.debug("game %s: hand %d dealt (dealer seat %d)", state.id, state.hand_number, state.dealer)
|
log.debug("hand %d dealt (dealer seat %d)", state.hand_number, state.dealer)
|
||||||
|
|
||||||
|
|
||||||
def _player_at(state: GameState, seat: int) -> PlayerState:
|
def _player_at(state: ScoponeState, seat: int) -> PlayerState:
|
||||||
for player in state.players:
|
for player in state.players:
|
||||||
if player.seat == seat:
|
if player.seat == seat:
|
||||||
return player
|
return player
|
||||||
@@ -214,7 +223,7 @@ def _player_at(state: GameState, seat: int) -> PlayerState:
|
|||||||
|
|
||||||
|
|
||||||
def play(
|
def play(
|
||||||
state: GameState,
|
state: ScoponeState,
|
||||||
sub: str,
|
sub: str,
|
||||||
card_code: str,
|
card_code: str,
|
||||||
capture_codes: Optional[Sequence[str]] = None,
|
capture_codes: Optional[Sequence[str]] = None,
|
||||||
@@ -223,8 +232,8 @@ def play(
|
|||||||
|
|
||||||
``capture_codes`` selects which table cards to capture; it must be a
|
``capture_codes`` selects which table cards to capture; it must be a
|
||||||
legal capture (see :func:`legal_captures`) when one exists and empty
|
legal capture (see :func:`legal_captures`) when one exists and empty
|
||||||
otherwise. Raises a :class:`~tavolo.game.errors.GameError` subclass on
|
otherwise. Raises a :class:`~tavolo.platform.errors.GameError` subclass
|
||||||
any violation.
|
on any violation.
|
||||||
"""
|
"""
|
||||||
if state.phase == PHASE_FINISHED:
|
if state.phase == PHASE_FINISHED:
|
||||||
raise GameFinished("the match is over")
|
raise GameFinished("the match is over")
|
||||||
@@ -245,7 +254,18 @@ def play(
|
|||||||
player.hand.remove(card)
|
player.hand.remove(card)
|
||||||
|
|
||||||
requested = [parse_card(c) for c in (capture_codes or [])]
|
requested = [parse_card(c) for c in (capture_codes or [])]
|
||||||
options = legal_captures(state.table, card)
|
table_before = list(state.table)
|
||||||
|
options = legal_captures(
|
||||||
|
table_before, card, asso_pigliatutto=state.asso_pigliatutto
|
||||||
|
)
|
||||||
|
# An asso pigliatutto capture: the ace takes the whole table because no
|
||||||
|
# equal rank was on it, or takes itself when the table is bare. Such a
|
||||||
|
# capture never scores a scopa.
|
||||||
|
pigliatutto = (
|
||||||
|
state.asso_pigliatutto
|
||||||
|
and card.rank == 1
|
||||||
|
and all(c.rank != card.rank for c in table_before)
|
||||||
|
)
|
||||||
|
|
||||||
taken: List[Card] = []
|
taken: List[Card] = []
|
||||||
scopa = False
|
scopa = False
|
||||||
@@ -265,7 +285,7 @@ def play(
|
|||||||
state.last_taker = player.seat
|
state.last_taker = player.seat
|
||||||
# A scopa scores only if cards remain to be played this hand.
|
# A scopa scores only if cards remain to be played this hand.
|
||||||
hands_empty = all(not p.hand for p in state.players)
|
hands_empty = all(not p.hand for p in state.players)
|
||||||
if not state.table and not hands_empty:
|
if not state.table and not hands_empty and not pigliatutto:
|
||||||
player.scope += 1
|
player.scope += 1
|
||||||
scopa = True
|
scopa = True
|
||||||
|
|
||||||
@@ -275,6 +295,7 @@ def play(
|
|||||||
card=card.code,
|
card=card.code,
|
||||||
captured=[c.code for c in taken],
|
captured=[c.code for c in taken],
|
||||||
scopa=scopa,
|
scopa=scopa,
|
||||||
|
pigliatutto=pigliatutto,
|
||||||
)
|
)
|
||||||
|
|
||||||
if all(not p.hand for p in state.players):
|
if all(not p.hand for p in state.players):
|
||||||
@@ -287,17 +308,19 @@ def play(
|
|||||||
def _match_option(
|
def _match_option(
|
||||||
options: Sequence[Sequence[Card]], requested: Sequence[Card]
|
options: Sequence[Sequence[Card]], requested: Sequence[Card]
|
||||||
) -> Optional[List[Card]]:
|
) -> Optional[List[Card]]:
|
||||||
"""Return the option matching ``requested`` exactly, if any."""
|
"""Return the option matching ``requested`` exactly, if any.
|
||||||
|
|
||||||
|
An empty request matches only an empty option — the asso pigliatutto
|
||||||
|
self-capture — so requesting nothing never satisfies a real capture.
|
||||||
|
"""
|
||||||
wanted = sorted(c.code for c in requested)
|
wanted = sorted(c.code for c in requested)
|
||||||
if not wanted:
|
|
||||||
return None
|
|
||||||
for option in options:
|
for option in options:
|
||||||
if sorted(c.code for c in option) == wanted:
|
if sorted(c.code for c in option) == wanted:
|
||||||
return list(option)
|
return list(option)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def auto_play(state: GameState, rng: Optional[random.Random] = None) -> None:
|
def auto_play(state: ScoponeState, rng: Optional[random.Random] = None) -> None:
|
||||||
"""Play a random legal move for the player currently on turn.
|
"""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,
|
A card is drawn at random from that player's hand; if it can capture,
|
||||||
@@ -313,24 +336,26 @@ def auto_play(state: GameState, rng: Optional[random.Random] = None) -> None:
|
|||||||
raise IllegalMove("the player on turn has no cards")
|
raise IllegalMove("the player on turn has no cards")
|
||||||
chooser = rng or _rng
|
chooser = rng or _rng
|
||||||
card = chooser.choice(player.hand)
|
card = chooser.choice(player.hand)
|
||||||
options = legal_captures(state.table, card)
|
options = legal_captures(
|
||||||
|
state.table, card, asso_pigliatutto=state.asso_pigliatutto
|
||||||
|
)
|
||||||
capture = chooser.choice(options) if options else None
|
capture = chooser.choice(options) if options else None
|
||||||
play(
|
play(
|
||||||
state,
|
state,
|
||||||
player.sub,
|
player.sub,
|
||||||
card.code,
|
card.code,
|
||||||
[c.code for c in capture] if capture else None,
|
[c.code for c in capture] if capture is not None else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _end_hand(state: GameState) -> None:
|
def _end_hand(state: ScoponeState) -> None:
|
||||||
"""Sweep the table and score the hand.
|
"""Sweep the table and score the hand.
|
||||||
|
|
||||||
If the match continues, the game pauses in the ``hand_end`` phase so
|
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
|
every player can read the scoring summary; the next hand is dealt by
|
||||||
:func:`acknowledge_hand` once all four players have acknowledged (or by
|
:func:`acknowledge_hand` once all four players have acknowledged (or
|
||||||
the hand-end timeout in the websocket layer). If the match is over the
|
by the hand-end timeout fired by the platform's deadline scheduler).
|
||||||
game goes to ``finished`` immediately.
|
If the match is over the game goes to ``finished`` immediately.
|
||||||
"""
|
"""
|
||||||
state.turn_deadline = None
|
state.turn_deadline = None
|
||||||
if state.table and state.last_taker is not None:
|
if state.table and state.last_taker is not None:
|
||||||
@@ -348,8 +373,7 @@ def _end_hand(state: GameState) -> None:
|
|||||||
|
|
||||||
a, b = state.scores
|
a, b = state.scores
|
||||||
log.debug(
|
log.debug(
|
||||||
"game %s: hand %d scored A+%d B+%d (totals %d-%d)",
|
"hand %d scored A+%d B+%d (totals %d-%d)",
|
||||||
state.id,
|
|
||||||
state.hand_number,
|
state.hand_number,
|
||||||
points[0],
|
points[0],
|
||||||
points[1],
|
points[1],
|
||||||
@@ -364,15 +388,13 @@ def _end_hand(state: GameState) -> None:
|
|||||||
if napola.get(name) == 10:
|
if napola.get(name) == 10:
|
||||||
state.phase = PHASE_FINISHED
|
state.phase = PHASE_FINISHED
|
||||||
state.winner = team
|
state.winner = team
|
||||||
state.finished_at = datetime.now(timezone.utc).isoformat()
|
log.info("team %s swept the denari (napola) and wins", name)
|
||||||
log.info("game %s: team %s swept the denari (napola) and wins", state.id, name)
|
|
||||||
return
|
return
|
||||||
reached = max(a, b) >= state.target_score
|
reached = max(a, b) >= state.target_score
|
||||||
if reached and a != b:
|
if reached and a != b:
|
||||||
state.phase = PHASE_FINISHED
|
state.phase = PHASE_FINISHED
|
||||||
state.winner = 0 if a > b else 1
|
state.winner = 0 if a > b else 1
|
||||||
state.finished_at = datetime.now(timezone.utc).isoformat()
|
log.debug("match ended, team %s wins", "A" if state.winner == 0 else "B")
|
||||||
log.debug("game %s: match ended, team %s wins", state.id, "A" if state.winner == 0 else "B")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Pause for the scoring summary instead of dealing immediately.
|
# Pause for the scoring summary instead of dealing immediately.
|
||||||
@@ -382,7 +404,7 @@ def _end_hand(state: GameState) -> None:
|
|||||||
state.hand_end_deadline = deadline.isoformat()
|
state.hand_end_deadline = deadline.isoformat()
|
||||||
|
|
||||||
|
|
||||||
def acknowledge_hand(state: GameState, sub: str) -> None:
|
def acknowledge_hand(state: ScoponeState, sub: str) -> None:
|
||||||
"""Record that ``sub`` has read the hand-end summary.
|
"""Record that ``sub`` has read the hand-end summary.
|
||||||
|
|
||||||
When all four players have acknowledged, the next hand is dealt.
|
When all four players have acknowledged, the next hand is dealt.
|
||||||
@@ -437,7 +459,7 @@ def napola_score(captured: Sequence[Card]) -> int:
|
|||||||
return run if run >= 3 else 0
|
return run if run >= 3 else 0
|
||||||
|
|
||||||
|
|
||||||
def hand_points(state: GameState) -> Tuple[List[int], Dict[str, object]]:
|
def hand_points(state: ScoponeState) -> Tuple[List[int], Dict[str, object]]:
|
||||||
"""Compute the hand points for both teams (index 0 = team A)."""
|
"""Compute the hand points for both teams (index 0 = team A)."""
|
||||||
piles: List[List[Card]] = [[], []]
|
piles: List[List[Card]] = [[], []]
|
||||||
scope: List[int] = [0, 0]
|
scope: List[int] = [0, 0]
|
||||||
@@ -506,13 +528,14 @@ def hand_points(state: GameState) -> Tuple[List[int], Dict[str, object]]:
|
|||||||
return points, details
|
return points, details
|
||||||
|
|
||||||
|
|
||||||
def state_for_player(state: GameState, sub: str) -> Dict[str, object]:
|
def state_for_player(state: ScoponeState, sub: Optional[str]) -> Dict[str, object]:
|
||||||
"""Serialize ``state`` hiding other players' hands.
|
"""Serialize ``state`` hiding other players' hands.
|
||||||
|
|
||||||
Hands are reduced to a count, except for the requesting player's own
|
Hands are reduced to a count, except for the requesting player's own
|
||||||
hand. Raises :class:`~tavolo.game.errors.GameNotFound`-style access via
|
hand. Non-seated viewers — including spectators, for whom ``sub`` is
|
||||||
the caller; this function assumes ``sub`` may or may not be seated and
|
``None`` — simply see no hand at all. The platform merges the session
|
||||||
simply omits the hand for non-seated viewers.
|
envelope (``id``, ``join_code`` for seated players, ``game_type``,
|
||||||
|
``spectator``) into the view itself.
|
||||||
"""
|
"""
|
||||||
viewer = state.player_for(sub)
|
viewer = state.player_for(sub)
|
||||||
players: List[Dict[str, object]] = []
|
players: List[Dict[str, object]] = []
|
||||||
@@ -531,12 +554,10 @@ def state_for_player(state: GameState, sub: str) -> Dict[str, object]:
|
|||||||
players.append(view)
|
players.append(view)
|
||||||
|
|
||||||
payload: Dict[str, object] = {
|
payload: Dict[str, object] = {
|
||||||
"id": state.id,
|
|
||||||
"join_code": state.join_code,
|
|
||||||
"game_type": state.game_type,
|
|
||||||
"phase": state.phase,
|
"phase": state.phase,
|
||||||
"target_score": state.target_score,
|
"target_score": state.target_score,
|
||||||
"napola": state.napola,
|
"napola": state.napola,
|
||||||
|
"asso_pigliatutto": state.asso_pigliatutto,
|
||||||
"hand_number": state.hand_number,
|
"hand_number": state.hand_number,
|
||||||
"dealer": state.dealer,
|
"dealer": state.dealer,
|
||||||
"turn": state.turn,
|
"turn": state.turn,
|
||||||
@@ -556,7 +577,9 @@ def state_for_player(state: GameState, sub: str) -> Dict[str, object]:
|
|||||||
# rule logic stays server-side.
|
# rule logic stays server-side.
|
||||||
legal_moves: Dict[str, List[List[str]]] = {}
|
legal_moves: Dict[str, List[List[str]]] = {}
|
||||||
for hand_card in viewer.hand:
|
for hand_card in viewer.hand:
|
||||||
options = legal_captures(state.table, hand_card)
|
options = legal_captures(
|
||||||
|
state.table, hand_card, asso_pigliatutto=state.asso_pigliatutto
|
||||||
|
)
|
||||||
if options:
|
if options:
|
||||||
legal_moves[hand_card.code] = [
|
legal_moves[hand_card.code] = [
|
||||||
[c.code for c in option] for option in options
|
[c.code for c in option] for option in options
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Scopone scientifico errors.
|
||||||
|
|
||||||
|
Most failure modes are game-independent and live in
|
||||||
|
:mod:`tavolo.platform.errors`; only genuinely scopone-specific errors
|
||||||
|
are defined here.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from tavolo.platform.errors import (
|
||||||
|
AlreadyJoined,
|
||||||
|
GameError,
|
||||||
|
GameFinished,
|
||||||
|
GameNotFound,
|
||||||
|
GameNotStarted,
|
||||||
|
IllegalMove,
|
||||||
|
LobbyFull,
|
||||||
|
NotYourTurn,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AlreadyJoined",
|
||||||
|
"CardNotInHand",
|
||||||
|
"GameError",
|
||||||
|
"GameFinished",
|
||||||
|
"GameNotFound",
|
||||||
|
"GameNotStarted",
|
||||||
|
"IllegalMove",
|
||||||
|
"LobbyFull",
|
||||||
|
"NotYourTurn",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class CardNotInHand(GameError):
|
||||||
|
"""The played card is not held by the player."""
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
"""The platform-facing adapter for scopone scientifico.
|
||||||
|
|
||||||
|
:class:`ScoponeEngine` implements
|
||||||
|
:class:`~tavolo.platform.engine.GameEngine` on top of the pure rules in
|
||||||
|
:mod:`tavolo.scopone.engine`: it translates platform calls (create, join,
|
||||||
|
websocket actions, deadlines) into rules-engine calls and back, keeping
|
||||||
|
all scopone knowledge inside this package. Nothing outside
|
||||||
|
``tavolo.scopone`` needs to know about phases, turns or hand scoring.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from logging import getLogger
|
||||||
|
from typing import Any, Dict, Mapping, Optional
|
||||||
|
|
||||||
|
from tavolo.platform.engine import (
|
||||||
|
Deadline,
|
||||||
|
GameEngine,
|
||||||
|
GameSession,
|
||||||
|
MatchResult,
|
||||||
|
PlayerResult,
|
||||||
|
Seat,
|
||||||
|
)
|
||||||
|
from tavolo.platform.errors import GameError, IllegalMove
|
||||||
|
|
||||||
|
from . import engine
|
||||||
|
from .state import (
|
||||||
|
DEFAULT_TARGET_SCORE,
|
||||||
|
PHASE_FINISHED,
|
||||||
|
PHASE_HAND_END,
|
||||||
|
PHASE_LOBBY,
|
||||||
|
PHASE_PLAYING,
|
||||||
|
TEAM_NAMES,
|
||||||
|
ScoponeState,
|
||||||
|
)
|
||||||
|
|
||||||
|
log = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _deadline_ms(iso: Optional[str]) -> Optional[int]:
|
||||||
|
"""Epoch milliseconds for an ISO-8601 deadline, ``None`` when absent
|
||||||
|
or unparseable."""
|
||||||
|
if not iso:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(datetime.fromisoformat(iso).timestamp() * 1000)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ScoponeEngine(GameEngine):
|
||||||
|
"""Scopone scientifico as a platform game engine."""
|
||||||
|
|
||||||
|
id = "scopone_scientifico"
|
||||||
|
name = "Scopone scientifico"
|
||||||
|
description = (
|
||||||
|
"Four players in fixed partnerships, ten cards each and an empty "
|
||||||
|
"table. First team to the target score wins."
|
||||||
|
)
|
||||||
|
min_players = 4
|
||||||
|
max_players = 4
|
||||||
|
options_schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"target_score": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 100,
|
||||||
|
"default": DEFAULT_TARGET_SCORE,
|
||||||
|
"description": "Match points the winning team must reach.",
|
||||||
|
},
|
||||||
|
"napola": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": True,
|
||||||
|
"description": "Score the napola rule; a full denari "
|
||||||
|
"sweep wins the match",
|
||||||
|
},
|
||||||
|
"asso_pigliatutto": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": False,
|
||||||
|
"description": "Score the asso pigliatutto rule; an ace "
|
||||||
|
"sweeps the whole table (no scopa)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
turn_timeout_seconds: int = engine.DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||||
|
hand_ack_timeout_seconds: int = engine.DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||||
|
) -> None:
|
||||||
|
self._turn_timeout = turn_timeout_seconds
|
||||||
|
self._hand_ack_timeout = hand_ack_timeout_seconds
|
||||||
|
|
||||||
|
# -- lobby ------------------------------------------------------------
|
||||||
|
|
||||||
|
def create(self, session: GameSession, options: Mapping[str, Any]) -> None:
|
||||||
|
target_score = options.get("target_score", DEFAULT_TARGET_SCORE)
|
||||||
|
if isinstance(target_score, bool) or not isinstance(target_score, int):
|
||||||
|
raise IllegalMove("target_score must be an integer")
|
||||||
|
napola = options.get("napola", True)
|
||||||
|
if not isinstance(napola, bool):
|
||||||
|
raise IllegalMove("napola must be a boolean")
|
||||||
|
asso_pigliatutto = options.get("asso_pigliatutto", False)
|
||||||
|
if not isinstance(asso_pigliatutto, bool):
|
||||||
|
raise IllegalMove("asso_pigliatutto must be a boolean")
|
||||||
|
creator = session.players[0]
|
||||||
|
session.state = engine.create_game(
|
||||||
|
creator_sub=creator.user_sub,
|
||||||
|
creator_name=creator.display_name,
|
||||||
|
target_score=target_score,
|
||||||
|
hand_ack_timeout=self._hand_ack_timeout,
|
||||||
|
turn_timeout=self._turn_timeout,
|
||||||
|
napola=napola,
|
||||||
|
asso_pigliatutto=asso_pigliatutto,
|
||||||
|
)
|
||||||
|
# The creator takes seat 0, i.e. team A.
|
||||||
|
session.players[0] = Seat(
|
||||||
|
user_sub=creator.user_sub,
|
||||||
|
display_name=creator.display_name,
|
||||||
|
team=TEAM_NAMES[0],
|
||||||
|
)
|
||||||
|
|
||||||
|
def join(self, session: GameSession, user_sub: str, display_name: str) -> None:
|
||||||
|
seat = len(session.players)
|
||||||
|
engine.join_game(session.state, user_sub, display_name)
|
||||||
|
# join_game raises before appending on any violation, so the seat
|
||||||
|
# list stays in sync with the engine's players.
|
||||||
|
session.players.append(
|
||||||
|
Seat(
|
||||||
|
user_sub=user_sub,
|
||||||
|
display_name=display_name,
|
||||||
|
team=TEAM_NAMES[seat % 2],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def in_lobby(self, session: GameSession) -> bool:
|
||||||
|
return session.state.phase == PHASE_LOBBY
|
||||||
|
|
||||||
|
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
|
||||||
|
state: ScoponeState = session.state
|
||||||
|
return {
|
||||||
|
"phase": state.phase,
|
||||||
|
"target_score": state.target_score,
|
||||||
|
"napola": state.napola,
|
||||||
|
"asso_pigliatutto": state.asso_pigliatutto,
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- play -------------------------------------------------------------
|
||||||
|
|
||||||
|
def handle_action(
|
||||||
|
self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any]
|
||||||
|
) -> None:
|
||||||
|
state: ScoponeState = session.state
|
||||||
|
if action == "play":
|
||||||
|
card = payload.get("card")
|
||||||
|
capture = payload.get("capture")
|
||||||
|
if not isinstance(card, str):
|
||||||
|
raise IllegalMove("'card' must be a card code string")
|
||||||
|
if capture is not None and (
|
||||||
|
not isinstance(capture, list)
|
||||||
|
or any(not isinstance(item, str) for item in capture)
|
||||||
|
):
|
||||||
|
raise IllegalMove("'capture' must be a list of card codes")
|
||||||
|
try:
|
||||||
|
engine.play(state, user_sub, card, capture)
|
||||||
|
except ValueError:
|
||||||
|
raise IllegalMove("invalid card code")
|
||||||
|
elif action == "ack":
|
||||||
|
engine.acknowledge_hand(state, user_sub)
|
||||||
|
else:
|
||||||
|
raise IllegalMove(f"unknown action: {action!r}")
|
||||||
|
|
||||||
|
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
|
||||||
|
return engine.state_for_player(session.state, user_sub)
|
||||||
|
|
||||||
|
def is_finished(self, session: GameSession) -> bool:
|
||||||
|
return session.state.phase == PHASE_FINISHED
|
||||||
|
|
||||||
|
def game_over_view(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
|
||||||
|
state: ScoponeState = session.state
|
||||||
|
return {
|
||||||
|
"scores": {"A": state.scores[0], "B": state.scores[1]},
|
||||||
|
"winner": None if state.winner is None else TEAM_NAMES[state.winner],
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- (de)serialization -------------------------------------------------
|
||||||
|
|
||||||
|
def state_to_json(self, state: Any) -> Dict[str, Any]:
|
||||||
|
assert isinstance(state, ScoponeState)
|
||||||
|
return state.to_json()
|
||||||
|
|
||||||
|
def state_from_json(self, data: Mapping[str, Any]) -> Any:
|
||||||
|
return ScoponeState.from_json(dict(data))
|
||||||
|
|
||||||
|
# -- deadlines ----------------------------------------------------------
|
||||||
|
|
||||||
|
def next_deadline(self, session: GameSession) -> Optional[Deadline]:
|
||||||
|
state: ScoponeState = session.state
|
||||||
|
if state.phase == PHASE_PLAYING and state.turn_deadline:
|
||||||
|
due_ms = _deadline_ms(state.turn_deadline)
|
||||||
|
if due_ms is None:
|
||||||
|
return None
|
||||||
|
return Deadline(
|
||||||
|
kind="turn",
|
||||||
|
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
|
||||||
|
token=f"turn:{state.hand_number}:{state.turn}:{due_ms}",
|
||||||
|
)
|
||||||
|
if state.phase == PHASE_HAND_END and state.hand_end_deadline:
|
||||||
|
due_ms = _deadline_ms(state.hand_end_deadline)
|
||||||
|
if due_ms is None:
|
||||||
|
return None
|
||||||
|
return Deadline(
|
||||||
|
kind="hand_end",
|
||||||
|
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
|
||||||
|
token=f"hand_end:{state.hand_number}:{due_ms}",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fire_deadline(self, session: GameSession, kind: str, token: str) -> None:
|
||||||
|
current = self.next_deadline(session)
|
||||||
|
if current is None or current.kind != kind or current.token != token:
|
||||||
|
# Overtaken by events (a play landed in time, the hand was
|
||||||
|
# acknowledged, the deadline moved): nothing to do.
|
||||||
|
raise GameError("stale deadline")
|
||||||
|
state: ScoponeState = session.state
|
||||||
|
if kind == "turn":
|
||||||
|
engine.auto_play(state)
|
||||||
|
actor = state.last_move.seat if state.last_move is not None else None
|
||||||
|
log.info(
|
||||||
|
"auto-played for seat %s (turn timeout, hand %d)",
|
||||||
|
actor,
|
||||||
|
state.hand_number,
|
||||||
|
)
|
||||||
|
elif kind == "hand_end":
|
||||||
|
for player in state.players:
|
||||||
|
engine.acknowledge_hand(state, player.sub)
|
||||||
|
log.info(
|
||||||
|
"hand %d auto-advanced after the acknowledgement timeout",
|
||||||
|
state.hand_number,
|
||||||
|
)
|
||||||
|
else: # pragma: no cover - next_deadline never emits other kinds
|
||||||
|
raise GameError(f"unknown deadline kind: {kind!r}")
|
||||||
|
|
||||||
|
# -- results -------------------------------------------------------------
|
||||||
|
|
||||||
|
def result(self, session: GameSession) -> MatchResult:
|
||||||
|
state: ScoponeState = session.state
|
||||||
|
if state.winner is None:
|
||||||
|
raise GameError("no result: the match is not finished")
|
||||||
|
teams = [
|
||||||
|
[p.sub for p in state.players if p.team == 0],
|
||||||
|
[p.sub for p in state.players if p.team == 1],
|
||||||
|
]
|
||||||
|
return MatchResult(
|
||||||
|
teams=teams,
|
||||||
|
winner_team=state.winner,
|
||||||
|
players=[
|
||||||
|
PlayerResult(
|
||||||
|
user_sub=player.sub,
|
||||||
|
seat=player.seat,
|
||||||
|
won=player.team == state.winner,
|
||||||
|
team=TEAM_NAMES[player.team],
|
||||||
|
score=float(state.scores[player.team]),
|
||||||
|
details={"scope": player.scope},
|
||||||
|
)
|
||||||
|
for player in state.players
|
||||||
|
],
|
||||||
|
summary={
|
||||||
|
"team_a_score": state.scores[0],
|
||||||
|
"team_b_score": state.scores[1],
|
||||||
|
"winner_team": TEAM_NAMES[state.winner],
|
||||||
|
"target_score": state.target_score,
|
||||||
|
"hands_played": state.hand_number,
|
||||||
|
"hand_scores": state.hand_scores,
|
||||||
|
},
|
||||||
|
)
|
||||||
+27
-35
@@ -1,9 +1,14 @@
|
|||||||
"""In-memory representation of a scopone scientifico game.
|
"""In-memory representation of a scopone scientifico game.
|
||||||
|
|
||||||
The whole mutable game lives in :class:`GameState`, which is serialized to
|
The whole mutable game lives in :class:`ScoponeState`, which is serialized
|
||||||
and from plain JSON for storage in Redis (see :mod:`tavolo.store`). Keeping
|
to and from plain JSON for storage inside the platform's session envelope
|
||||||
the representation JSON-native means the store needs no custom codecs and
|
(see :mod:`tavolo.platform.store`). Keeping the representation JSON-native
|
||||||
the state is inspectable with ``redis-cli``.
|
means the store needs no custom codecs and the state is inspectable with
|
||||||
|
``redis-cli``.
|
||||||
|
|
||||||
|
The state contains only game data: the platform owns the session envelope
|
||||||
|
(id, join code, seats, timestamps, stats persistence) — see
|
||||||
|
:class:`tavolo.platform.engine.GameSession`.
|
||||||
|
|
||||||
Deck convention: a 40-card Italian deck. Suits are ``D`` (denari),
|
Deck convention: a 40-card Italian deck. Suits are ``D`` (denari),
|
||||||
``C`` (coppe), ``S`` (spade) and ``B`` (bastoni); ranks are ``1``..``10``.
|
``C`` (coppe), ``S`` (spade) and ``B`` (bastoni); ranks are ``1``..``10``.
|
||||||
@@ -87,6 +92,9 @@ class Move:
|
|||||||
card: str
|
card: str
|
||||||
captured: List[str] = field(default_factory=list)
|
captured: List[str] = field(default_factory=list)
|
||||||
scopa: bool = False
|
scopa: bool = False
|
||||||
|
# The play was an asso pigliatutto capture (the whole table, or the
|
||||||
|
# ace itself when the table was empty).
|
||||||
|
pigliatutto: bool = False
|
||||||
|
|
||||||
def to_json(self) -> Dict[str, Any]:
|
def to_json(self) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -95,6 +103,7 @@ class Move:
|
|||||||
"card": self.card,
|
"card": self.card,
|
||||||
"captured": list(self.captured),
|
"captured": list(self.captured),
|
||||||
"scopa": self.scopa,
|
"scopa": self.scopa,
|
||||||
|
"pigliatutto": self.pigliatutto,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -105,6 +114,7 @@ class Move:
|
|||||||
card=str(data["card"]),
|
card=str(data["card"]),
|
||||||
captured=[str(c) for c in data.get("captured", [])],
|
captured=[str(c) for c in data.get("captured", [])],
|
||||||
scopa=bool(data.get("scopa", False)),
|
scopa=bool(data.get("scopa", False)),
|
||||||
|
pigliatutto=bool(data.get("pigliatutto", False)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -144,18 +154,16 @@ class PlayerState:
|
|||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class GameState:
|
class ScoponeState:
|
||||||
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
|
target_score: int = DEFAULT_TARGET_SCORE
|
||||||
# Whether the napola rule is scored (denari run from the ace; a full
|
# Whether the napola rule is scored (denari run from the ace; a full
|
||||||
# suit wins the match instantly). Default on, also for states
|
# suit wins the match instantly). Default on.
|
||||||
# serialized before the option existed.
|
|
||||||
napola: bool = True
|
napola: bool = True
|
||||||
|
# Whether the asso pigliatutto rule is scored (an ace sweeps the whole
|
||||||
|
# table when no equal rank is on it, or captures itself when the table
|
||||||
|
# is empty; the capture never scores a scopa). Default off, also for
|
||||||
|
# states serialized before the option existed.
|
||||||
|
asso_pigliatutto: bool = False
|
||||||
phase: str = PHASE_LOBBY
|
phase: str = PHASE_LOBBY
|
||||||
players: List[PlayerState] = field(default_factory=list)
|
players: List[PlayerState] = field(default_factory=list)
|
||||||
table: List[Card] = field(default_factory=list)
|
table: List[Card] = field(default_factory=list)
|
||||||
@@ -167,10 +175,6 @@ class GameState:
|
|||||||
last_taker: Optional[int] = None
|
last_taker: Optional[int] = None
|
||||||
# Per-hand points awarded, for a compact audit trail in the API.
|
# Per-hand points awarded, for a compact audit trail in the API.
|
||||||
hand_scores: List[Dict[str, Any]] = field(default_factory=list)
|
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.
|
# The most recent play in the current hand, for move announcements.
|
||||||
last_move: Optional[Move] = None
|
last_move: Optional[Move] = None
|
||||||
# While phase == "hand_end": seats that acknowledged the summary, and
|
# While phase == "hand_end": seats that acknowledged the summary, and
|
||||||
@@ -180,7 +184,7 @@ class GameState:
|
|||||||
# Seconds the hand-end summary waits before dealing anyway.
|
# Seconds the hand-end summary waits before dealing anyway.
|
||||||
hand_ack_timeout: int = 30
|
hand_ack_timeout: int = 30
|
||||||
# While phase == "playing": when the server plays a random legal card
|
# While phase == "playing": when the server plays a random legal card
|
||||||
# for the player on turn. Copied from settings at creation.
|
# for the player on turn.
|
||||||
turn_deadline: Optional[str] = None
|
turn_deadline: Optional[str] = None
|
||||||
turn_timeout: int = 30
|
turn_timeout: int = 30
|
||||||
|
|
||||||
@@ -188,12 +192,9 @@ class GameState:
|
|||||||
|
|
||||||
def to_json(self) -> Dict[str, Any]:
|
def to_json(self) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"id": self.id,
|
|
||||||
"join_code": self.join_code,
|
|
||||||
"creator_sub": self.creator_sub,
|
|
||||||
"game_type": self.game_type,
|
|
||||||
"target_score": self.target_score,
|
"target_score": self.target_score,
|
||||||
"napola": self.napola,
|
"napola": self.napola,
|
||||||
|
"asso_pigliatutto": self.asso_pigliatutto,
|
||||||
"phase": self.phase,
|
"phase": self.phase,
|
||||||
"players": [p.to_json() for p in self.players],
|
"players": [p.to_json() for p in self.players],
|
||||||
"table": [c.to_json() for c in self.table],
|
"table": [c.to_json() for c in self.table],
|
||||||
@@ -204,9 +205,6 @@ class GameState:
|
|||||||
"winner": self.winner,
|
"winner": self.winner,
|
||||||
"last_taker": self.last_taker,
|
"last_taker": self.last_taker,
|
||||||
"hand_scores": list(self.hand_scores),
|
"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,
|
"last_move": self.last_move.to_json() if self.last_move else None,
|
||||||
"acked": list(self.acked),
|
"acked": list(self.acked),
|
||||||
"hand_end_deadline": self.hand_end_deadline,
|
"hand_end_deadline": self.hand_end_deadline,
|
||||||
@@ -216,14 +214,11 @@ class GameState:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_json(data: Dict[str, Any]) -> "GameState":
|
def from_json(data: Dict[str, Any]) -> "ScoponeState":
|
||||||
return GameState(
|
return ScoponeState(
|
||||||
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)),
|
target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)),
|
||||||
napola=bool(data.get("napola", True)),
|
napola=bool(data.get("napola", True)),
|
||||||
|
asso_pigliatutto=bool(data.get("asso_pigliatutto", False)),
|
||||||
phase=str(data.get("phase", PHASE_LOBBY)),
|
phase=str(data.get("phase", PHASE_LOBBY)),
|
||||||
players=[PlayerState.from_json(p) for p in data.get("players", [])],
|
players=[PlayerState.from_json(p) for p in data.get("players", [])],
|
||||||
table=[Card.from_json(c) for c in data.get("table", [])],
|
table=[Card.from_json(c) for c in data.get("table", [])],
|
||||||
@@ -234,9 +229,6 @@ class GameState:
|
|||||||
winner=data.get("winner"),
|
winner=data.get("winner"),
|
||||||
last_taker=data.get("last_taker"),
|
last_taker=data.get("last_taker"),
|
||||||
hand_scores=list(data.get("hand_scores", [])),
|
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,
|
last_move=Move.from_json(data["last_move"]) if data.get("last_move") else None,
|
||||||
acked=[int(s) for s in data.get("acked", [])],
|
acked=[int(s) for s in data.get("acked", [])],
|
||||||
hand_end_deadline=data.get("hand_end_deadline"),
|
hand_end_deadline=data.get("hand_end_deadline"),
|
||||||
@@ -247,7 +239,7 @@ class GameState:
|
|||||||
|
|
||||||
# -- helpers ----------------------------------------------------------
|
# -- helpers ----------------------------------------------------------
|
||||||
|
|
||||||
def player_for(self, sub: str) -> Optional[PlayerState]:
|
def player_for(self, sub: Optional[str]) -> Optional[PlayerState]:
|
||||||
for player in self.players:
|
for player in self.players:
|
||||||
if player.sub == sub:
|
if player.sub == sub:
|
||||||
return player
|
return player
|
||||||
+250
-18
@@ -4,19 +4,19 @@ from __future__ import annotations
|
|||||||
import random
|
import random
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from tavolo.game import engine
|
from tavolo.scopone import engine
|
||||||
from tavolo.game.errors import (
|
from tavolo.scopone.errors import (
|
||||||
CardNotInHand,
|
CardNotInHand,
|
||||||
GameFinished,
|
GameFinished,
|
||||||
GameNotStarted,
|
GameNotStarted,
|
||||||
IllegalMove,
|
IllegalMove,
|
||||||
NotYourTurn,
|
NotYourTurn,
|
||||||
)
|
)
|
||||||
from tavolo.game.state import (
|
from tavolo.scopone.state import (
|
||||||
PHASE_FINISHED,
|
PHASE_FINISHED,
|
||||||
PHASE_PLAYING,
|
PHASE_PLAYING,
|
||||||
Card,
|
Card,
|
||||||
GameState,
|
ScoponeState,
|
||||||
PlayerState,
|
PlayerState,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,12 +34,9 @@ def make_state(
|
|||||||
scope=None,
|
scope=None,
|
||||||
target: int = 11,
|
target: int = 11,
|
||||||
last_taker=None,
|
last_taker=None,
|
||||||
) -> GameState:
|
) -> ScoponeState:
|
||||||
"""Build a controlled game state directly (bypassing the deal)."""
|
"""Build a controlled game state directly (bypassing the deal)."""
|
||||||
state = GameState(
|
state = ScoponeState(
|
||||||
id="game-1",
|
|
||||||
join_code="ABC123",
|
|
||||||
creator_sub="p0",
|
|
||||||
target_score=target,
|
target_score=target,
|
||||||
phase=PHASE_PLAYING,
|
phase=PHASE_PLAYING,
|
||||||
turn=turn,
|
turn=turn,
|
||||||
@@ -315,22 +312,237 @@ class NapolaTest(unittest.TestCase):
|
|||||||
state = make_state([["02D"], [], [], []], table=[])
|
state = make_state([["02D"], [], [], []], table=[])
|
||||||
self.assertTrue(state.napola)
|
self.assertTrue(state.napola)
|
||||||
state.napola = False
|
state.napola = False
|
||||||
self.assertFalse(GameState.from_json(state.to_json()).napola)
|
self.assertFalse(ScoponeState.from_json(state.to_json()).napola)
|
||||||
# States serialized before the option existed default to enabled.
|
# States serialized before the option existed default to enabled.
|
||||||
data = state.to_json()
|
data = state.to_json()
|
||||||
del data["napola"]
|
del data["napola"]
|
||||||
self.assertTrue(GameState.from_json(data).napola)
|
self.assertTrue(ScoponeState.from_json(data).napola)
|
||||||
|
|
||||||
def test_create_game_napola_default_and_override(self) -> None:
|
def test_create_game_napola_default_and_override(self) -> None:
|
||||||
self.assertTrue(engine.create_game("g", "CODE42", "p0", "p0").napola)
|
self.assertTrue(engine.create_game("p0", "p0").napola)
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
engine.create_game("g", "CODE42", "p0", "p0", napola=False).napola
|
engine.create_game("p0", "p0", napola=False).napola
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AssoPigliatuttoTest(unittest.TestCase):
|
||||||
|
def test_ace_sweeps_the_whole_table_without_scopa(self) -> None:
|
||||||
|
# The other players still hold cards, so emptying the table would
|
||||||
|
# normally score a scopa: the sweep must suppress it.
|
||||||
|
state = make_state(
|
||||||
|
[["01D", "09C"], ["04D"], ["05D"], ["06D"]],
|
||||||
|
table=["05C", "02D", "03S"],
|
||||||
|
)
|
||||||
|
state.asso_pigliatutto = True
|
||||||
|
engine.play(state, "p0", "01D", ["05C", "02D", "03S"])
|
||||||
|
self.assertEqual([], state.table)
|
||||||
|
self.assertEqual(
|
||||||
|
{"05C", "02D", "03S", "01D"},
|
||||||
|
{c.code for c in state.players[0].captured},
|
||||||
|
)
|
||||||
|
self.assertEqual(0, state.players[0].scope)
|
||||||
|
assert state.last_move is not None
|
||||||
|
self.assertTrue(state.last_move.pigliatutto)
|
||||||
|
self.assertFalse(state.last_move.scopa)
|
||||||
|
|
||||||
|
def test_legal_captures_offer_only_the_whole_table(self) -> None:
|
||||||
|
table = [card("05C"), card("02D"), card("03S")]
|
||||||
|
options = engine.legal_captures(
|
||||||
|
table, card("01D"), asso_pigliatutto=True
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[["05C", "02D", "03S"]], [[c.code for c in o] for o in options]
|
||||||
|
)
|
||||||
|
# A bare table leaves only the self-capture option; without the
|
||||||
|
# rule the ace stays on the table.
|
||||||
|
self.assertEqual(
|
||||||
|
[[]], engine.legal_captures([], card("01D"), asso_pigliatutto=True)
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[], engine.legal_captures([], card("01D"), asso_pigliatutto=False)
|
||||||
|
)
|
||||||
|
# Non-ace cards keep the normal rules.
|
||||||
|
self.assertEqual(
|
||||||
|
[],
|
||||||
|
engine.legal_captures(table, card("04B"), asso_pigliatutto=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_equal_rank_ace_blocks_the_sweep(self) -> None:
|
||||||
|
table = ["01C", "02D"]
|
||||||
|
state = make_state([["01D"], ["04D"], ["05D"], ["06D"]], table=table)
|
||||||
|
state.asso_pigliatutto = True
|
||||||
|
options = engine.legal_captures(
|
||||||
|
state.table, card("01D"), asso_pigliatutto=True
|
||||||
|
)
|
||||||
|
self.assertEqual([["01C"]], [[c.code for c in o] for o in options])
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.play(state, "p0", "01D", ["01C", "02D"])
|
||||||
|
# A rejected play consumes the card, so retry on a fresh state.
|
||||||
|
state = make_state([["01D"], ["04D"], ["05D"], ["06D"]], table=table)
|
||||||
|
state.asso_pigliatutto = True
|
||||||
|
engine.play(state, "p0", "01D", ["01C"])
|
||||||
|
self.assertEqual(["02D"], [c.code for c in state.table])
|
||||||
|
assert state.last_move is not None
|
||||||
|
self.assertFalse(state.last_move.pigliatutto)
|
||||||
|
|
||||||
|
def test_disabled_by_default(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["01D", "09C"], ["04D"], ["05D"], ["06D"]],
|
||||||
|
table=["05C", "02D"],
|
||||||
|
)
|
||||||
|
engine.play(state, "p0", "01D")
|
||||||
|
# Without the rule an ace captures nothing here and stays on the
|
||||||
|
# table.
|
||||||
|
self.assertEqual({"05C", "02D", "01D"}, {c.code for c in state.table})
|
||||||
|
assert state.last_move is not None
|
||||||
|
self.assertFalse(state.last_move.pigliatutto)
|
||||||
|
# ... and on a bare table it stays there too.
|
||||||
|
state = make_state(
|
||||||
|
[["01D", "09C"], ["04D"], ["05D"], ["06D"]], table=[]
|
||||||
|
)
|
||||||
|
engine.play(state, "p0", "01D")
|
||||||
|
self.assertEqual(["01D"], [c.code for c in state.table])
|
||||||
|
self.assertEqual([], state.players[0].captured)
|
||||||
|
self.assertEqual(
|
||||||
|
[],
|
||||||
|
engine.legal_captures(
|
||||||
|
[card("05C")], card("01D"), asso_pigliatutto=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_ace_on_empty_table_captures_itself(self) -> None:
|
||||||
|
# Other players still hold cards, so the bare table would trigger a
|
||||||
|
# (nonsensical) scopa without the pigliatutto suppression. The
|
||||||
|
# self-capture makes p0 the last taker, replacing seat 1.
|
||||||
|
state = make_state(
|
||||||
|
[["01D", "09C"], ["04D"], ["05D"], ["06D"]],
|
||||||
|
table=[],
|
||||||
|
last_taker=1,
|
||||||
|
)
|
||||||
|
state.asso_pigliatutto = True
|
||||||
|
engine.play(state, "p0", "01D")
|
||||||
|
self.assertEqual([], state.table)
|
||||||
|
self.assertEqual(["01D"], [c.code for c in state.players[0].captured])
|
||||||
|
self.assertEqual(0, state.players[0].scope)
|
||||||
|
self.assertEqual(0, state.last_taker)
|
||||||
|
assert state.last_move is not None
|
||||||
|
self.assertTrue(state.last_move.pigliatutto)
|
||||||
|
self.assertEqual([], state.last_move.captured)
|
||||||
|
self.assertFalse(state.last_move.scopa)
|
||||||
|
|
||||||
|
def test_non_ace_on_empty_table_stays(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D", "09C"], ["04D"], ["05D"], ["06D"]], table=[]
|
||||||
|
)
|
||||||
|
state.asso_pigliatutto = True
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
self.assertEqual(["02D"], [c.code for c in state.table])
|
||||||
|
self.assertEqual([], state.players[0].captured)
|
||||||
|
assert state.last_move is not None
|
||||||
|
self.assertFalse(state.last_move.pigliatutto)
|
||||||
|
|
||||||
|
def test_move_pigliatutto_serialization(self) -> None:
|
||||||
|
move = engine.Move(
|
||||||
|
seat=0, name="p0", card="01D", captured=["05C"], pigliatutto=True
|
||||||
|
)
|
||||||
|
restored = engine.Move.from_json(move.to_json())
|
||||||
|
self.assertTrue(restored.pigliatutto)
|
||||||
|
legacy = move.to_json()
|
||||||
|
del legacy["pigliatutto"]
|
||||||
|
self.assertFalse(engine.Move.from_json(legacy).pigliatutto)
|
||||||
|
|
||||||
|
def test_state_serialization_roundtrip(self) -> None:
|
||||||
|
state = make_state([["02D"], [], [], []], table=[])
|
||||||
|
self.assertFalse(state.asso_pigliatutto)
|
||||||
|
state.asso_pigliatutto = True
|
||||||
|
self.assertTrue(
|
||||||
|
ScoponeState.from_json(state.to_json()).asso_pigliatutto
|
||||||
|
)
|
||||||
|
# States serialized before the option existed default to disabled.
|
||||||
|
data = state.to_json()
|
||||||
|
del data["asso_pigliatutto"]
|
||||||
|
self.assertFalse(ScoponeState.from_json(data).asso_pigliatutto)
|
||||||
|
|
||||||
|
def test_create_game_default_and_override(self) -> None:
|
||||||
|
self.assertFalse(engine.create_game("p0", "p0").asso_pigliatutto)
|
||||||
|
self.assertTrue(
|
||||||
|
engine.create_game("p0", "p0", asso_pigliatutto=True).asso_pigliatutto
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_state_for_player_exposes_rule_and_legal_moves(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["01D"], ["04D"], ["05D"], ["06D"]],
|
||||||
|
table=["05C", "02D", "03S"],
|
||||||
|
)
|
||||||
|
state.asso_pigliatutto = True
|
||||||
|
view = engine.state_for_player(state, "p0")
|
||||||
|
self.assertTrue(view["asso_pigliatutto"])
|
||||||
|
self.assertEqual(
|
||||||
|
{"01D": [["05C", "02D", "03S"]]}, view["legal_moves"]
|
||||||
|
)
|
||||||
|
# On a bare table the ace advertises the self-capture.
|
||||||
|
state = make_state(
|
||||||
|
[["01D"], ["04D"], ["05D"], ["06D"]], table=[]
|
||||||
|
)
|
||||||
|
state.asso_pigliatutto = True
|
||||||
|
view = engine.state_for_player(state, "p0")
|
||||||
|
self.assertEqual({"01D": [[]]}, view["legal_moves"])
|
||||||
|
|
||||||
|
def test_auto_play_sweeps_with_the_rule(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["01D"], ["04D"], ["05D"], ["06D"]],
|
||||||
|
table=["05C", "02D"],
|
||||||
|
)
|
||||||
|
state.asso_pigliatutto = True
|
||||||
|
engine.auto_play(state)
|
||||||
|
assert state.last_move is not None
|
||||||
|
self.assertTrue(state.last_move.pigliatutto)
|
||||||
|
self.assertEqual(["05C", "02D"], state.last_move.captured)
|
||||||
|
|
||||||
|
def test_auto_play_self_captures_on_bare_table(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["01D"], ["04D"], ["05D"], ["06D"]], table=[]
|
||||||
|
)
|
||||||
|
state.asso_pigliatutto = True
|
||||||
|
engine.auto_play(state)
|
||||||
|
assert state.last_move is not None
|
||||||
|
self.assertTrue(state.last_move.pigliatutto)
|
||||||
|
self.assertEqual(["01D"], [c.code for c in state.players[0].captured])
|
||||||
|
self.assertEqual([], state.table)
|
||||||
|
|
||||||
|
def test_full_random_match_with_rule_keeps_all_cards(self) -> None:
|
||||||
|
state = engine.create_game(
|
||||||
|
"p0", "p0", target_score=11, asso_pigliatutto=True
|
||||||
|
)
|
||||||
|
for i in range(1, 4):
|
||||||
|
engine.join_game(state, f"p{i}", f"p{i}")
|
||||||
|
|
||||||
|
moves = 0
|
||||||
|
while state.phase != PHASE_FINISHED and moves < 200000:
|
||||||
|
if state.phase == "hand_end":
|
||||||
|
for p in state.players:
|
||||||
|
engine.acknowledge_hand(state, p.sub)
|
||||||
|
continue
|
||||||
|
player = next(p for p in state.players if p.seat == state.turn)
|
||||||
|
played = player.hand[0]
|
||||||
|
options = engine.legal_captures(
|
||||||
|
state.table, played, asso_pigliatutto=state.asso_pigliatutto
|
||||||
|
)
|
||||||
|
capture = [c.code for c in options[0]] if options else None
|
||||||
|
engine.play(state, player.sub, played.code, capture)
|
||||||
|
moves += 1
|
||||||
|
|
||||||
|
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||||
|
self.assertIn(state.winner, (0, 1))
|
||||||
|
# Self-captures included, all 40 cards end up in capture piles.
|
||||||
|
self.assertEqual([], state.table)
|
||||||
|
self.assertTrue(all(not p.hand for p in state.players))
|
||||||
|
self.assertEqual(40, sum(len(p.captured) for p in state.players))
|
||||||
|
|
||||||
|
|
||||||
class MatchFlowTest(unittest.TestCase):
|
class MatchFlowTest(unittest.TestCase):
|
||||||
def test_join_starts_when_full(self) -> None:
|
def test_join_starts_when_full(self) -> None:
|
||||||
state = engine.create_game("g", "CODE42", "p0", "p0", target_score=11)
|
state = engine.create_game("p0", "p0", target_score=11)
|
||||||
self.assertEqual(1, len(state.players))
|
self.assertEqual(1, len(state.players))
|
||||||
engine.join_game(state, "p1", "p1")
|
engine.join_game(state, "p1", "p1")
|
||||||
engine.join_game(state, "p2", "p2")
|
engine.join_game(state, "p2", "p2")
|
||||||
@@ -362,6 +574,27 @@ class MatchFlowTest(unittest.TestCase):
|
|||||||
self.assertEqual(["07C"], view["table"])
|
self.assertEqual(["07C"], view["table"])
|
||||||
self.assertTrue(view.get("your_turn"))
|
self.assertTrue(view.get("your_turn"))
|
||||||
|
|
||||||
|
def test_state_for_player_spectator_sees_public_info_only(self) -> None:
|
||||||
|
state = make_state(
|
||||||
|
[["02D", "03C"], ["04D"], ["05D"], ["06D"]],
|
||||||
|
table=["07C"],
|
||||||
|
captured=[["01D"], [], ["02C"], ["03B"]],
|
||||||
|
scope=[1, 0, 2, 0],
|
||||||
|
)
|
||||||
|
view = engine.state_for_player(state, None)
|
||||||
|
for player in view["players"]:
|
||||||
|
self.assertNotIn("hand", player)
|
||||||
|
self.assertNotIn("your_turn", view)
|
||||||
|
self.assertNotIn("legal_moves", view)
|
||||||
|
# The public table state is still fully visible.
|
||||||
|
self.assertEqual(["07C"], view["table"])
|
||||||
|
self.assertEqual({"A": 0, "B": 0}, view["scores"])
|
||||||
|
players = {p["seat"]: p for p in view["players"]}
|
||||||
|
self.assertEqual(1, players[0]["captured_count"])
|
||||||
|
self.assertEqual(1, players[0]["scope"])
|
||||||
|
self.assertEqual(2, players[2]["scope"])
|
||||||
|
self.assertEqual(1, players[1]["cards_left"])
|
||||||
|
|
||||||
def test_legal_moves_only_for_player_on_turn(self) -> None:
|
def test_legal_moves_only_for_player_on_turn(self) -> None:
|
||||||
state = make_state([["02D", "03C"], ["04D"], ["05D"], ["06D"]],
|
state = make_state([["02D", "03C"], ["04D"], ["05D"], ["06D"]],
|
||||||
table=["07C"])
|
table=["07C"])
|
||||||
@@ -382,7 +615,7 @@ class MatchFlowTest(unittest.TestCase):
|
|||||||
self.assertNotIn("your_turn", other)
|
self.assertNotIn("your_turn", other)
|
||||||
|
|
||||||
def test_full_random_match_reaches_completion(self) -> None:
|
def test_full_random_match_reaches_completion(self) -> None:
|
||||||
state = engine.create_game("g", "CODE99", "p0", "p0", target_score=11)
|
state = engine.create_game("p0", "p0", target_score=11)
|
||||||
for i in range(1, 4):
|
for i in range(1, 4):
|
||||||
engine.join_game(state, f"p{i}", f"p{i}")
|
engine.join_game(state, f"p{i}", f"p{i}")
|
||||||
|
|
||||||
@@ -405,11 +638,10 @@ class MatchFlowTest(unittest.TestCase):
|
|||||||
self.assertEqual([], state.table)
|
self.assertEqual([], state.table)
|
||||||
self.assertTrue(all(not p.hand for p in state.players))
|
self.assertTrue(all(not p.hand for p in state.players))
|
||||||
self.assertEqual(40, sum(len(p.captured) 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):
|
class HandEndAckTest(unittest.TestCase):
|
||||||
def _hand_end_state(self) -> GameState:
|
def _hand_end_state(self) -> ScoponeState:
|
||||||
"""Drive a game into the hand_end phase with a one-card hand."""
|
"""Drive a game into the hand_end phase with a one-card hand."""
|
||||||
state = make_state([["02D"], [], [], []],
|
state = make_state([["02D"], [], [], []],
|
||||||
table=["02C"], target=11)
|
table=["02C"], target=11)
|
||||||
@@ -525,7 +757,7 @@ class AutoPlayTest(unittest.TestCase):
|
|||||||
engine.auto_play(state)
|
engine.auto_play(state)
|
||||||
|
|
||||||
def test_create_game_copies_turn_timeout_and_arms_deadline(self) -> None:
|
def test_create_game_copies_turn_timeout_and_arms_deadline(self) -> None:
|
||||||
state = engine.create_game("g", "CODE98", "p0", "p0", turn_timeout=7)
|
state = engine.create_game("p0", "p0", turn_timeout=7)
|
||||||
self.assertEqual(7, state.turn_timeout)
|
self.assertEqual(7, state.turn_timeout)
|
||||||
for i in range(1, 4):
|
for i in range(1, 4):
|
||||||
engine.join_game(state, f"p{i}", f"p{i}")
|
engine.join_game(state, f"p{i}", f"p{i}")
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
"""ScoponeEngine adapter tests: the platform contract over the pure rules."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from tavolo.platform import GameSession, Seat
|
||||||
|
from tavolo.platform.errors import GameError, IllegalMove
|
||||||
|
from tavolo.scopone import ScoponeEngine
|
||||||
|
from tavolo.scopone.state import PHASE_FINISHED, PHASE_HAND_END, PHASE_PLAYING, ScoponeState
|
||||||
|
|
||||||
|
|
||||||
|
def _session(engine: ScoponeEngine, **options) -> GameSession:
|
||||||
|
session = GameSession(
|
||||||
|
id="s1",
|
||||||
|
game_type=engine.id,
|
||||||
|
join_code="CODE01",
|
||||||
|
creator_sub="alice",
|
||||||
|
players=[Seat(user_sub="alice", display_name="Alice")],
|
||||||
|
)
|
||||||
|
engine.create(session, options)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def _started(engine: ScoponeEngine, **options) -> GameSession:
|
||||||
|
session = _session(engine, **options)
|
||||||
|
for name in ("bob", "carol", "dave"):
|
||||||
|
engine.join(session, name, name.capitalize())
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
class CreateTest(unittest.TestCase):
|
||||||
|
def test_create_seats_creator_on_team_a(self) -> None:
|
||||||
|
engine = ScoponeEngine()
|
||||||
|
session = _session(engine)
|
||||||
|
self.assertEqual("A", session.players[0].team)
|
||||||
|
self.assertIsInstance(session.state, ScoponeState)
|
||||||
|
self.assertEqual(11, session.state.target_score)
|
||||||
|
self.assertTrue(session.state.napola)
|
||||||
|
|
||||||
|
def test_create_options(self) -> None:
|
||||||
|
engine = ScoponeEngine()
|
||||||
|
session = _session(engine, target_score=16, napola=False)
|
||||||
|
self.assertEqual(16, session.state.target_score)
|
||||||
|
self.assertFalse(session.state.napola)
|
||||||
|
self.assertFalse(session.state.asso_pigliatutto)
|
||||||
|
|
||||||
|
def test_create_asso_pigliatutto_option(self) -> None:
|
||||||
|
engine = ScoponeEngine()
|
||||||
|
session = _session(engine, asso_pigliatutto=True)
|
||||||
|
self.assertTrue(session.state.asso_pigliatutto)
|
||||||
|
|
||||||
|
def test_create_rejects_bad_options(self) -> None:
|
||||||
|
engine = ScoponeEngine()
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
_session(engine, target_score=0)
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
_session(engine, target_score="eleven")
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
_session(engine, napola="yes")
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
_session(engine, asso_pigliatutto="yes")
|
||||||
|
|
||||||
|
def test_timeouts_come_from_the_engine(self) -> None:
|
||||||
|
engine = ScoponeEngine(turn_timeout_seconds=7, hand_ack_timeout_seconds=9)
|
||||||
|
session = _session(engine)
|
||||||
|
self.assertEqual(7, session.state.turn_timeout)
|
||||||
|
self.assertEqual(9, session.state.hand_ack_timeout)
|
||||||
|
|
||||||
|
|
||||||
|
class JoinTest(unittest.TestCase):
|
||||||
|
def test_join_assigns_teams_and_starts(self) -> None:
|
||||||
|
engine = ScoponeEngine()
|
||||||
|
session = _session(engine)
|
||||||
|
self.assertTrue(engine.in_lobby(session))
|
||||||
|
for name, team in (("bob", "B"), ("carol", "A"), ("dave", "B")):
|
||||||
|
engine.join(session, name, name.capitalize())
|
||||||
|
self.assertEqual(team, session.players[-1].team)
|
||||||
|
self.assertFalse(engine.in_lobby(session))
|
||||||
|
self.assertEqual(PHASE_PLAYING, session.state.phase)
|
||||||
|
|
||||||
|
def test_join_errors(self) -> None:
|
||||||
|
from tavolo.platform.errors import AlreadyJoined, GameNotStarted
|
||||||
|
|
||||||
|
engine = ScoponeEngine()
|
||||||
|
session = _session(engine)
|
||||||
|
with self.assertRaises(AlreadyJoined):
|
||||||
|
engine.join(session, "alice", "Alice")
|
||||||
|
for name in ("bob", "carol", "dave"):
|
||||||
|
engine.join(session, name, name.capitalize())
|
||||||
|
# The lobby filled up and the match started: late joins and even
|
||||||
|
# re-joins are rejected as "already started".
|
||||||
|
with self.assertRaises(GameNotStarted):
|
||||||
|
engine.join(session, "erin", "Erin")
|
||||||
|
with self.assertRaises(GameNotStarted):
|
||||||
|
engine.join(session, "alice", "Alice")
|
||||||
|
|
||||||
|
|
||||||
|
class ActionTest(unittest.TestCase):
|
||||||
|
def test_unknown_action_rejected(self) -> None:
|
||||||
|
engine = ScoponeEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.handle_action(session, "alice", "dance", {})
|
||||||
|
|
||||||
|
def test_play_validates_payload(self) -> None:
|
||||||
|
engine = ScoponeEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.handle_action(session, "bob", "play", {"card": 42})
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.handle_action(session, "bob", "play", {})
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.handle_action(session, "bob", "play", {"card": "01D", "capture": "02C"})
|
||||||
|
|
||||||
|
def test_invalid_card_code_is_illegal_move(self) -> None:
|
||||||
|
engine = ScoponeEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.handle_action(session, "bob", "play", {"card": "nope"})
|
||||||
|
|
||||||
|
def test_finished_match_rejects_actions(self) -> None:
|
||||||
|
from tavolo.platform.errors import GameFinished
|
||||||
|
|
||||||
|
engine = ScoponeEngine()
|
||||||
|
session = _started(engine, target_score=1)
|
||||||
|
# Drive to completion: keep playing legal moves until finished.
|
||||||
|
from tavolo.scopone import engine as rules
|
||||||
|
|
||||||
|
moves = 0
|
||||||
|
while not engine.is_finished(session) and moves < 200000:
|
||||||
|
state = session.state
|
||||||
|
if state.phase == "hand_end":
|
||||||
|
for p in state.players:
|
||||||
|
engine.handle_action(session, p.sub, "ack", {})
|
||||||
|
continue
|
||||||
|
player = next(p for p in state.players if p.seat == state.turn)
|
||||||
|
played = player.hand[0]
|
||||||
|
options = rules.legal_captures(state.table, played)
|
||||||
|
payload = {"card": played.code}
|
||||||
|
if options:
|
||||||
|
payload["capture"] = [c.code for c in options[0]]
|
||||||
|
engine.handle_action(session, player.sub, "play", payload)
|
||||||
|
moves += 1
|
||||||
|
self.assertTrue(engine.is_finished(session))
|
||||||
|
with self.assertRaises(GameFinished):
|
||||||
|
engine.handle_action(session, "alice", "play", {"card": "01D"})
|
||||||
|
|
||||||
|
|
||||||
|
class ViewTest(unittest.TestCase):
|
||||||
|
def test_view_for_hides_other_hands(self) -> None:
|
||||||
|
engine = ScoponeEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
view = engine.view_for(session, "alice")
|
||||||
|
players = {p["seat"]: p for p in view["players"]}
|
||||||
|
self.assertIn("hand", players[0])
|
||||||
|
self.assertNotIn("hand", players[1])
|
||||||
|
# The envelope is the platform's job, not the view's.
|
||||||
|
self.assertNotIn("id", view)
|
||||||
|
self.assertNotIn("join_code", view)
|
||||||
|
self.assertNotIn("game_type", view)
|
||||||
|
|
||||||
|
def test_view_for_spectator_hides_all_hands(self) -> None:
|
||||||
|
engine = ScoponeEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
view = engine.view_for(session, None)
|
||||||
|
for player in view["players"]:
|
||||||
|
self.assertNotIn("hand", player)
|
||||||
|
self.assertNotIn("your_turn", view)
|
||||||
|
self.assertNotIn("legal_moves", view)
|
||||||
|
self.assertIn("table", view)
|
||||||
|
self.assertEqual({"A": 0, "B": 0}, view["scores"])
|
||||||
|
|
||||||
|
def test_lobby_view(self) -> None:
|
||||||
|
engine = ScoponeEngine()
|
||||||
|
session = _session(engine, target_score=16)
|
||||||
|
lobby = engine.lobby_view(session)
|
||||||
|
self.assertEqual("lobby", lobby["phase"])
|
||||||
|
self.assertEqual(16, lobby["target_score"])
|
||||||
|
self.assertTrue(lobby["napola"])
|
||||||
|
self.assertFalse(lobby["asso_pigliatutto"])
|
||||||
|
|
||||||
|
|
||||||
|
class SerializationTest(unittest.TestCase):
|
||||||
|
def test_state_roundtrip(self) -> None:
|
||||||
|
engine = ScoponeEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
restored = engine.state_from_json(engine.state_to_json(session.state))
|
||||||
|
self.assertIsInstance(restored, ScoponeState)
|
||||||
|
self.assertEqual(session.state.phase, restored.phase)
|
||||||
|
self.assertEqual(session.state.turn, restored.turn)
|
||||||
|
self.assertEqual(
|
||||||
|
[p.sub for p in session.state.players],
|
||||||
|
[p.sub for p in restored.players],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeadlineTest(unittest.TestCase):
|
||||||
|
def test_no_deadline_in_lobby(self) -> None:
|
||||||
|
engine = ScoponeEngine()
|
||||||
|
self.assertIsNone(engine.next_deadline(_session(engine)))
|
||||||
|
|
||||||
|
def test_turn_deadline_and_revalidation(self) -> None:
|
||||||
|
engine = ScoponeEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
deadline = engine.next_deadline(session)
|
||||||
|
assert deadline is not None
|
||||||
|
self.assertEqual("turn", deadline.kind)
|
||||||
|
# A forged token is stale.
|
||||||
|
with self.assertRaises(GameError):
|
||||||
|
engine.fire_deadline(session, "turn", "turn:1:1:0")
|
||||||
|
turn_before = session.state.turn
|
||||||
|
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||||
|
self.assertNotEqual(turn_before, session.state.turn)
|
||||||
|
|
||||||
|
def test_stale_deadline_after_play(self) -> None:
|
||||||
|
from tavolo.scopone import engine as rules
|
||||||
|
|
||||||
|
engine = ScoponeEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
deadline = engine.next_deadline(session)
|
||||||
|
assert deadline is not None
|
||||||
|
# A play lands in time: the armed deadline is overtaken.
|
||||||
|
state = session.state
|
||||||
|
player = next(p for p in state.players if p.seat == state.turn)
|
||||||
|
played = player.hand[0]
|
||||||
|
options = rules.legal_captures(state.table, played)
|
||||||
|
payload = {"card": played.code}
|
||||||
|
if options:
|
||||||
|
payload["capture"] = [c.code for c in options[0]]
|
||||||
|
engine.handle_action(session, player.sub, "play", payload)
|
||||||
|
with self.assertRaises(GameError):
|
||||||
|
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||||
|
|
||||||
|
def test_hand_end_deadline_advances(self) -> None:
|
||||||
|
engine = ScoponeEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
state = session.state
|
||||||
|
# Force the hand-end phase with an imminent deadline.
|
||||||
|
state.phase = PHASE_HAND_END
|
||||||
|
state.hand_end_deadline = (
|
||||||
|
datetime.now(timezone.utc) + timedelta(seconds=60)
|
||||||
|
).isoformat()
|
||||||
|
deadline = engine.next_deadline(session)
|
||||||
|
assert deadline is not None
|
||||||
|
self.assertEqual("hand_end", deadline.kind)
|
||||||
|
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||||
|
self.assertEqual(PHASE_PLAYING, session.state.phase)
|
||||||
|
self.assertEqual(2, session.state.hand_number)
|
||||||
|
|
||||||
|
|
||||||
|
class ResultTest(unittest.TestCase):
|
||||||
|
def test_result_of_finished_match(self) -> None:
|
||||||
|
from tavolo.scopone import engine as rules
|
||||||
|
|
||||||
|
engine = ScoponeEngine()
|
||||||
|
session = _started(engine, target_score=1)
|
||||||
|
moves = 0
|
||||||
|
while not engine.is_finished(session) and moves < 200000:
|
||||||
|
state = session.state
|
||||||
|
if state.phase == "hand_end":
|
||||||
|
for p in state.players:
|
||||||
|
engine.handle_action(session, p.sub, "ack", {})
|
||||||
|
continue
|
||||||
|
player = next(p for p in state.players if p.seat == state.turn)
|
||||||
|
played = player.hand[0]
|
||||||
|
options = rules.legal_captures(state.table, played)
|
||||||
|
payload = {"card": played.code}
|
||||||
|
if options:
|
||||||
|
payload["capture"] = [c.code for c in options[0]]
|
||||||
|
engine.handle_action(session, player.sub, "play", payload)
|
||||||
|
moves += 1
|
||||||
|
result = engine.result(session)
|
||||||
|
self.assertEqual(2, len(result.teams))
|
||||||
|
self.assertIn(result.winner_team, (0, 1))
|
||||||
|
self.assertEqual(4, len(result.players))
|
||||||
|
for player in result.players:
|
||||||
|
self.assertEqual(
|
||||||
|
player.won, player.team == ("A" if result.winner_team == 0 else "B")
|
||||||
|
)
|
||||||
|
self.assertIn("team_a_score", result.summary)
|
||||||
|
self.assertIn("hands_played", result.summary)
|
||||||
|
|
||||||
|
def test_result_requires_finished_match(self) -> None:
|
||||||
|
engine = ScoponeEngine()
|
||||||
|
with self.assertRaises(GameError):
|
||||||
|
engine.result(_started(engine))
|
||||||
|
|
||||||
|
def test_game_over_view(self) -> None:
|
||||||
|
engine = ScoponeEngine()
|
||||||
|
session = _started(engine)
|
||||||
|
session.state.phase = PHASE_FINISHED
|
||||||
|
session.state.winner = 1
|
||||||
|
session.state.scores = [3, 11]
|
||||||
|
over = engine.game_over_view(session, "alice")
|
||||||
|
self.assertEqual({"A": 3, "B": 11}, over["scores"])
|
||||||
|
self.assertEqual("B", over["winner"])
|
||||||
|
|
||||||
|
def test_registry_metadata(self) -> None:
|
||||||
|
engine = ScoponeEngine()
|
||||||
|
self.assertEqual("scopone_scientifico", engine.id)
|
||||||
|
self.assertEqual(4, engine.min_players)
|
||||||
|
self.assertEqual(4, engine.max_players)
|
||||||
|
self.assertIn("target_score", engine.options_schema["properties"])
|
||||||
|
self.assertIn("napola", engine.options_schema["properties"])
|
||||||
|
self.assertIn("asso_pigliatutto", engine.options_schema["properties"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+8
-55
@@ -1,43 +1,11 @@
|
|||||||
[build-system]
|
# NOTE: the tavolo-app distribution lives in packages/tavolo-app/ (same
|
||||||
requires = ["setuptools>=68"]
|
# kaya-style layout as tavolo-platform and tavolo-scopone). This file
|
||||||
build-backend = "setuptools.build_meta"
|
# exists only for tooling that reads configuration from the working
|
||||||
|
# directory:
|
||||||
[project]
|
# - aerich reads [tool.aerich] from here (server/ locally, /app in the
|
||||||
name = "tavolo"
|
# db-migrate compose service), next to ./migrations;
|
||||||
version = "0.1.0"
|
# - mypy reads [tool.mypy] from here when run from server/.
|
||||||
description = "Multiplayer card-game platform backend built on the kaya framework"
|
# See the Migrations section in README.md.
|
||||||
readme = "README.md"
|
|
||||||
requires-python = ">=3.10"
|
|
||||||
dependencies = [
|
|
||||||
"kaya-core",
|
|
||||||
"kaya-cors",
|
|
||||||
"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]
|
[tool.aerich]
|
||||||
tortoise_orm = "tavolo.aerich_config.TORTOISE_ORM"
|
tortoise_orm = "tavolo.aerich_config.TORTOISE_ORM"
|
||||||
location = "./migrations"
|
location = "./migrations"
|
||||||
@@ -46,18 +14,3 @@ location = "./migrations"
|
|||||||
python_version = "3.12"
|
python_version = "3.12"
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
plugins = []
|
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"]
|
|
||||||
|
|||||||
+107
-29
@@ -1,14 +1,26 @@
|
|||||||
#
|
#
|
||||||
# This file is autogenerated by pip-compile with Python 3.14
|
# This file is autogenerated by pip-compile with Python 3.14
|
||||||
# by the following command:
|
# by the following command (run from server/packages/tavolo-app/):
|
||||||
#
|
#
|
||||||
# pip-compile --allow-unsafe --extra-index-url=https://pypi.org/simple --index-url=https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --no-index --output-file=requirements.txt pyproject.toml
|
# pip-compile --allow-unsafe --extra otel --extra-index-url=https://pypi.org/simple --index-url=https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --output-file=../../requirements.txt pyproject.toml
|
||||||
|
#
|
||||||
|
# NOTE: the tavolo-platform, tavolo-scopone, tavolo-briscola and tavolo-app
|
||||||
|
# sibling packages are NOT pinned here: they are installed from ./packages in
|
||||||
|
# the Dockerfile and in development (pip install -e ./packages/tavolo-platform
|
||||||
|
# -e ./packages/tavolo-scopone -e ./packages/tavolo-briscola
|
||||||
|
# -e ./packages/tavolo-app). All of their
|
||||||
|
# third-party dependencies are direct dependencies of tavolo-app, so this
|
||||||
|
# lock covers the full closure. The otel extra (kaya-otel and its
|
||||||
|
# OpenTelemetry dependencies) IS included, so OTEL_ENABLED works in the
|
||||||
|
# Docker image; pip-compile cannot resolve the unpublishable sibling
|
||||||
|
# packages, so regeneration drops them from a copy of the input file (the
|
||||||
|
# existing pins constrain the rest).
|
||||||
#
|
#
|
||||||
--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple
|
--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple
|
||||||
--extra-index-url https://pypi.org/simple
|
--extra-index-url https://pypi.org/simple
|
||||||
|
|
||||||
aerich==0.10.1
|
aerich==0.10.1
|
||||||
# via tavolo (pyproject.toml)
|
# via tavolo-app (pyproject.toml)
|
||||||
aiosqlite==0.22.1
|
aiosqlite==0.22.1
|
||||||
# via tortoise-orm
|
# via tortoise-orm
|
||||||
anyio==4.15.1
|
anyio==4.15.1
|
||||||
@@ -19,7 +31,7 @@ anyio==4.15.1
|
|||||||
asyncclick==8.4.2.1
|
asyncclick==8.4.2.1
|
||||||
# via aerich
|
# via aerich
|
||||||
asyncpg==0.31.0
|
asyncpg==0.31.0
|
||||||
# via tavolo (pyproject.toml)
|
# via tavolo-app (pyproject.toml)
|
||||||
certifi==2026.7.22
|
certifi==2026.7.22
|
||||||
# via
|
# via
|
||||||
# httpcore
|
# httpcore
|
||||||
@@ -32,10 +44,12 @@ cryptography==50.0.1
|
|||||||
# via pyjwt
|
# via pyjwt
|
||||||
dictdiffer==0.10.0
|
dictdiffer==0.10.0
|
||||||
# via aerich
|
# via aerich
|
||||||
|
googleapis-common-protos==1.75.4
|
||||||
|
# via opentelemetry-exporter-otlp-proto-http
|
||||||
granian==2.8.3
|
granian==2.8.3
|
||||||
# via
|
# via
|
||||||
# kaya-rsgi
|
# kaya-rsgi
|
||||||
# tavolo (pyproject.toml)
|
# tavolo-app (pyproject.toml)
|
||||||
h11==0.16.0
|
h11==0.16.0
|
||||||
# via httpcore
|
# via httpcore
|
||||||
httpcore==1.0.9
|
httpcore==1.0.9
|
||||||
@@ -43,62 +57,126 @@ httpcore==1.0.9
|
|||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
# via
|
# via
|
||||||
# kaya-oidc
|
# kaya-oidc
|
||||||
# tavolo (pyproject.toml)
|
# tavolo-app (pyproject.toml)
|
||||||
idna==3.19
|
idna==3.20
|
||||||
# via
|
# via
|
||||||
# anyio
|
# anyio
|
||||||
# httpx
|
# httpx
|
||||||
iso8601==2.1.0
|
iso8601==2.1.0
|
||||||
# via tortoise-orm
|
# via tortoise-orm
|
||||||
kaya-core==0.0.3
|
kaya-core==0.0.7
|
||||||
# via
|
# via
|
||||||
# kaya-cors
|
# kaya-cors
|
||||||
# kaya-oidc
|
# kaya-oidc
|
||||||
# kaya-openapi
|
# kaya-openapi
|
||||||
|
# kaya-otel
|
||||||
# kaya-rsgi
|
# kaya-rsgi
|
||||||
# kaya-session
|
# kaya-session
|
||||||
# tavolo (pyproject.toml)
|
# tavolo-app (pyproject.toml)
|
||||||
kaya-cors==0.0.3
|
kaya-cors==0.0.7
|
||||||
# via tavolo (pyproject.toml)
|
# via tavolo-app (pyproject.toml)
|
||||||
kaya-oidc==0.0.3
|
kaya-oidc==0.0.7
|
||||||
# via tavolo (pyproject.toml)
|
# via tavolo-app (pyproject.toml)
|
||||||
kaya-openapi==0.0.3
|
kaya-openapi==0.0.7
|
||||||
# via tavolo (pyproject.toml)
|
# via tavolo-app (pyproject.toml)
|
||||||
kaya-rsgi==0.0.3
|
kaya-otel==0.0.7
|
||||||
# via tavolo (pyproject.toml)
|
# via tavolo-app (pyproject.toml)
|
||||||
kaya-session==0.0.3
|
kaya-rsgi==0.0.7
|
||||||
|
# via tavolo-app (pyproject.toml)
|
||||||
|
kaya-session==0.0.7
|
||||||
# via
|
# via
|
||||||
# kaya-oidc
|
# kaya-oidc
|
||||||
# kaya-session-redis
|
# kaya-session-redis
|
||||||
# tavolo (pyproject.toml)
|
# tavolo-app (pyproject.toml)
|
||||||
kaya-session-redis==0.0.3
|
kaya-session-redis==0.0.7
|
||||||
# via tavolo (pyproject.toml)
|
# via tavolo-app (pyproject.toml)
|
||||||
|
opentelemetry-api==1.45.0
|
||||||
|
# via
|
||||||
|
# opentelemetry-exporter-http-transport
|
||||||
|
# opentelemetry-exporter-otlp-proto-http
|
||||||
|
# opentelemetry-instrumentation
|
||||||
|
# opentelemetry-instrumentation-asyncpg
|
||||||
|
# opentelemetry-instrumentation-redis
|
||||||
|
# opentelemetry-instrumentation-system-metrics
|
||||||
|
# opentelemetry-sdk
|
||||||
|
# opentelemetry-semantic-conventions
|
||||||
|
opentelemetry-exporter-http-transport[urllib3]==0.66b0
|
||||||
|
# via opentelemetry-exporter-otlp-proto-http
|
||||||
|
opentelemetry-exporter-otlp-common==0.66b0
|
||||||
|
# via opentelemetry-exporter-otlp-proto-http
|
||||||
|
opentelemetry-exporter-otlp-proto-common==1.45.0
|
||||||
|
# via opentelemetry-exporter-otlp-proto-http
|
||||||
|
opentelemetry-exporter-otlp-proto-http==1.45.0
|
||||||
|
# via kaya-otel
|
||||||
|
opentelemetry-instrumentation==0.66b0
|
||||||
|
# via
|
||||||
|
# opentelemetry-instrumentation-asyncpg
|
||||||
|
# opentelemetry-instrumentation-redis
|
||||||
|
# opentelemetry-instrumentation-system-metrics
|
||||||
|
opentelemetry-instrumentation-asyncpg==0.66b0
|
||||||
|
# via tavolo-app (pyproject.toml)
|
||||||
|
opentelemetry-instrumentation-redis==0.66b0
|
||||||
|
# via tavolo-app (pyproject.toml)
|
||||||
|
opentelemetry-instrumentation-system-metrics==0.66b0
|
||||||
|
# via tavolo-app (pyproject.toml)
|
||||||
|
opentelemetry-proto==1.45.0
|
||||||
|
# via
|
||||||
|
# opentelemetry-exporter-otlp-proto-common
|
||||||
|
# opentelemetry-exporter-otlp-proto-http
|
||||||
|
opentelemetry-sdk==1.45.0
|
||||||
|
# via
|
||||||
|
# kaya-otel
|
||||||
|
# opentelemetry-exporter-otlp-common
|
||||||
|
# opentelemetry-exporter-otlp-proto-http
|
||||||
|
opentelemetry-semantic-conventions==0.66b0
|
||||||
|
# via
|
||||||
|
# opentelemetry-instrumentation
|
||||||
|
# opentelemetry-instrumentation-asyncpg
|
||||||
|
# opentelemetry-instrumentation-redis
|
||||||
|
# opentelemetry-instrumentation-system-metrics
|
||||||
|
# opentelemetry-sdk
|
||||||
|
packaging==26.3
|
||||||
|
# via opentelemetry-instrumentation
|
||||||
|
protobuf==7.36.2
|
||||||
|
# via
|
||||||
|
# googleapis-common-protos
|
||||||
|
# opentelemetry-proto
|
||||||
|
psutil==7.2.2
|
||||||
|
# via opentelemetry-instrumentation-system-metrics
|
||||||
pwo==0.1.2
|
pwo==0.1.2
|
||||||
# via
|
# via
|
||||||
# kaya-core
|
# kaya-core
|
||||||
# kaya-rsgi
|
# kaya-rsgi
|
||||||
# kaya-session
|
# kaya-session
|
||||||
# tavolo (pyproject.toml)
|
# tavolo-app (pyproject.toml)
|
||||||
pycparser==3.0
|
pycparser==3.0
|
||||||
# via cffi
|
# via cffi
|
||||||
pyjwt[crypto]==2.14.0
|
pyjwt[crypto]==2.15.1
|
||||||
# via
|
# via
|
||||||
# kaya-oidc
|
# kaya-oidc
|
||||||
# tavolo (pyproject.toml)
|
# tavolo-app (pyproject.toml)
|
||||||
pypika-tortoise==0.6.5
|
pypika-tortoise==0.6.5
|
||||||
# via tortoise-orm
|
# via tortoise-orm
|
||||||
pyyaml==6.0.3
|
pyyaml==6.0.3
|
||||||
# via tavolo (pyproject.toml)
|
# via tavolo-app (pyproject.toml)
|
||||||
redis==8.1.0
|
redis==8.1.0
|
||||||
# via
|
# via
|
||||||
# kaya-session-redis
|
# kaya-session-redis
|
||||||
# tavolo (pyproject.toml)
|
# tavolo-app (pyproject.toml)
|
||||||
tortoise-orm==1.1.8
|
tortoise-orm==1.1.8
|
||||||
# via
|
# via aerich
|
||||||
# aerich
|
|
||||||
# tavolo (pyproject.toml)
|
|
||||||
typing-extensions==4.16.0
|
typing-extensions==4.16.0
|
||||||
# via
|
# via
|
||||||
# anyio
|
# anyio
|
||||||
# kaya-core
|
# kaya-core
|
||||||
|
# opentelemetry-api
|
||||||
|
# opentelemetry-exporter-otlp-proto-http
|
||||||
|
# opentelemetry-sdk
|
||||||
|
# opentelemetry-semantic-conventions
|
||||||
# pwo
|
# pwo
|
||||||
|
urllib3==2.8.0
|
||||||
|
# via opentelemetry-exporter-http-transport
|
||||||
|
wrapt==2.5.0
|
||||||
|
# via
|
||||||
|
# opentelemetry-instrumentation
|
||||||
|
# opentelemetry-instrumentation-redis
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""Scopone scientifico backend built on the kaya framework."""
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
"""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``)
|
|
||||||
|
|
||||||
A :class:`~kaya.cors.CorsMixin` is prepended when CORS is configured via the
|
|
||||||
``CORS_*`` environment variables (see :mod:`tavolo.config`).
|
|
||||||
|
|
||||||
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 typing import Optional
|
|
||||||
|
|
||||||
from kaya.core import KayaApp, KayaMixin
|
|
||||||
from kaya.cors import CorsMixin
|
|
||||||
from kaya.oidc import OIDCConfig, OIDCMixin
|
|
||||||
from kaya.openapi import OpenAPIMixin
|
|
||||||
from kaya.session import InMemorySessionStore, SessionMixin, SessionStore
|
|
||||||
from kaya.session.redis import RedisSessionStore
|
|
||||||
from redis.asyncio import Redis
|
|
||||||
|
|
||||||
from .config import Settings, settings
|
|
||||||
from .deadlines import DeadlineSchedulerMixin
|
|
||||||
from .logging_config import configure_logging
|
|
||||||
from .store import GameStore, InMemoryGameStore, RedisGameStore
|
|
||||||
from .tortoise_mixin import TortoiseMixin
|
|
||||||
|
|
||||||
configure_logging(settings.logging_config)
|
|
||||||
log = getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def cors_mixin_from_settings(settings: Settings) -> Optional[CorsMixin]:
|
|
||||||
"""Build a :class:`~kaya.cors.CorsMixin` from the CORS settings.
|
|
||||||
|
|
||||||
Returns ``None`` — CORS disabled — unless at least one of
|
|
||||||
``CORS_ALLOW_ORIGINS`` / ``CORS_ALLOW_ORIGIN_REGEX`` is configured.
|
|
||||||
Settings left unset fall back to the mixin's own defaults.
|
|
||||||
"""
|
|
||||||
if settings.cors_allow_origins is None and settings.cors_allow_origin_regex is None:
|
|
||||||
return None
|
|
||||||
return CorsMixin(
|
|
||||||
allow_origins=settings.cors_allow_origins or (),
|
|
||||||
allow_origin_regex=settings.cors_allow_origin_regex,
|
|
||||||
allow_methods=settings.cors_allow_methods or ("GET",),
|
|
||||||
allow_headers=settings.cors_allow_headers or (),
|
|
||||||
allow_credentials=settings.cors_allow_credentials,
|
|
||||||
expose_headers=settings.cors_expose_headers or (),
|
|
||||||
max_age=settings.cors_max_age,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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"}),
|
|
||||||
)
|
|
||||||
|
|
||||||
mixins: list[KayaMixin] = [session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin,
|
|
||||||
DeadlineSchedulerMixin(game_store)]
|
|
||||||
cors_mixin = cors_mixin_from_settings(settings)
|
|
||||||
if cors_mixin is not None:
|
|
||||||
# First in the list: preflight requests are answered before the session
|
|
||||||
# and OIDC hooks run.
|
|
||||||
mixins.insert(0, cors_mixin)
|
|
||||||
log.info(
|
|
||||||
"CORS enabled: origins=%s origin_regex=%s credentials=%s",
|
|
||||||
settings.cors_allow_origins,
|
|
||||||
settings.cors_allow_origin_regex,
|
|
||||||
settings.cors_allow_credentials,
|
|
||||||
)
|
|
||||||
|
|
||||||
app = KayaApp(mixins=mixins)
|
|
||||||
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
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
"""Environment-driven configuration for the tavolo application.
|
|
||||||
|
|
||||||
Mirrors kaya's own pattern: read ``os.environ`` directly into a plain
|
|
||||||
dataclass. No pydantic-settings, no settings module.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Optional, Tuple
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
|
|
||||||
def _env(name: str, default: Optional[str] = None) -> str:
|
|
||||||
value = os.environ.get(name)
|
|
||||||
if value is None or value == "":
|
|
||||||
if default is None:
|
|
||||||
raise RuntimeError(f"Missing required environment variable: {name}")
|
|
||||||
return default
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _env_list(name: str) -> Optional[Tuple[str, ...]]:
|
|
||||||
"""Parse a comma-separated environment variable into a tuple of values.
|
|
||||||
|
|
||||||
Items are stripped and empty items dropped. Unset or empty variables
|
|
||||||
yield ``None``.
|
|
||||||
"""
|
|
||||||
value = os.environ.get(name)
|
|
||||||
if value is None or value.strip() == "":
|
|
||||||
return None
|
|
||||||
return tuple(part.strip() for part in value.split(",") if part.strip())
|
|
||||||
|
|
||||||
|
|
||||||
def _env_bool(name: str, default: bool = False) -> bool:
|
|
||||||
value = os.environ.get(name)
|
|
||||||
if value is None or value == "":
|
|
||||||
return default
|
|
||||||
return value.strip().lower() in ("1", "true", "yes", "on")
|
|
||||||
|
|
||||||
|
|
||||||
def _database_url_from_parts(engine: str,
|
|
||||||
user: str,
|
|
||||||
password: Optional[str],
|
|
||||||
host: str,
|
|
||||||
port: str,
|
|
||||||
name: str,
|
|
||||||
options: str) -> str:
|
|
||||||
"""Assemble a database DSN from individual components.
|
|
||||||
|
|
||||||
``user`` and ``password`` are percent-encoded so credentials containing
|
|
||||||
URL-reserved characters (``@``, ``:``, ``/``, ...) do not corrupt the
|
|
||||||
DSN. ``port`` and ``options`` are omitted when empty: a missing port
|
|
||||||
lets the driver pick its default (5432 for asyncpg). ``options`` is a
|
|
||||||
raw query string (e.g. ``ssl=require``) appended after a ``?``.
|
|
||||||
"""
|
|
||||||
netloc = quote(user, safe="")
|
|
||||||
if password:
|
|
||||||
netloc += ":" + quote(password, safe="")
|
|
||||||
netloc += "@" + host
|
|
||||||
if port:
|
|
||||||
netloc += ":" + port
|
|
||||||
url = f"{engine}://{netloc}/{name}"
|
|
||||||
options = options.lstrip("?")
|
|
||||||
if options:
|
|
||||||
url += "?" + options
|
|
||||||
return url
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Settings:
|
|
||||||
database_url: str
|
|
||||||
oidc_issuer: str
|
|
||||||
oidc_client_id: str
|
|
||||||
oidc_client_secret: Optional[str]
|
|
||||||
oidc_redirect_uri: str
|
|
||||||
# Where the browser is sent after login/logout. In production the SPA is
|
|
||||||
# served by this app ("/"); in development point these at the trunk dev
|
|
||||||
# server (e.g. "http://localhost:8000/").
|
|
||||||
oidc_post_login_redirect: str
|
|
||||||
oidc_post_logout_redirect: str
|
|
||||||
app_host: str
|
|
||||||
app_port: int
|
|
||||||
redis_url: Optional[str]
|
|
||||||
# How long a live game (and its join-code index) survives in Redis
|
|
||||||
# without activity, in seconds. Defaults to 24h.
|
|
||||||
game_ttl_seconds: int
|
|
||||||
# Directory holding the compiled frontend (trunk's dist output). Only
|
|
||||||
# used to locate index.html for the SPA shell; the assets themselves
|
|
||||||
# are served by Granian under /static (GRANIAN_STATIC_PATH_* env vars).
|
|
||||||
static_dir: str
|
|
||||||
# Seconds the between-hands scoring summary waits for acknowledgements
|
|
||||||
# before dealing the next hand anyway.
|
|
||||||
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]
|
|
||||||
# CORS (kaya-cors' CorsMixin). Disabled unless CORS_ALLOW_ORIGINS or
|
|
||||||
# CORS_ALLOW_ORIGIN_REGEX is set; the app serves the SPA and the API
|
|
||||||
# from the same origin, so no CORS headers are needed by default.
|
|
||||||
cors_allow_origins: Optional[Tuple[str, ...]]
|
|
||||||
cors_allow_origin_regex: Optional[str]
|
|
||||||
cors_allow_methods: Optional[Tuple[str, ...]]
|
|
||||||
cors_allow_headers: Optional[Tuple[str, ...]]
|
|
||||||
cors_allow_credentials: bool
|
|
||||||
cors_expose_headers: Optional[Tuple[str, ...]]
|
|
||||||
cors_max_age: int
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def from_env() -> "Settings":
|
|
||||||
return Settings(
|
|
||||||
# DATABASE_URL, when set, is used verbatim and the DATABASE_*
|
|
||||||
# parts below are ignored (sqlite in tests, managed-DB DSNs).
|
|
||||||
database_url=os.environ.get("DATABASE_URL") or _database_url_from_parts(
|
|
||||||
engine=_env("DATABASE_ENGINE", "postgres"),
|
|
||||||
user=_env("DATABASE_USER", "tavolo"),
|
|
||||||
password=_env("DATABASE_PASSWORD", "password"),
|
|
||||||
host=_env("DATABASE_HOST", "localhost"),
|
|
||||||
# Unset: the port segment is omitted and the driver default
|
|
||||||
# (5432 for asyncpg) applies.
|
|
||||||
port=os.environ.get("DATABASE_PORT", ""),
|
|
||||||
name=_env("DATABASE_NAME", "tavolo"),
|
|
||||||
# Raw DSN query string (e.g. "ssl=require"); empty = none.
|
|
||||||
options=os.environ.get("DATABASE_OPTIONS", ""),
|
|
||||||
),
|
|
||||||
oidc_issuer=_env("OIDC_ISSUER", "http://localhost:8180/tavolo"),
|
|
||||||
oidc_client_id=_env("OIDC_CLIENT_ID", "tavolo"),
|
|
||||||
oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET"),
|
|
||||||
oidc_redirect_uri=_env("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback"),
|
|
||||||
oidc_post_login_redirect=_env("OIDC_POST_LOGIN_REDIRECT", "/"),
|
|
||||||
oidc_post_logout_redirect=_env("OIDC_POST_LOGOUT_REDIRECT", "/"),
|
|
||||||
app_host=_env("APP_HOST", "0.0.0.0"),
|
|
||||||
app_port=int(_env("APP_PORT", "8080")),
|
|
||||||
# When unset, sessions and live games fall back to in-memory
|
|
||||||
# stores (tests, ephemeral dev). Set to e.g.
|
|
||||||
# redis://localhost:6379/0 to persist both in Redis.
|
|
||||||
redis_url=os.environ.get("REDIS_URL"),
|
|
||||||
game_ttl_seconds=int(_env("GAME_TTL_SECONDS", "86400")),
|
|
||||||
static_dir=_env("STATIC_DIR", "web/dist"),
|
|
||||||
hand_ack_timeout_seconds=int(_env("HAND_ACK_TIMEOUT_SECONDS", "30")),
|
|
||||||
turn_timeout_seconds=int(_env("TURN_TIMEOUT_SECONDS", "30")),
|
|
||||||
deadline_heartbeat_ms=int(_env("DEADLINE_HEARTBEAT_MS", "1000")),
|
|
||||||
logging_config=os.environ.get("LOGGING_CONFIG"),
|
|
||||||
# CORS is disabled unless CORS_ALLOW_ORIGINS (a comma-separated
|
|
||||||
# list of origins, or "*" for any) or CORS_ALLOW_ORIGIN_REGEX
|
|
||||||
# is set.
|
|
||||||
cors_allow_origins=_env_list("CORS_ALLOW_ORIGINS"),
|
|
||||||
cors_allow_origin_regex=os.environ.get("CORS_ALLOW_ORIGIN_REGEX") or None,
|
|
||||||
cors_allow_methods=_env_list("CORS_ALLOW_METHODS"),
|
|
||||||
cors_allow_headers=_env_list("CORS_ALLOW_HEADERS"),
|
|
||||||
cors_allow_credentials=_env_bool("CORS_ALLOW_CREDENTIALS"),
|
|
||||||
cors_expose_headers=_env_list("CORS_EXPOSE_HEADERS"),
|
|
||||||
cors_max_age=int(_env("CORS_MAX_AGE", "600")),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
settings: Settings = Settings.from_env()
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
"""Deadline-driven timeouts, independent of player connections.
|
|
||||||
|
|
||||||
Both in-match timeouts — the per-turn auto-play (``turn_deadline``) and
|
|
||||||
the hand-end summary auto-continue (``hand_end_deadline``) — are driven by
|
|
||||||
the absolute deadlines persisted on the game state, never by which players
|
|
||||||
(or whether any players) are connected.
|
|
||||||
|
|
||||||
Every mutation that sets a deadline enqueues an entry in the store's
|
|
||||||
shared deadline queue (a Redis sorted set in production, see
|
|
||||||
:mod:`tavolo.store`), and a background consumer running on **every**
|
|
||||||
worker polls the queue for due entries. An entry records the phase, hand,
|
|
||||||
turn and deadline (as integer epoch milliseconds) it was enqueued for;
|
|
||||||
before acting, the consumer revalidates all of it against the live state
|
|
||||||
under the per-game lock, so entries that were overtaken by events (a play
|
|
||||||
landed in time, the hand was acknowledged, the deadline moved) are simply
|
|
||||||
discarded.
|
|
||||||
|
|
||||||
Delivery is at-least-once: an entry is removed from the queue only after
|
|
||||||
it has been processed. If a worker dies mid-processing, the entry stays in
|
|
||||||
Redis and another worker's consumer picks it up — the lock plus
|
|
||||||
revalidation make the duplicate delivery a no-op. Entries whose game has
|
|
||||||
expired are dropped the first time they fire, so the queue is
|
|
||||||
self-cleaning.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
from datetime import datetime
|
|
||||||
from logging import getLogger
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from kaya.core import KayaApp, KayaMixin
|
|
||||||
|
|
||||||
from .config import settings
|
|
||||||
from .game import engine
|
|
||||||
from .game.errors import GameError
|
|
||||||
from .game.state import PHASE_HAND_END, PHASE_PLAYING, PHASE_FINISHED, GameState
|
|
||||||
from .stats import save_match_result
|
|
||||||
from .store import GameStore
|
|
||||||
|
|
||||||
log = getLogger(__name__)
|
|
||||||
|
|
||||||
# Entry kinds enqueued in the deadline queue.
|
|
||||||
KIND_TURN = "turn"
|
|
||||||
KIND_HAND_END = "hand_end"
|
|
||||||
|
|
||||||
# One consumer task and its wake-up event per event loop (tests run each
|
|
||||||
# test on a fresh loop).
|
|
||||||
_consumers: Dict[asyncio.AbstractEventLoop, asyncio.Task] = {}
|
|
||||||
_wake_events: Dict[asyncio.AbstractEventLoop, asyncio.Event] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def encode(entry: Dict[str, Any]) -> str:
|
|
||||||
"""Canonical queue-member encoding for a deadline entry."""
|
|
||||||
return json.dumps(entry, sort_keys=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _decode(member: Any) -> Optional[Dict[str, Any]]:
|
|
||||||
if isinstance(member, bytes):
|
|
||||||
member = member.decode("utf-8")
|
|
||||||
if not isinstance(member, str):
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
entry = json.loads(member)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
return entry if isinstance(entry, dict) else None
|
|
||||||
|
|
||||||
|
|
||||||
def _deadline_ms(iso: Optional[str]) -> Optional[int]:
|
|
||||||
"""Epoch milliseconds for an ISO-8601 deadline, ``None`` when absent
|
|
||||||
or unparseable. Queue entries carry this integer (never the ISO
|
|
||||||
string) as their revalidation token."""
|
|
||||||
if not iso:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return int(datetime.fromisoformat(iso).timestamp() * 1000)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def sync_deadline(store: GameStore, state: GameState) -> None:
|
|
||||||
"""Enqueue the deadline the current state carries, if any.
|
|
||||||
|
|
||||||
Called after every mutation that can set a deadline (plays, acks, game
|
|
||||||
start) and as a backstop when a client connects. Enqueueing is
|
|
||||||
idempotent: an identical entry is already queued with the same due
|
|
||||||
time, so re-adding it changes nothing.
|
|
||||||
"""
|
|
||||||
entry: Optional[Dict[str, Any]] = None
|
|
||||||
due_ms: Optional[int] = None
|
|
||||||
if state.phase == PHASE_PLAYING and state.turn_deadline:
|
|
||||||
due_ms = _deadline_ms(state.turn_deadline)
|
|
||||||
entry = {
|
|
||||||
"game_id": state.id,
|
|
||||||
"kind": KIND_TURN,
|
|
||||||
"hand": state.hand_number,
|
|
||||||
"turn": state.turn,
|
|
||||||
"deadline": due_ms,
|
|
||||||
}
|
|
||||||
elif state.phase == PHASE_HAND_END and state.hand_end_deadline:
|
|
||||||
due_ms = _deadline_ms(state.hand_end_deadline)
|
|
||||||
entry = {
|
|
||||||
"game_id": state.id,
|
|
||||||
"kind": KIND_HAND_END,
|
|
||||||
"hand": state.hand_number,
|
|
||||||
"deadline": due_ms,
|
|
||||||
}
|
|
||||||
if entry is None or due_ms is None:
|
|
||||||
if entry is not None:
|
|
||||||
log.warning("game %s: unparseable deadline", state.id)
|
|
||||||
return
|
|
||||||
ensure_consumer(store)
|
|
||||||
# The score derives from the same value carried in the member, so the
|
|
||||||
# two can never disagree.
|
|
||||||
await store.add_deadline(encode(entry), due_ms / 1000)
|
|
||||||
wake = _wake_events.get(asyncio.get_running_loop())
|
|
||||||
if wake is not None:
|
|
||||||
wake.set()
|
|
||||||
|
|
||||||
|
|
||||||
async def finalize_mutation(store: GameStore, state: GameState) -> None:
|
|
||||||
"""Persist a successful mutation, notify subscribers and enqueue the
|
|
||||||
next deadline.
|
|
||||||
|
|
||||||
Callers must hold the per-game lock. Handles the terminal transition:
|
|
||||||
the match result is written to Postgres once (guarded by
|
|
||||||
``stats_saved``).
|
|
||||||
"""
|
|
||||||
if state.phase == PHASE_FINISHED:
|
|
||||||
await save_match_result(state)
|
|
||||||
log.info(
|
|
||||||
"game %s finished: team %s wins %d-%d",
|
|
||||||
state.id,
|
|
||||||
"A" if state.winner == 0 else "B",
|
|
||||||
state.scores[0],
|
|
||||||
state.scores[1],
|
|
||||||
)
|
|
||||||
await store.save(state)
|
|
||||||
await store.publish(state.id)
|
|
||||||
await sync_deadline(store, state)
|
|
||||||
|
|
||||||
|
|
||||||
async def process_due(store: GameStore, member: Any) -> None:
|
|
||||||
"""Fire a single due deadline entry.
|
|
||||||
|
|
||||||
Revalidates the entry against the live state under the per-game lock;
|
|
||||||
stale or foreign entries are discarded without effect. The entry is
|
|
||||||
removed from the queue once handled (including "nothing to do"); if
|
|
||||||
handling fails (e.g. the lock cannot be acquired), the entry is left
|
|
||||||
in the queue so another consumer retries it.
|
|
||||||
"""
|
|
||||||
entry = _decode(member)
|
|
||||||
if entry is None:
|
|
||||||
log.warning("deadline consumer: dropping malformed entry %r", member)
|
|
||||||
await store.remove_deadline(member)
|
|
||||||
return
|
|
||||||
game_id = entry.get("game_id")
|
|
||||||
kind = entry.get("kind")
|
|
||||||
if not isinstance(game_id, str):
|
|
||||||
await store.remove_deadline(member)
|
|
||||||
return
|
|
||||||
async with store.lock(game_id):
|
|
||||||
state = await store.load(game_id)
|
|
||||||
if state is not None:
|
|
||||||
if kind == KIND_TURN:
|
|
||||||
await _fire_turn(store, state, entry)
|
|
||||||
elif kind == KIND_HAND_END:
|
|
||||||
await _fire_hand_end(store, state, entry)
|
|
||||||
await store.remove_deadline(member)
|
|
||||||
|
|
||||||
|
|
||||||
async def _fire_turn(store: GameStore, state: GameState, entry: Dict[str, Any]) -> None:
|
|
||||||
if (
|
|
||||||
state.phase != PHASE_PLAYING
|
|
||||||
or state.hand_number != entry.get("hand")
|
|
||||||
or state.turn != entry.get("turn")
|
|
||||||
or _deadline_ms(state.turn_deadline) != entry.get("deadline")
|
|
||||||
):
|
|
||||||
return
|
|
||||||
seat = state.turn
|
|
||||||
try:
|
|
||||||
engine.auto_play(state)
|
|
||||||
except GameError:
|
|
||||||
return
|
|
||||||
log.info(
|
|
||||||
"game %s: auto-played for %s (turn timeout, hand %d)",
|
|
||||||
state.id,
|
|
||||||
state.players[seat].sub if seat < len(state.players) else "?",
|
|
||||||
entry.get("hand"),
|
|
||||||
)
|
|
||||||
await finalize_mutation(store, state)
|
|
||||||
|
|
||||||
|
|
||||||
async def _fire_hand_end(store: GameStore, state: GameState, entry: Dict[str, Any]) -> None:
|
|
||||||
if (
|
|
||||||
state.phase != PHASE_HAND_END
|
|
||||||
or state.hand_number != entry.get("hand")
|
|
||||||
or _deadline_ms(state.hand_end_deadline) != entry.get("deadline")
|
|
||||||
):
|
|
||||||
return
|
|
||||||
for player in state.players:
|
|
||||||
engine.acknowledge_hand(state, player.sub)
|
|
||||||
log.info(
|
|
||||||
"game %s: hand %d auto-advanced after the acknowledgement timeout",
|
|
||||||
state.id,
|
|
||||||
entry.get("hand"),
|
|
||||||
)
|
|
||||||
await finalize_mutation(store, state)
|
|
||||||
|
|
||||||
|
|
||||||
# --- consumer lifecycle -------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_consumer(
|
|
||||||
store: GameStore, loop: Optional[asyncio.AbstractEventLoop] = None
|
|
||||||
) -> None:
|
|
||||||
"""Start the deadline consumer on the given (or running) loop if not
|
|
||||||
yet running.
|
|
||||||
|
|
||||||
Called lazily whenever a deadline is enqueued (the ASGI test transport
|
|
||||||
never fires the lifespan hooks, so the mixin's ``setup`` alone is not
|
|
||||||
enough) and on application startup. The explicit ``loop`` matters at
|
|
||||||
startup: under RSGI granian calls ``setup`` before the loop runs, so
|
|
||||||
``asyncio.get_running_loop()`` would fail there.
|
|
||||||
"""
|
|
||||||
if loop is None:
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
for old in list(_consumers):
|
|
||||||
if old.is_closed():
|
|
||||||
_consumers.pop(old, None)
|
|
||||||
_wake_events.pop(old, None)
|
|
||||||
task = _consumers.get(loop)
|
|
||||||
if task is None or task.done():
|
|
||||||
_wake_events[loop] = asyncio.Event()
|
|
||||||
_consumers[loop] = loop.create_task(_run(store, loop))
|
|
||||||
log.debug("deadline consumer started")
|
|
||||||
|
|
||||||
|
|
||||||
def stop_consumer(loop: asyncio.AbstractEventLoop) -> None:
|
|
||||||
task = _consumers.pop(loop, None)
|
|
||||||
_wake_events.pop(loop, None)
|
|
||||||
if task is not None:
|
|
||||||
task.cancel()
|
|
||||||
|
|
||||||
|
|
||||||
async def _run(store: GameStore, loop: asyncio.AbstractEventLoop) -> None:
|
|
||||||
wake = _wake_events[loop]
|
|
||||||
heartbeat = settings.deadline_heartbeat_ms / 1000
|
|
||||||
while True:
|
|
||||||
# Clear before polling so an enqueue racing the poll re-wakes us.
|
|
||||||
wake.clear()
|
|
||||||
delay = heartbeat
|
|
||||||
try:
|
|
||||||
for member in await store.due_deadlines(time.time()):
|
|
||||||
try:
|
|
||||||
await process_due(store, member)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
# Left in the queue; retried on the next pass.
|
|
||||||
log.exception("deadline consumer: failed to process %r", member)
|
|
||||||
next_due = await store.next_deadline()
|
|
||||||
if next_due is not None:
|
|
||||||
delay = max(0.0, min(heartbeat, next_due - time.time()))
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
log.exception("deadline consumer: poll failed; retrying")
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(wake.wait(), timeout=delay)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class DeadlineSchedulerMixin(KayaMixin):
|
|
||||||
"""Run the deadline consumer for the whole app lifetime.
|
|
||||||
|
|
||||||
Every worker (and every pod) runs the same consumer; coordination
|
|
||||||
happens exclusively through the shared deadline queue and the per-game
|
|
||||||
locks, so any worker may fire any game's deadline.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, store: GameStore) -> None:
|
|
||||||
self._store = store
|
|
||||||
|
|
||||||
def apply(self, app: KayaApp) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def setup(self, loop: asyncio.AbstractEventLoop) -> None:
|
|
||||||
ensure_consumer(self._store, loop)
|
|
||||||
|
|
||||||
def shutdown(self, loop: asyncio.AbstractEventLoop) -> None:
|
|
||||||
stop_consumer(loop)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Scopone scientifico domain package."""
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
"""Typed errors raised by the scopone engine.
|
|
||||||
|
|
||||||
Route/WebSocket handlers translate these into 4xx responses or ``error``
|
|
||||||
WebSocket messages; the engine itself stays transport-agnostic.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
|
|
||||||
class GameError(Exception):
|
|
||||||
"""Base class for every rule/validation failure."""
|
|
||||||
|
|
||||||
|
|
||||||
class IllegalMove(GameError):
|
|
||||||
"""The requested play violates the rules of scopone scientifico."""
|
|
||||||
|
|
||||||
|
|
||||||
class NotYourTurn(GameError):
|
|
||||||
"""A player attempted to play out of turn."""
|
|
||||||
|
|
||||||
|
|
||||||
class CardNotInHand(GameError):
|
|
||||||
"""The played card is not held by the player."""
|
|
||||||
|
|
||||||
|
|
||||||
class GameNotStarted(GameError):
|
|
||||||
"""An action was attempted before the game left the lobby."""
|
|
||||||
|
|
||||||
|
|
||||||
class GameFinished(GameError):
|
|
||||||
"""An action was attempted after the match ended."""
|
|
||||||
|
|
||||||
|
|
||||||
class LobbyFull(GameError):
|
|
||||||
"""A game already has four players."""
|
|
||||||
|
|
||||||
|
|
||||||
class AlreadyJoined(GameError):
|
|
||||||
"""A player tried to join a game they are already seated in."""
|
|
||||||
|
|
||||||
|
|
||||||
class GameNotFound(GameError):
|
|
||||||
"""No live game exists for the given id or join code."""
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
"""Registry of the card games the platform can host.
|
|
||||||
|
|
||||||
Only *scopone scientifico* is implemented for now; adding a game means a
|
|
||||||
new entry here plus its engine. The registry is the single source of truth
|
|
||||||
for the ``game_type`` carried by every live game (:mod:`tavolo.game.state`)
|
|
||||||
and persisted on each finished match (:mod:`tavolo.models`), which is what
|
|
||||||
makes match statistics game-scoped.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Dict, Optional
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class GameType:
|
|
||||||
"""Metadata describing one playable card game."""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
description: str
|
|
||||||
|
|
||||||
|
|
||||||
SCOPONE_SCIENTIFICO = "scopone_scientifico"
|
|
||||||
|
|
||||||
GAME_TYPES: Dict[str, GameType] = {
|
|
||||||
SCOPONE_SCIENTIFICO: GameType(
|
|
||||||
id=SCOPONE_SCIENTIFICO,
|
|
||||||
name="Scopone scientifico",
|
|
||||||
description=(
|
|
||||||
"Four players in fixed partnerships, ten cards each and an empty "
|
|
||||||
"table. First team to the target score wins."
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
DEFAULT_GAME_TYPE = SCOPONE_SCIENTIFICO
|
|
||||||
|
|
||||||
|
|
||||||
def get_game_type(game_type_id: str) -> Optional[GameType]:
|
|
||||||
"""Return the registered game type with id ``game_type_id``, if any."""
|
|
||||||
return GAME_TYPES.get(game_type_id)
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
"""Tortoise ORM models: match statistics persisted in Postgres.
|
|
||||||
|
|
||||||
Live game state lives in Redis (see :mod:`tavolo.store`); only completed
|
|
||||||
matches are written here. The two tables answer the question "every match
|
|
||||||
a player took part in, with the final score":
|
|
||||||
|
|
||||||
* :class:`Match` — one row per finished match with both teams' scores.
|
|
||||||
* :class:`MatchPlayer` — one row per participant, linking an OIDC
|
|
||||||
``sub`` to a seat/team and whether they won.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from tortoise import fields
|
|
||||||
from tortoise.models import Model
|
|
||||||
|
|
||||||
|
|
||||||
class Match(Model):
|
|
||||||
"""A completed match of one of the registered game types."""
|
|
||||||
|
|
||||||
id = fields.UUIDField(pk=True)
|
|
||||||
# Which card game was played (tavolo.games.GAME_TYPES); the default
|
|
||||||
# backfills matches recorded before game types existed.
|
|
||||||
game_type = fields.CharField(max_length=32, db_index=True, default="scopone_scientifico")
|
|
||||||
team_a_score = fields.SmallIntField()
|
|
||||||
team_b_score = fields.SmallIntField()
|
|
||||||
# "A" or "B".
|
|
||||||
winner_team = fields.CharField(max_length=1)
|
|
||||||
target_score = fields.SmallIntField()
|
|
||||||
hands_played = fields.SmallIntField()
|
|
||||||
started_at = fields.DatetimeField()
|
|
||||||
finished_at = fields.DatetimeField()
|
|
||||||
|
|
||||||
players: fields.ReverseRelation["MatchPlayer"]
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
table = "match"
|
|
||||||
ordering = ["-finished_at"]
|
|
||||||
|
|
||||||
|
|
||||||
class MatchPlayer(Model):
|
|
||||||
"""Participation of one user in one match."""
|
|
||||||
|
|
||||||
id = fields.UUIDField(pk=True)
|
|
||||||
match: fields.ForeignKeyRelation[Match] = fields.ForeignKeyField(
|
|
||||||
"models.Match", related_name="players", on_delete=fields.CASCADE
|
|
||||||
)
|
|
||||||
# OIDC subject of the player; no local users table.
|
|
||||||
user_sub = fields.CharField(max_length=255, db_index=True)
|
|
||||||
display_name = fields.CharField(max_length=200)
|
|
||||||
seat = fields.SmallIntField()
|
|
||||||
team = fields.CharField(max_length=1)
|
|
||||||
won = fields.BooleanField()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
table = "match_player"
|
|
||||||
unique_together = (("match", "user_sub"),)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""HTTP route modules."""
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
"""Game lobby endpoints.
|
|
||||||
|
|
||||||
A game starts as a lobby: the creator is seated first and shares the
|
|
||||||
six-character ``join_code``. When the fourth player joins, the engine deals
|
|
||||||
the first hand and the match begins. Live play then happens over the
|
|
||||||
``/ws/games/{id}`` websocket (see :mod:`tavolo.ws`); these endpoints cover
|
|
||||||
creation, joining and snapshotting state.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import secrets
|
|
||||||
import uuid
|
|
||||||
from logging import getLogger
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from kaya.core import HttpContext
|
|
||||||
from kaya.openapi import operation
|
|
||||||
|
|
||||||
from .. import auth, deadlines
|
|
||||||
from ..app import app, game_store, oidc_mixin
|
|
||||||
from ..auth import require_auth
|
|
||||||
from ..config import settings
|
|
||||||
from ..game import engine
|
|
||||||
from ..game.errors import GameError
|
|
||||||
from ..game.state import DEFAULT_TARGET_SCORE, PHASE_LOBBY, GameState
|
|
||||||
from ..games import GAME_TYPES, get_game_type
|
|
||||||
from ..http import JsonRequestError, read_json, read_json_optional, send_error, send_json
|
|
||||||
|
|
||||||
log = getLogger(__name__)
|
|
||||||
|
|
||||||
# Join codes avoid characters that are easy to confuse when read aloud.
|
|
||||||
_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
|
||||||
_CODE_LENGTH = 6
|
|
||||||
_MAX_CODE_ATTEMPTS = 20
|
|
||||||
|
|
||||||
|
|
||||||
def _now_code() -> str:
|
|
||||||
return "".join(secrets.choice(_CODE_ALPHABET) for _ in range(_CODE_LENGTH))
|
|
||||||
|
|
||||||
|
|
||||||
async def _unique_code() -> str:
|
|
||||||
for _ in range(_MAX_CODE_ATTEMPTS):
|
|
||||||
code = _now_code()
|
|
||||||
if await game_store.find_by_code(code) is None:
|
|
||||||
return code
|
|
||||||
raise RuntimeError("could not allocate a unique join code")
|
|
||||||
|
|
||||||
|
|
||||||
def _lobby_payload(state: GameState) -> Dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"id": state.id,
|
|
||||||
"join_code": state.join_code,
|
|
||||||
"game_type": state.game_type,
|
|
||||||
"target_score": state.target_score,
|
|
||||||
"napola": state.napola,
|
|
||||||
"phase": state.phase,
|
|
||||||
"players": [
|
|
||||||
{"sub": p.sub, "name": p.name, "seat": p.seat, "team": "A" if p.seat % 2 == 0 else "B"}
|
|
||||||
for p in state.players
|
|
||||||
],
|
|
||||||
"seats_open": 4 - len(state.players),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.GET("/api/game-types")
|
|
||||||
@operation(summary="List available games",
|
|
||||||
description="Every card game the platform can host, for the "
|
|
||||||
"match-creation dropdown.",
|
|
||||||
tags=["games"],
|
|
||||||
responses={200: {"description": "The available game types"}})
|
|
||||||
async def list_game_types(ctx: HttpContext) -> None:
|
|
||||||
await send_json(ctx, 200, {
|
|
||||||
"results": [
|
|
||||||
{"id": g.id, "name": g.name, "description": g.description}
|
|
||||||
for g in GAME_TYPES.values()
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.POST("/api/games")
|
|
||||||
@operation(summary="Create a game",
|
|
||||||
description="Creates a lobby game and seats the caller in seat 0. "
|
|
||||||
"Share the returned join_code with three other players.",
|
|
||||||
tags=["games"],
|
|
||||||
request_body={
|
|
||||||
"required": False,
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"game_type": {
|
|
||||||
"type": "string",
|
|
||||||
"default": "scopone_scientifico",
|
|
||||||
"description": "One of the ids from GET /api/game-types",
|
|
||||||
},
|
|
||||||
"target_score": {"type": "integer", "minimum": 1, "maximum": 100},
|
|
||||||
"napola": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": True,
|
|
||||||
"description": "Score the napola rule; a full "
|
|
||||||
"denari sweep wins the match",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
responses={
|
|
||||||
201: {"description": "The created lobby"},
|
|
||||||
400: {"description": "Invalid game_type, target_score or body"},
|
|
||||||
401: {"description": "Authentication required"},
|
|
||||||
})
|
|
||||||
@require_auth
|
|
||||||
async def create_game(ctx: HttpContext) -> None:
|
|
||||||
body: dict = {}
|
|
||||||
try:
|
|
||||||
body = await read_json_optional(ctx)
|
|
||||||
except JsonRequestError as exc:
|
|
||||||
await send_error(ctx, 400, str(exc))
|
|
||||||
return
|
|
||||||
|
|
||||||
target_score: Any = body.get("target_score", DEFAULT_TARGET_SCORE)
|
|
||||||
if isinstance(target_score, bool) or not isinstance(target_score, int):
|
|
||||||
await send_error(ctx, 400, "target_score must be an integer")
|
|
||||||
return
|
|
||||||
|
|
||||||
game_type: Any = body.get("game_type", "scopone_scientifico")
|
|
||||||
if not isinstance(game_type, str) or get_game_type(game_type) is None:
|
|
||||||
await send_error(ctx, 400, f"unknown game_type: {game_type!r}")
|
|
||||||
return
|
|
||||||
|
|
||||||
napola: Any = body.get("napola", True)
|
|
||||||
if not isinstance(napola, bool):
|
|
||||||
await send_error(ctx, 400, "napola must be a boolean")
|
|
||||||
return
|
|
||||||
|
|
||||||
user = oidc_mixin.get_user(ctx)
|
|
||||||
assert user is not None # enforced by @require_auth
|
|
||||||
game_id = str(uuid.uuid4())
|
|
||||||
join_code = await _unique_code()
|
|
||||||
try:
|
|
||||||
state = engine.create_game(
|
|
||||||
game_id=game_id,
|
|
||||||
join_code=join_code,
|
|
||||||
creator_sub=user.sub,
|
|
||||||
creator_name=auth.display_name(user),
|
|
||||||
target_score=target_score,
|
|
||||||
hand_ack_timeout=settings.hand_ack_timeout_seconds,
|
|
||||||
turn_timeout=settings.turn_timeout_seconds,
|
|
||||||
game_type=game_type,
|
|
||||||
napola=napola,
|
|
||||||
)
|
|
||||||
except GameError as exc:
|
|
||||||
await send_error(ctx, 400, str(exc))
|
|
||||||
return
|
|
||||||
await game_store.save(state)
|
|
||||||
log.info(
|
|
||||||
"game %s created by %s (%s, target score %d)",
|
|
||||||
game_id,
|
|
||||||
user.sub,
|
|
||||||
game_type,
|
|
||||||
target_score,
|
|
||||||
)
|
|
||||||
await send_json(ctx, 201, _lobby_payload(state))
|
|
||||||
|
|
||||||
|
|
||||||
@app.POST("/api/games/join")
|
|
||||||
@operation(summary="Join a game by code",
|
|
||||||
description="Seats the caller in the next free chair. Joining as the "
|
|
||||||
"fourth player starts the match.",
|
|
||||||
tags=["games"],
|
|
||||||
request_body={
|
|
||||||
"required": True,
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {"code": {"type": "string"}},
|
|
||||||
"required": ["code"],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
responses={
|
|
||||||
200: {"description": "Seated; game state (may be playing)"},
|
|
||||||
400: {"description": "Missing code"},
|
|
||||||
401: {"description": "Authentication required"},
|
|
||||||
404: {"description": "Unknown join code"},
|
|
||||||
409: {"description": "Already joined or lobby full"},
|
|
||||||
})
|
|
||||||
@require_auth
|
|
||||||
async def join_game(ctx: HttpContext) -> None:
|
|
||||||
try:
|
|
||||||
body = await read_json(ctx)
|
|
||||||
except JsonRequestError as exc:
|
|
||||||
await send_error(ctx, 400, str(exc))
|
|
||||||
return
|
|
||||||
code = body.get("code")
|
|
||||||
if not isinstance(code, str) or not code:
|
|
||||||
await send_error(ctx, 400, "code is required")
|
|
||||||
return
|
|
||||||
|
|
||||||
user = oidc_mixin.get_user(ctx)
|
|
||||||
assert user is not None
|
|
||||||
existing = await game_store.find_by_code(code)
|
|
||||||
if existing is None:
|
|
||||||
log.debug("join rejected for %s: unknown code %r", user.sub, code)
|
|
||||||
await send_error(ctx, 404, "unknown join code")
|
|
||||||
return
|
|
||||||
|
|
||||||
async with game_store.lock(existing.id):
|
|
||||||
state = await game_store.load(existing.id)
|
|
||||||
if state is None:
|
|
||||||
await send_error(ctx, 404, "unknown join code")
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
engine.join_game(state, user.sub, auth.display_name(user))
|
|
||||||
except GameError as exc:
|
|
||||||
log.debug("join rejected for %s in game %s: %s", user.sub, state.id, exc)
|
|
||||||
await send_error(ctx, 409, str(exc))
|
|
||||||
return
|
|
||||||
await game_store.save(state)
|
|
||||||
await game_store.publish(state.id)
|
|
||||||
# When the fourth join started the match, the first turn deadline
|
|
||||||
# was armed; queue it so it fires even if nobody ever connects.
|
|
||||||
await deadlines.sync_deadline(game_store, state)
|
|
||||||
seat = next(p.seat for p in state.players if p.sub == user.sub)
|
|
||||||
if state.phase == PHASE_LOBBY:
|
|
||||||
log.info("%s joined game %s (seat %d, %d/4 players)", user.sub, state.id, seat, len(state.players))
|
|
||||||
else:
|
|
||||||
log.info("%s joined game %s (seat %d); match started", user.sub, state.id, seat)
|
|
||||||
await send_json(ctx, 200, engine.state_for_player(state, user.sub))
|
|
||||||
return
|
|
||||||
await send_json(ctx, 200, _lobby_payload(state))
|
|
||||||
|
|
||||||
|
|
||||||
@app.GET("/api/games/${game_id}")
|
|
||||||
@operation(summary="Get a game snapshot",
|
|
||||||
description="Only seated players may read a game; other players' "
|
|
||||||
"hands are hidden.",
|
|
||||||
tags=["games"],
|
|
||||||
responses={
|
|
||||||
200: {"description": "The personalized game state"},
|
|
||||||
401: {"description": "Authentication required"},
|
|
||||||
403: {"description": "Not a player in this game"},
|
|
||||||
404: {"description": "Game not found"},
|
|
||||||
})
|
|
||||||
@require_auth
|
|
||||||
async def get_game(ctx: HttpContext, game_id: str) -> None:
|
|
||||||
state = await game_store.load(game_id)
|
|
||||||
if state is None:
|
|
||||||
await send_error(ctx, 404, "game not found")
|
|
||||||
return
|
|
||||||
user = oidc_mixin.get_user(ctx)
|
|
||||||
assert user is not None
|
|
||||||
if not state.seated(user.sub):
|
|
||||||
await send_error(ctx, 403, "forbidden")
|
|
||||||
return
|
|
||||||
await send_json(ctx, 200, engine.state_for_player(state, user.sub))
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
"""Liveness probe."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from kaya.core import HttpContext
|
|
||||||
from kaya.openapi import operation
|
|
||||||
|
|
||||||
from ..app import app
|
|
||||||
|
|
||||||
|
|
||||||
@app.GET("/api/health")
|
|
||||||
@operation(summary="Health check",
|
|
||||||
tags=["health"],
|
|
||||||
responses={200: {"description": "The service is up"}})
|
|
||||||
async def health(ctx: HttpContext) -> None:
|
|
||||||
await ctx.send_bytes(
|
|
||||||
200,
|
|
||||||
b'{"status":"ok"}',
|
|
||||||
{"content-type": ("application/json",)},
|
|
||||||
)
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
"""Whoami endpoint: lets the single-page app detect the login state."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from kaya.core import HttpContext
|
|
||||||
from kaya.openapi import operation
|
|
||||||
|
|
||||||
from ..app import app, oidc_mixin
|
|
||||||
from ..auth import display_name, require_auth
|
|
||||||
from ..http import send_json
|
|
||||||
|
|
||||||
|
|
||||||
@app.GET("/api/me")
|
|
||||||
@operation(summary="Current user",
|
|
||||||
description="Returns the authenticated user's identity from the "
|
|
||||||
"session; 401 when not logged in.",
|
|
||||||
tags=["auth"],
|
|
||||||
responses={
|
|
||||||
200: {"description": "The current user"},
|
|
||||||
401: {"description": "Not logged in"},
|
|
||||||
})
|
|
||||||
@require_auth
|
|
||||||
async def me(ctx: HttpContext) -> None:
|
|
||||||
user = oidc_mixin.get_user(ctx)
|
|
||||||
assert user is not None # enforced by @require_auth
|
|
||||||
await send_json(ctx, 200, {"sub": user.sub, "name": display_name(user)})
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
"""Player statistics endpoints, served from Postgres.
|
|
||||||
|
|
||||||
Every finished match is persisted by :func:`tavolo.stats.save_match_result`.
|
|
||||||
These endpoints expose a player's own match history and a global
|
|
||||||
leaderboard aggregated from the same two tables.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
|
||||||
|
|
||||||
from kaya.core import HttpContext
|
|
||||||
from kaya.openapi import operation
|
|
||||||
|
|
||||||
from ..app import app, oidc_mixin
|
|
||||||
from ..auth import require_auth
|
|
||||||
from ..games import get_game_type
|
|
||||||
from ..http import extract_query_params, send_error, send_json
|
|
||||||
from ..models import Match, MatchPlayer
|
|
||||||
from ..openapi import PAGINATION_PARAMETERS
|
|
||||||
from ..pagination import CursorDecodeError, paginate, parse_cursor_params
|
|
||||||
|
|
||||||
GAME_TYPE_PARAMETER: Dict[str, Any] = {
|
|
||||||
"name": "game_type",
|
|
||||||
"in": "query",
|
|
||||||
"required": False,
|
|
||||||
"schema": {"type": "string"},
|
|
||||||
"description": "Only count matches of this game (id from GET /api/game-types).",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_game_type(query_string: str) -> Tuple[Optional[str], Optional[str]]:
|
|
||||||
"""Parse the ``game_type`` query parameter.
|
|
||||||
|
|
||||||
Returns ``(value, error)``: ``(None, None)`` when absent, ``(id, None)``
|
|
||||||
when valid, ``(None, message)`` when it names no registered game."""
|
|
||||||
values = extract_query_params(query_string).get("game_type")
|
|
||||||
if not values:
|
|
||||||
return None, None
|
|
||||||
game_type = values[0]
|
|
||||||
if get_game_type(game_type) is None:
|
|
||||||
return None, f"unknown game_type: {game_type!r}"
|
|
||||||
return game_type, None
|
|
||||||
|
|
||||||
|
|
||||||
async def _serialize_match(match: Match, viewer: str) -> Dict[str, Any]:
|
|
||||||
participants = await MatchPlayer.filter(match_id=match.id).order_by("seat")
|
|
||||||
return {
|
|
||||||
"id": str(match.id),
|
|
||||||
"game_type": match.game_type,
|
|
||||||
"team_a_score": match.team_a_score,
|
|
||||||
"team_b_score": match.team_b_score,
|
|
||||||
"winner_team": match.winner_team,
|
|
||||||
"target_score": match.target_score,
|
|
||||||
"hands_played": match.hands_played,
|
|
||||||
"started_at": match.started_at.isoformat(),
|
|
||||||
"finished_at": match.finished_at.isoformat(),
|
|
||||||
"you_won": any(p.user_sub == viewer and p.won for p in participants),
|
|
||||||
"players": [
|
|
||||||
{
|
|
||||||
"user_sub": p.user_sub,
|
|
||||||
"display_name": p.display_name,
|
|
||||||
"seat": p.seat,
|
|
||||||
"team": p.team,
|
|
||||||
"won": p.won,
|
|
||||||
}
|
|
||||||
for p in participants
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.GET("/api/me/matches")
|
|
||||||
@operation(summary="List my matches",
|
|
||||||
description="Cursor-paginated history of finished matches the caller "
|
|
||||||
"played, newest first, with the final score.",
|
|
||||||
tags=["stats"],
|
|
||||||
parameters=[*PAGINATION_PARAMETERS, GAME_TYPE_PARAMETER],
|
|
||||||
responses={
|
|
||||||
200: {"description": "A page of matches"},
|
|
||||||
400: {"description": "Invalid pagination cursor or game_type"},
|
|
||||||
401: {"description": "Authentication required"},
|
|
||||||
})
|
|
||||||
@require_auth
|
|
||||||
async def my_matches(ctx: HttpContext) -> None:
|
|
||||||
try:
|
|
||||||
cursor = parse_cursor_params(ctx.query_string)
|
|
||||||
except CursorDecodeError as exc:
|
|
||||||
await send_error(ctx, 400, str(exc))
|
|
||||||
return
|
|
||||||
game_type, error = _parse_game_type(ctx.query_string)
|
|
||||||
if error is not None:
|
|
||||||
await send_error(ctx, 400, error)
|
|
||||||
return
|
|
||||||
user = oidc_mixin.get_user(ctx)
|
|
||||||
assert user is not None
|
|
||||||
queryset = Match.filter(players__user_sub=user.sub).distinct()
|
|
||||||
if game_type is not None:
|
|
||||||
queryset = queryset.filter(game_type=game_type)
|
|
||||||
matches, next_cursor = await paginate(
|
|
||||||
queryset, [("finished_at", "DESC"), ("id", "ASC")], cursor
|
|
||||||
)
|
|
||||||
results = [await _serialize_match(m, user.sub) for m in matches]
|
|
||||||
await send_json(ctx, 200, {"results": results, "next_cursor": next_cursor})
|
|
||||||
|
|
||||||
|
|
||||||
@app.GET("/api/leaderboard")
|
|
||||||
@operation(summary="Global leaderboard",
|
|
||||||
description="Aggregated wins, matches played and team points for every "
|
|
||||||
"player with at least one finished match. Sorted by wins.",
|
|
||||||
tags=["stats"],
|
|
||||||
parameters=[GAME_TYPE_PARAMETER],
|
|
||||||
responses={
|
|
||||||
200: {"description": "The leaderboard"},
|
|
||||||
400: {"description": "Unknown game_type"},
|
|
||||||
})
|
|
||||||
async def leaderboard(ctx: HttpContext) -> None:
|
|
||||||
game_type, error = _parse_game_type(ctx.query_string)
|
|
||||||
if error is not None:
|
|
||||||
await send_error(ctx, 400, error)
|
|
||||||
return
|
|
||||||
queryset = MatchPlayer.all()
|
|
||||||
if game_type is not None:
|
|
||||||
queryset = queryset.filter(match__game_type=game_type)
|
|
||||||
rows = await queryset.prefetch_related("match")
|
|
||||||
aggregate: Dict[str, Dict[str, Any]] = {}
|
|
||||||
for row in rows:
|
|
||||||
entry = aggregate.setdefault(
|
|
||||||
row.user_sub,
|
|
||||||
{
|
|
||||||
"user_sub": row.user_sub,
|
|
||||||
"display_name": row.display_name,
|
|
||||||
"matches": 0,
|
|
||||||
"wins": 0,
|
|
||||||
"points": 0,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
entry["matches"] += 1
|
|
||||||
entry["wins"] += 1 if row.won else 0
|
|
||||||
match = row.match
|
|
||||||
if match is not None:
|
|
||||||
entry["points"] += (
|
|
||||||
match.team_a_score if row.team == "A" else match.team_b_score
|
|
||||||
)
|
|
||||||
# Keep the most recent display name seen.
|
|
||||||
entry["display_name"] = row.display_name
|
|
||||||
|
|
||||||
ranking: List[Dict[str, Any]] = sorted(
|
|
||||||
aggregate.values(),
|
|
||||||
key=lambda e: (e["wins"], e["points"], -e["matches"]),
|
|
||||||
reverse=True,
|
|
||||||
)
|
|
||||||
await send_json(ctx, 200, {"results": ranking})
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
"""Copy finished match results from Redis into Postgres.
|
|
||||||
|
|
||||||
Called once when a game reaches the finished phase (guarded by the
|
|
||||||
``stats_saved`` flag on the state). The write is transactional so a match
|
|
||||||
never appears with only some of its players.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from logging import getLogger
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from tortoise.transactions import in_transaction
|
|
||||||
|
|
||||||
from .game.state import PHASE_FINISHED, TEAM_NAMES, GameState
|
|
||||||
|
|
||||||
log = getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_timestamp(value: Optional[str]) -> datetime:
|
|
||||||
if value:
|
|
||||||
try:
|
|
||||||
return datetime.fromisoformat(value)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
async def save_match_result(state: GameState) -> None:
|
|
||||||
"""Persist ``state`` to Postgres if it is finished and not yet saved."""
|
|
||||||
if state.stats_saved or state.phase != PHASE_FINISHED or state.winner is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
from .models import Match, MatchPlayer
|
|
||||||
|
|
||||||
started_at = _parse_timestamp(state.created_at)
|
|
||||||
finished_at = _parse_timestamp(state.finished_at)
|
|
||||||
async with in_transaction():
|
|
||||||
match = await Match.create(
|
|
||||||
id=uuid.uuid4(),
|
|
||||||
game_type=state.game_type,
|
|
||||||
team_a_score=state.scores[0],
|
|
||||||
team_b_score=state.scores[1],
|
|
||||||
winner_team=TEAM_NAMES[state.winner],
|
|
||||||
target_score=state.target_score,
|
|
||||||
hands_played=state.hand_number,
|
|
||||||
started_at=started_at,
|
|
||||||
finished_at=finished_at,
|
|
||||||
)
|
|
||||||
for player in state.players:
|
|
||||||
await MatchPlayer.create(
|
|
||||||
id=uuid.uuid4(),
|
|
||||||
match=match,
|
|
||||||
user_sub=player.sub,
|
|
||||||
display_name=player.name,
|
|
||||||
seat=player.seat,
|
|
||||||
team=TEAM_NAMES[player.team],
|
|
||||||
won=player.team == state.winner,
|
|
||||||
)
|
|
||||||
state.stats_saved = True
|
|
||||||
log.info(
|
|
||||||
"match result persisted: game %s (%s), team %s won %d-%d over %d hands",
|
|
||||||
state.id,
|
|
||||||
state.game_type,
|
|
||||||
TEAM_NAMES[state.winner],
|
|
||||||
state.scores[0],
|
|
||||||
state.scores[1],
|
|
||||||
state.hand_number,
|
|
||||||
)
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
"""Persistence for live games.
|
|
||||||
|
|
||||||
Game state is small, mutable and short-lived, which makes Redis a natural
|
|
||||||
fit: the whole match is a single JSON value under ``tavolo:game:<id>`` with
|
|
||||||
a sliding TTL, and a join-code index maps the short code a player shares to
|
|
||||||
that id. Completed matches are copied to Postgres (see
|
|
||||||
:mod:`tavolo.models`); Redis keeps serving the finished state until it
|
|
||||||
expires.
|
|
||||||
|
|
||||||
Two implementations satisfy the same interface:
|
|
||||||
|
|
||||||
* :class:`RedisGameStore` — production, used when ``REDIS_URL`` is set.
|
|
||||||
* :class:`InMemoryGameStore` — tests and ephemeral dev, used otherwise.
|
|
||||||
|
|
||||||
Concurrency is handled with a per-game lock so two simultaneous plays
|
|
||||||
cannot interleave. State changes are broadcast on a per-game pub/sub
|
|
||||||
channel as a simple "something changed" signal; every open websocket
|
|
||||||
reloads the state and renders the personalized view. Publishing only a
|
|
||||||
signal (never the state) means updated state reaches connections on every
|
|
||||||
worker without leaking hidden hands into the channel.
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
"""WebSocket endpoint for live play.
|
|
||||||
|
|
||||||
Clients connect to ``/ws/games/{game_id}`` using their session cookie (the
|
|
||||||
OIDC login stores the user in the session, which the session mixin loads
|
|
||||||
onto the websocket). Only seated players are accepted.
|
|
||||||
|
|
||||||
Protocol
|
|
||||||
--------
|
|
||||||
Server -> client messages are JSON objects with a ``type``:
|
|
||||||
|
|
||||||
* ``state`` — the personalized game view (own hand visible, others hidden).
|
|
||||||
* ``game_over`` — sent once when the match ends, with the final scores.
|
|
||||||
* ``error`` — a rejected action or malformed message.
|
|
||||||
|
|
||||||
Client -> server messages are JSON objects::
|
|
||||||
|
|
||||||
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
|
|
||||||
{"action": "play", "card": "07D"}
|
|
||||||
{"action": "ack"}
|
|
||||||
{"action": "state"}
|
|
||||||
|
|
||||||
``capture`` lists the table cards to take and must be a legal capture when
|
|
||||||
one exists (see :func:`tavolo.game.engine.legal_captures`); it is omitted
|
|
||||||
when the played card cannot capture. ``ack`` acknowledges the hand-end
|
|
||||||
scoring summary; the next hand is dealt when all four players have
|
|
||||||
acknowledged or the timeout fires.
|
|
||||||
|
|
||||||
Mutations run under the per-game lock; after a successful move the new
|
|
||||||
state is saved to Redis and a change signal is published. Every connected
|
|
||||||
websocket is subscribed to that signal and re-renders the state, so all
|
|
||||||
players see the move immediately (and consistently across workers).
|
|
||||||
|
|
||||||
Timeouts do not depend on anyone being connected: both the per-turn
|
|
||||||
auto-play and the hand-end auto-continue are driven by the absolute
|
|
||||||
deadlines persisted on the game state, via the shared deadline queue
|
|
||||||
drained by a consumer on every worker (see :mod:`tavolo.deadlines`). A
|
|
||||||
disconnected or idle player therefore cannot stall the match, and a
|
|
||||||
worker dying cannot either.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
from contextlib import suppress
|
|
||||||
from logging import getLogger
|
|
||||||
from typing import Any, Awaitable, Callable, Dict
|
|
||||||
|
|
||||||
from kaya.core import WebSocket
|
|
||||||
|
|
||||||
from . import auth, deadlines
|
|
||||||
from .app import app, game_store
|
|
||||||
from .game import engine
|
|
||||||
from .game.errors import GameError
|
|
||||||
from .game.state import PHASE_FINISHED, GameState
|
|
||||||
|
|
||||||
log = getLogger(__name__)
|
|
||||||
|
|
||||||
Send = Callable[[Dict[str, Any]], Awaitable[None]]
|
|
||||||
|
|
||||||
|
|
||||||
def _error(message: str, code: str = "invalid") -> Dict[str, Any]:
|
|
||||||
return {"type": "error", "code": code, "message": message}
|
|
||||||
|
|
||||||
|
|
||||||
def _state_message(state: GameState, sub: str) -> Dict[str, Any]:
|
|
||||||
return {"type": "state", "game": engine.state_for_player(state, sub)}
|
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/ws/games/${game_id}")
|
|
||||||
async def game_socket(ws: WebSocket, game_id: str) -> None:
|
|
||||||
user = auth.get_ws_user(ws)
|
|
||||||
if user is None:
|
|
||||||
log.debug("websocket %s rejected: no authenticated user", game_id)
|
|
||||||
await ws.close(4401)
|
|
||||||
return
|
|
||||||
|
|
||||||
state = await game_store.load(game_id)
|
|
||||||
if state is None:
|
|
||||||
log.debug("websocket rejected: unknown game %s", game_id)
|
|
||||||
await ws.close(4404)
|
|
||||||
return
|
|
||||||
if not state.seated(user.sub):
|
|
||||||
log.debug("websocket %s rejected: %s is not seated", game_id, user.sub)
|
|
||||||
await ws.close(4403)
|
|
||||||
return
|
|
||||||
|
|
||||||
await ws.accept()
|
|
||||||
log.info("%s connected to game %s", user.sub, game_id)
|
|
||||||
|
|
||||||
send_lock = asyncio.Lock()
|
|
||||||
|
|
||||||
async def send(payload: Dict[str, Any]) -> None:
|
|
||||||
async with send_lock:
|
|
||||||
await ws.send_text(json.dumps(payload))
|
|
||||||
|
|
||||||
await send(_state_message(state, user.sub))
|
|
||||||
# Backstop: make sure the current phase's deadline is queued even if
|
|
||||||
# its entry was lost (e.g. the queue was flushed while the game lived
|
|
||||||
# on thanks to its sliding TTL).
|
|
||||||
await deadlines.sync_deadline(game_store, state)
|
|
||||||
|
|
||||||
async with game_store.subscribe(game_id) as events:
|
|
||||||
forward = asyncio.create_task(
|
|
||||||
_forward(events, game_id, user.sub, send)
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
async for message in ws:
|
|
||||||
if message.kind == "close":
|
|
||||||
break
|
|
||||||
if message.kind != "text" or not isinstance(message.data, str):
|
|
||||||
await send(_error("expected a text frame with a JSON object"))
|
|
||||||
continue
|
|
||||||
await _handle_message(send, game_id, user.sub, message.data)
|
|
||||||
finally:
|
|
||||||
forward.cancel()
|
|
||||||
with suppress(asyncio.CancelledError):
|
|
||||||
await forward
|
|
||||||
log.debug("%s disconnected from game %s", user.sub, game_id)
|
|
||||||
|
|
||||||
|
|
||||||
async def _forward(
|
|
||||||
events,
|
|
||||||
game_id: str,
|
|
||||||
sub: str,
|
|
||||||
send: Send,
|
|
||||||
) -> None:
|
|
||||||
async for _ in events:
|
|
||||||
state = await game_store.load(game_id)
|
|
||||||
if state is None:
|
|
||||||
return
|
|
||||||
await send(_state_message(state, sub))
|
|
||||||
if state.phase == PHASE_FINISHED:
|
|
||||||
await send(
|
|
||||||
{
|
|
||||||
"type": "game_over",
|
|
||||||
"scores": {"A": state.scores[0], "B": state.scores[1]},
|
|
||||||
"winner": "A" if state.winner == 0 else "B",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_message(send: Send, game_id: str, sub: str, raw: str) -> None:
|
|
||||||
try:
|
|
||||||
data = json.loads(raw)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
log.debug("game %s: malformed message from %s (not JSON)", game_id, sub)
|
|
||||||
await send(_error("invalid JSON"))
|
|
||||||
return
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
log.debug("game %s: malformed message from %s (not an object)", game_id, sub)
|
|
||||||
await send(_error("message must be a JSON object"))
|
|
||||||
return
|
|
||||||
|
|
||||||
action = data.get("action")
|
|
||||||
if action == "play":
|
|
||||||
await _handle_play(send, game_id, sub, data)
|
|
||||||
elif action == "ack":
|
|
||||||
await _handle_ack(send, game_id, sub)
|
|
||||||
elif action in ("state", "sync"):
|
|
||||||
state = await game_store.load(game_id)
|
|
||||||
if state is not None:
|
|
||||||
await send(_state_message(state, sub))
|
|
||||||
else:
|
|
||||||
await send(_error(f"unknown action: {action!r}"))
|
|
||||||
|
|
||||||
|
|
||||||
# --- hand-end acknowledgement ------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_ack(send: Send, game_id: str, sub: str) -> None:
|
|
||||||
async with game_store.lock(game_id):
|
|
||||||
state = await game_store.load(game_id)
|
|
||||||
if state is None:
|
|
||||||
await send(_error("game not found", code="not_found"))
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
engine.acknowledge_hand(state, sub)
|
|
||||||
except GameError as exc:
|
|
||||||
await send(_error(str(exc), code="illegal_move"))
|
|
||||||
return
|
|
||||||
log.debug("game %s: %s acknowledged hand %d", game_id, sub, state.hand_number)
|
|
||||||
await game_store.save(state)
|
|
||||||
await game_store.publish(game_id)
|
|
||||||
# The fourth ack deals the next hand, which arms a new turn
|
|
||||||
# deadline; earlier acks change nothing and this is a no-op.
|
|
||||||
await deadlines.sync_deadline(game_store, state)
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_play(
|
|
||||||
send: Send, game_id: str, sub: str, data: Dict[str, Any]
|
|
||||||
) -> None:
|
|
||||||
card = data.get("card")
|
|
||||||
capture = data.get("capture")
|
|
||||||
if not isinstance(card, str):
|
|
||||||
await send(_error("'card' must be a card code string"))
|
|
||||||
return
|
|
||||||
if capture is not None and (
|
|
||||||
not isinstance(capture, list)
|
|
||||||
or any(not isinstance(item, str) for item in capture)
|
|
||||||
):
|
|
||||||
await send(_error("'capture' must be a list of card codes"))
|
|
||||||
return
|
|
||||||
|
|
||||||
async with game_store.lock(game_id):
|
|
||||||
state = await game_store.load(game_id)
|
|
||||||
if state is None:
|
|
||||||
await send(_error("game not found", code="not_found"))
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
engine.play(state, sub, card, capture)
|
|
||||||
except GameError as exc:
|
|
||||||
log.debug("game %s: illegal move by %s: %s", game_id, sub, exc)
|
|
||||||
await send(_error(str(exc), code="illegal_move"))
|
|
||||||
return
|
|
||||||
except ValueError:
|
|
||||||
log.debug("game %s: invalid card code from %s: %r", game_id, sub, card)
|
|
||||||
await send(_error("invalid card code", code="illegal_move"))
|
|
||||||
return
|
|
||||||
|
|
||||||
log.debug("game %s: %s played %s (capture: %s)", game_id, sub, card, capture or "-")
|
|
||||||
await deadlines.finalize_mutation(game_store, state)
|
|
||||||
@@ -10,8 +10,9 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "sqlite://:memory:")
|
os.environ.setdefault("DATABASE_URL", "sqlite://:memory:")
|
||||||
os.environ.setdefault("OIDC_ISSUER", "http://localhost:8180/tavolo")
|
os.environ.setdefault("OIDC_0_PROVIDER", "default")
|
||||||
os.environ.setdefault("OIDC_CLIENT_ID", "tavolo")
|
os.environ.setdefault("OIDC_0_ISSUER", "http://localhost:8180/tavolo")
|
||||||
os.environ.setdefault("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback")
|
os.environ.setdefault("OIDC_0_CLIENT_ID", "tavolo")
|
||||||
|
os.environ.setdefault("OIDC_0_REDIRECT_URI", "http://localhost:8080/auth/default/callback")
|
||||||
# Unset REDIS_URL: sessions and live games use the in-memory fallbacks.
|
# Unset REDIS_URL: sessions and live games use the in-memory fallbacks.
|
||||||
os.environ.pop("REDIS_URL", None)
|
os.environ.pop("REDIS_URL", None)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Test helpers package."""
|
"""Test helpers package for the tavolo-app integration suite."""
|
||||||
|
from .asynctest import async_test
|
||||||
from .oidc import make_user, oidc_user, ws_users
|
from .oidc import make_user, oidc_user, ws_users
|
||||||
|
|
||||||
__all__ = ["make_user", "oidc_user", "ws_users"]
|
__all__ = ["async_test", "make_user", "oidc_user", "ws_users"]
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""An ``async_test`` that also closes the app's Tortoise context.
|
||||||
|
|
||||||
|
``pwo.async_test`` runs every test in a fresh event loop (``asyncio.Runner``).
|
||||||
|
The app's :class:`~tavolo.platform.tortoise_mixin.TortoiseMixin` builds one
|
||||||
|
``TortoiseContext`` per loop, so without an explicit close each test orphans
|
||||||
|
an aiosqlite connection whose non-daemon worker thread keeps the interpreter
|
||||||
|
alive after the suite reports "OK".
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Any, Callable, Coroutine
|
||||||
|
|
||||||
|
from tavolo.app import scheduler, tortoise_mixin
|
||||||
|
|
||||||
|
|
||||||
|
def async_test(coro: Callable[..., Coroutine[Any, Any, None]]) -> Callable[..., None]:
|
||||||
|
"""Like ``pwo.async_test``, but close the Tortoise context and stop the
|
||||||
|
deadline consumer afterwards."""
|
||||||
|
|
||||||
|
@wraps(coro)
|
||||||
|
def wrapper(*args: Any, **kwargs: Any) -> None:
|
||||||
|
async def run() -> None:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
try:
|
||||||
|
await coro(*args, **kwargs)
|
||||||
|
finally:
|
||||||
|
scheduler.stop_consumer(loop)
|
||||||
|
await tortoise_mixin.aclose()
|
||||||
|
|
||||||
|
with asyncio.Runner() as runner:
|
||||||
|
runner.run(run())
|
||||||
|
|
||||||
|
return wrapper
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
"""Helpers for faking the OIDC authenticated user during tests.
|
"""Helpers for faking the OIDC authenticated user during tests.
|
||||||
|
|
||||||
HTTP handlers funnel through ``oidc_mixin.get_user``; websocket handlers
|
HTTP handlers funnel through ``OIDCProviders.get_users`` (wrapped by
|
||||||
through :func:`tavolo.auth.get_ws_user`. Patching those two entry points
|
``tavolo.platform.auth.current_user``); websocket handlers through
|
||||||
|
:func:`tavolo.platform.auth.get_ws_user`. Patching those two entry points
|
||||||
lets route and websocket tests run entirely in-process with no IdP.
|
lets route and websocket tests run entirely in-process with no IdP.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -13,10 +14,13 @@ from typing import Iterator, Optional, Sequence
|
|||||||
from kaya.oidc import OIDCUser
|
from kaya.oidc import OIDCUser
|
||||||
|
|
||||||
# Import the app first: it pulls in the route modules, which import
|
# Import the app first: it pulls in the route modules, which import
|
||||||
# ``tavolo.auth`` themselves. Importing ``auth`` before ``app`` would hit a
|
# ``tavolo.platform.auth`` themselves.
|
||||||
# partially initialized module (same constraint as reimpasto).
|
from tavolo.app import oidc
|
||||||
from tavolo.app import oidc_mixin
|
from tavolo.platform import auth
|
||||||
from tavolo import auth
|
|
||||||
|
# The app's first configured provider (the test environment configures one
|
||||||
|
# named "default").
|
||||||
|
_PROVIDER = next(iter(oidc.providers))
|
||||||
|
|
||||||
|
|
||||||
def make_user(sub: str, name: Optional[str] = None) -> OIDCUser:
|
def make_user(sub: str, name: Optional[str] = None) -> OIDCUser:
|
||||||
@@ -25,9 +29,9 @@ def make_user(sub: str, name: Optional[str] = None) -> OIDCUser:
|
|||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def oidc_user(sub: str, name: Optional[str] = None) -> Iterator[OIDCUser]:
|
def oidc_user(sub: str, name: Optional[str] = None) -> Iterator[OIDCUser]:
|
||||||
"""Context manager: patch ``oidc_mixin.get_user`` to return this user."""
|
"""Context manager: make ``oidc.get_users`` return this user."""
|
||||||
user = make_user(sub, name)
|
user = make_user(sub, name)
|
||||||
patcher = _mock.patch.object(oidc_mixin, "get_user", return_value=user)
|
patcher = _mock.patch.object(oidc, "get_users", return_value={_PROVIDER: user})
|
||||||
patcher.start()
|
patcher.start()
|
||||||
try:
|
try:
|
||||||
yield user
|
yield user
|
||||||
|
|||||||
@@ -0,0 +1,368 @@
|
|||||||
|
"""Briscola integration tests: lobby, websocket play, timeouts and summaries.
|
||||||
|
|
||||||
|
The per-package suites cover the rules and the engine contract; these
|
||||||
|
tests wire both briscola variants through the full platform stack (REST,
|
||||||
|
WebSocket, deadline scheduler).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from httpx_ws import aconnect_ws
|
||||||
|
from httpx_ws.transport import ASGIWebSocketTransport
|
||||||
|
|
||||||
|
from tavolo.app import app, game_store, platform
|
||||||
|
from tavolo.briscola.state import (
|
||||||
|
PHASE_HAND_END,
|
||||||
|
PHASE_PLAYING,
|
||||||
|
BriscolaState,
|
||||||
|
Card,
|
||||||
|
PlayerState,
|
||||||
|
)
|
||||||
|
from tavolo.platform import GameSession, Seat
|
||||||
|
from tests.helpers import async_test, make_user, oidc_user, ws_users
|
||||||
|
|
||||||
|
|
||||||
|
class BriscolaRoutesTest(unittest.TestCase):
|
||||||
|
@async_test
|
||||||
|
async def test_create_and_start_two_player_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={"game_type": "briscola", "options": {"target_score": 101}},
|
||||||
|
)
|
||||||
|
self.assertEqual(201, created.status_code)
|
||||||
|
body = created.json()
|
||||||
|
self.assertEqual("briscola", body["game_type"])
|
||||||
|
self.assertEqual("lobby", body["phase"])
|
||||||
|
self.assertEqual(1, body["seats_open"])
|
||||||
|
self.assertEqual(101, body["target_score"])
|
||||||
|
self.assertEqual("A", body["players"][0]["team"])
|
||||||
|
code = body["join_code"]
|
||||||
|
|
||||||
|
with oidc_user("bob"):
|
||||||
|
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(2, len(state["players"]))
|
||||||
|
self.assertEqual(["A", "B"], [p["team"] for p in state["players"]])
|
||||||
|
for participant in state["players"]:
|
||||||
|
self.assertEqual(3, participant["cards_left"])
|
||||||
|
self.assertEqual(1, state["turn"])
|
||||||
|
self.assertEqual(33, state["stock_count"])
|
||||||
|
self.assertTrue(state["briscola_suit"])
|
||||||
|
self.assertIsNotNone(state["briscola_card"])
|
||||||
|
# Bob's view carries his own hand only.
|
||||||
|
self.assertIn("hand", state["players"][1])
|
||||||
|
self.assertNotIn("hand", state["players"][0])
|
||||||
|
|
||||||
|
# A non-player watches without a hand or the join code.
|
||||||
|
watched = await client.get(f"/api/games/{state['id']}")
|
||||||
|
self.assertEqual(200, watched.status_code)
|
||||||
|
spectator = watched.json()
|
||||||
|
self.assertTrue(spectator["spectator"])
|
||||||
|
self.assertNotIn("join_code", spectator)
|
||||||
|
for participant in spectator["players"]:
|
||||||
|
self.assertNotIn("hand", participant)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_create_and_start_four_player_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={"game_type": "briscola_a_quattro"}
|
||||||
|
)
|
||||||
|
self.assertEqual(201, created.status_code)
|
||||||
|
body = created.json()
|
||||||
|
self.assertEqual("briscola_a_quattro", body["game_type"])
|
||||||
|
self.assertEqual(3, body["seats_open"])
|
||||||
|
code = body["join_code"]
|
||||||
|
|
||||||
|
for player in ("bob", "carol"):
|
||||||
|
with oidc_user(player):
|
||||||
|
joined = await client.post("/api/games/join", json={"code": code})
|
||||||
|
self.assertEqual("lobby", joined.json()["phase"])
|
||||||
|
|
||||||
|
with oidc_user("dave"):
|
||||||
|
started = await client.post("/api/games/join", json={"code": code})
|
||||||
|
state = started.json()
|
||||||
|
self.assertEqual("playing", state["phase"])
|
||||||
|
self.assertEqual(["A", "B", "A", "B"], [p["team"] for p in state["players"]])
|
||||||
|
for participant in state["players"]:
|
||||||
|
self.assertEqual(3, participant["cards_left"])
|
||||||
|
self.assertEqual(40 - 12 - 1, state["stock_count"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_preview_shows_briscola_options(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": "briscola", "options": {"target_score": 101}},
|
||||||
|
)
|
||||||
|
code = created.json()["join_code"]
|
||||||
|
with oidc_user("bob"):
|
||||||
|
preview = await client.get(f"/api/games/preview?code={code}")
|
||||||
|
self.assertEqual(200, preview.status_code)
|
||||||
|
body = preview.json()
|
||||||
|
self.assertEqual("briscola", body["game_type"])
|
||||||
|
self.assertEqual("Briscola", body["game_name"])
|
||||||
|
self.assertTrue(body["game_description"])
|
||||||
|
self.assertEqual(101, body["target_score"])
|
||||||
|
self.assertEqual(1, body["seats_open"])
|
||||||
|
|
||||||
|
@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:
|
||||||
|
for bad in (60, 121, "sixtyone", True):
|
||||||
|
with oidc_user("alice"):
|
||||||
|
response = await client.post(
|
||||||
|
"/api/games",
|
||||||
|
json={"game_type": "briscola", "options": {"target_score": bad}},
|
||||||
|
)
|
||||||
|
self.assertEqual(400, response.status_code, bad)
|
||||||
|
|
||||||
|
|
||||||
|
class BriscolaWebSocketTest(unittest.TestCase):
|
||||||
|
async def _started_two_player(self, client: AsyncClient) -> dict:
|
||||||
|
with oidc_user("alice"):
|
||||||
|
created = await client.post("/api/games", json={"game_type": "briscola"})
|
||||||
|
code = created.json()["join_code"]
|
||||||
|
with oidc_user("bob"):
|
||||||
|
started = await client.post("/api/games/join", json={"code": code})
|
||||||
|
return started.json()
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_moves_resolve_the_trick(self) -> None:
|
||||||
|
api_transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||||
|
state = await self._started_two_player(client)
|
||||||
|
game_id = state["id"]
|
||||||
|
bob_card = state["players"][1]["hand"][0]
|
||||||
|
with oidc_user("alice"):
|
||||||
|
alice_view = await client.get(f"/api/games/{game_id}")
|
||||||
|
alice_card = alice_view.json()["players"][0]["hand"][0]
|
||||||
|
|
||||||
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
with ws_users([make_user("bob"), make_user("alice")]):
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
|
||||||
|
first = await bob_ws.receive_json()
|
||||||
|
self.assertEqual("state", first["type"])
|
||||||
|
self.assertEqual(1, first["game"]["turn"])
|
||||||
|
self.assertTrue(first["game"].get("your_turn"))
|
||||||
|
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
|
||||||
|
await alice_ws.receive_json()
|
||||||
|
await bob_ws.send_json(
|
||||||
|
{"action": "play", "card": bob_card}
|
||||||
|
)
|
||||||
|
bob_update = await bob_ws.receive_json()
|
||||||
|
alice_update = await alice_ws.receive_json()
|
||||||
|
for update in (bob_update, alice_update):
|
||||||
|
self.assertEqual("state", update["type"])
|
||||||
|
self.assertEqual(0, update["game"]["turn"])
|
||||||
|
self.assertEqual(
|
||||||
|
[{"seat": 1, "card": bob_card}],
|
||||||
|
update["game"]["trick"],
|
||||||
|
)
|
||||||
|
|
||||||
|
await alice_ws.send_json(
|
||||||
|
{"action": "play", "card": alice_card}
|
||||||
|
)
|
||||||
|
bob_update = await bob_ws.receive_json()
|
||||||
|
alice_update = await alice_ws.receive_json()
|
||||||
|
for update in (bob_update, alice_update):
|
||||||
|
self.assertEqual([], update["game"]["trick"])
|
||||||
|
last = update["game"]["last_trick"]
|
||||||
|
self.assertIsNotNone(last)
|
||||||
|
self.assertIn(last["winner"], (0, 1))
|
||||||
|
self.assertEqual(2, len(last["cards"]))
|
||||||
|
self.assertEqual(last["winner"], update["game"]["turn"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_illegal_move_returns_error(self) -> None:
|
||||||
|
api_transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||||
|
state = await self._started_two_player(client)
|
||||||
|
game_id = state["id"]
|
||||||
|
|
||||||
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
with ws_users([make_user("alice")]):
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
|
||||||
|
await alice_ws.receive_json()
|
||||||
|
# Bob (seat 1) is on turn, not Alice.
|
||||||
|
await alice_ws.send_json({"action": "play", "card": "01D"})
|
||||||
|
error = await alice_ws.receive_json()
|
||||||
|
self.assertEqual("error", error["type"])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_spectator_cannot_act(self) -> None:
|
||||||
|
api_transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||||
|
state = await self._started_two_player(client)
|
||||||
|
game_id = state["id"]
|
||||||
|
|
||||||
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
with ws_users([make_user("mallory")]):
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||||
|
first = await ws.receive_json()
|
||||||
|
self.assertTrue(first["game"]["spectator"])
|
||||||
|
self.assertNotIn("join_code", first["game"])
|
||||||
|
for player in first["game"]["players"]:
|
||||||
|
self.assertNotIn("hand", player)
|
||||||
|
await ws.send_json({"action": "play", "card": "01D"})
|
||||||
|
error = await ws.receive_json()
|
||||||
|
self.assertEqual("forbidden", error["code"])
|
||||||
|
|
||||||
|
|
||||||
|
class BriscolaTurnTimeoutTest(unittest.TestCase):
|
||||||
|
@async_test
|
||||||
|
async def test_turn_timeout_auto_plays_a_card(self) -> None:
|
||||||
|
engine = platform.registry.require("briscola")
|
||||||
|
session = GameSession(
|
||||||
|
id="briscola-turn-timeout-1",
|
||||||
|
game_type=engine.id,
|
||||||
|
join_code="BT0001",
|
||||||
|
creator_sub="alice",
|
||||||
|
players=[
|
||||||
|
Seat(user_sub="alice", display_name="Alice", team="A"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
engine.create(session, {})
|
||||||
|
session.state.turn_timeout = 1
|
||||||
|
engine.join(session, "bob", "Bob")
|
||||||
|
await game_store.save(session)
|
||||||
|
|
||||||
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
with ws_users([make_user("alice")]):
|
||||||
|
async with aconnect_ws(f"/ws/games/{session.id}", ws_client) as ws:
|
||||||
|
first = await ws.receive_json()
|
||||||
|
# Bob (seat 1) is first to act and never connects.
|
||||||
|
self.assertEqual(1, first["game"]["turn"])
|
||||||
|
self.assertIsNotNone(first["game"]["turn_deadline"])
|
||||||
|
|
||||||
|
update = None
|
||||||
|
for _ in range(20):
|
||||||
|
try:
|
||||||
|
update = await asyncio.wait_for(ws.receive_json(), timeout=2)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
break
|
||||||
|
if (
|
||||||
|
update.get("type") == "state"
|
||||||
|
and update["game"]["last_trick"] is not None
|
||||||
|
):
|
||||||
|
break
|
||||||
|
self.assertIsNotNone(update)
|
||||||
|
assert update is not None
|
||||||
|
# Both absent players were auto-played; the trick
|
||||||
|
# resolved and the winner leads with a full hand.
|
||||||
|
self.assertIn(update["game"]["last_trick"]["winner"], (0, 1))
|
||||||
|
self.assertEqual([], update["game"]["trick"])
|
||||||
|
for participant in update["game"]["players"]:
|
||||||
|
self.assertEqual(3, participant["cards_left"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_hand_end_state() -> str:
|
||||||
|
"""Seed a game where a single trick ends the deal with target 120 so
|
||||||
|
the match continues to the summary."""
|
||||||
|
engine = platform.registry.require("briscola")
|
||||||
|
session = GameSession(
|
||||||
|
id="briscola-hand-end-1",
|
||||||
|
game_type=engine.id,
|
||||||
|
join_code="BH0001",
|
||||||
|
creator_sub="alice",
|
||||||
|
players=[
|
||||||
|
Seat(user_sub="alice", display_name="Alice", team="A"),
|
||||||
|
Seat(user_sub="bob", display_name="Bob", team="B"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
session.state = BriscolaState(
|
||||||
|
player_count=2,
|
||||||
|
target_score=120,
|
||||||
|
phase=PHASE_PLAYING,
|
||||||
|
dealer=0,
|
||||||
|
turn=0,
|
||||||
|
briscola_suit="S",
|
||||||
|
players=[
|
||||||
|
PlayerState(
|
||||||
|
sub="alice", name="Alice", seat=0,
|
||||||
|
hand=[Card.parse("02D")], captured=[Card.parse("01C")],
|
||||||
|
),
|
||||||
|
PlayerState(
|
||||||
|
sub="bob", name="Bob", seat=1,
|
||||||
|
hand=[Card.parse("05D")], captured=[Card.parse("10C")],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
hand_ack_timeout=30,
|
||||||
|
)
|
||||||
|
await game_store.save(session)
|
||||||
|
return session.id
|
||||||
|
|
||||||
|
|
||||||
|
class BriscolaHandEndTest(unittest.TestCase):
|
||||||
|
@async_test
|
||||||
|
async def test_deal_end_ack_flow(self) -> None:
|
||||||
|
game_id = await _seed_hand_end_state()
|
||||||
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
async with contextlib.AsyncExitStack() as stack:
|
||||||
|
with ws_users([make_user("alice"), make_user("bob")]):
|
||||||
|
sockets = [
|
||||||
|
await stack.enter_async_context(
|
||||||
|
aconnect_ws(f"/ws/games/{game_id}", ws_client)
|
||||||
|
)
|
||||||
|
for _ in range(2)
|
||||||
|
]
|
||||||
|
for ws in sockets:
|
||||||
|
await ws.receive_json() # initial state
|
||||||
|
|
||||||
|
# Alice leads, Bob takes the trick: the deal ends.
|
||||||
|
await sockets[0].send_json(
|
||||||
|
{"action": "play", "card": "02D"}
|
||||||
|
)
|
||||||
|
for ws in sockets:
|
||||||
|
await ws.receive_json()
|
||||||
|
await sockets[1].send_json(
|
||||||
|
{"action": "play", "card": "05D"}
|
||||||
|
)
|
||||||
|
summaries = [await ws.receive_json() for ws in sockets]
|
||||||
|
for summary in summaries:
|
||||||
|
self.assertEqual("state", summary["type"])
|
||||||
|
self.assertEqual(PHASE_HAND_END, summary["game"]["phase"])
|
||||||
|
self.assertEqual([], summary["game"]["acknowledged"])
|
||||||
|
self.assertIsNotNone(summary["game"]["hand_end_deadline"])
|
||||||
|
deal = summary["game"]["last_deal"]
|
||||||
|
self.assertEqual({"A": 11, "B": 4}, deal["points"])
|
||||||
|
self.assertEqual({"A": 11, "B": 4}, deal["totals"])
|
||||||
|
self.assertFalse(deal["patta"])
|
||||||
|
|
||||||
|
# Both acknowledge; the second ack deals the next deal.
|
||||||
|
await sockets[0].send_json({"action": "ack"})
|
||||||
|
updates = [await ws.receive_json() for ws in sockets]
|
||||||
|
for update in updates:
|
||||||
|
self.assertEqual(PHASE_HAND_END, update["game"]["phase"])
|
||||||
|
self.assertEqual([0], update["game"]["acknowledged"])
|
||||||
|
await sockets[1].send_json({"action": "ack"})
|
||||||
|
updates = [await ws.receive_json() for ws in sockets]
|
||||||
|
for update in updates:
|
||||||
|
self.assertEqual(PHASE_PLAYING, update["game"]["phase"])
|
||||||
|
self.assertEqual(2, update["game"]["hand_number"])
|
||||||
|
for player in update["game"]["players"]:
|
||||||
|
self.assertEqual(3, player["cards_left"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -130,5 +130,188 @@ class CorsSettingsTests(unittest.TestCase):
|
|||||||
self.assertEqual(3600, settings.cors_max_age)
|
self.assertEqual(3600, settings.cors_max_age)
|
||||||
|
|
||||||
|
|
||||||
|
class OTelSettingsTests(unittest.TestCase):
|
||||||
|
def test_otel_disabled_by_default(self):
|
||||||
|
settings = _settings({})
|
||||||
|
self.assertFalse(settings.otel_enabled)
|
||||||
|
self.assertEqual("tavolo", settings.otel_service_name)
|
||||||
|
self.assertIsNone(settings.otel_exporter_endpoint)
|
||||||
|
self.assertIsNone(settings.otel_exporter_headers)
|
||||||
|
|
||||||
|
def test_otel_enabled_parses_boolean(self):
|
||||||
|
for value in ("1", "true", "TRUE", "yes", "on"):
|
||||||
|
self.assertTrue(_settings({"OTEL_ENABLED": value}).otel_enabled)
|
||||||
|
for value in ("0", "false", "no", "off", "anything-else"):
|
||||||
|
self.assertFalse(_settings({"OTEL_ENABLED": value}).otel_enabled)
|
||||||
|
|
||||||
|
def test_otel_settings_are_passed_through(self):
|
||||||
|
settings = _settings({
|
||||||
|
"OTEL_SERVICE_NAME": "cards",
|
||||||
|
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector:4318",
|
||||||
|
"OTEL_EXPORTER_OTLP_HEADERS": "Authorization=Bearer t, X-Tenant=one",
|
||||||
|
})
|
||||||
|
self.assertEqual("cards", settings.otel_service_name)
|
||||||
|
self.assertEqual("http://collector:4318", settings.otel_exporter_endpoint)
|
||||||
|
self.assertEqual(
|
||||||
|
("Authorization=Bearer t", "X-Tenant=one"),
|
||||||
|
settings.otel_exporter_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_otel_excluded_paths_defaults_to_health_endpoint(self):
|
||||||
|
self.assertEqual(("/api/health",), _settings({}).otel_excluded_paths)
|
||||||
|
|
||||||
|
def test_otel_excluded_paths_parses_comma_separated_list(self):
|
||||||
|
settings = _settings({"OTEL_EXCLUDED_PATHS": "/api/health, /metrics"})
|
||||||
|
self.assertEqual(("/api/health", "/metrics"), settings.otel_excluded_paths)
|
||||||
|
|
||||||
|
def test_otel_interval_defaults(self):
|
||||||
|
settings = _settings({})
|
||||||
|
self.assertEqual(60000, settings.otel_metric_export_interval_millis)
|
||||||
|
self.assertEqual(15, settings.otel_metrics_poll_interval_seconds)
|
||||||
|
|
||||||
|
def test_otel_intervals_parse(self):
|
||||||
|
settings = _settings({
|
||||||
|
"OTEL_METRIC_EXPORT_INTERVAL_MILLIS": "5000",
|
||||||
|
"OTEL_METRICS_POLL_INTERVAL_SECONDS": "3",
|
||||||
|
})
|
||||||
|
self.assertEqual(5000, settings.otel_metric_export_interval_millis)
|
||||||
|
self.assertEqual(3, settings.otel_metrics_poll_interval_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
class OidcSettingsTests(unittest.TestCase):
|
||||||
|
def test_defaults_to_the_dev_mock_provider(self):
|
||||||
|
settings = _settings({})
|
||||||
|
self.assertEqual(1, len(settings.oidc_providers))
|
||||||
|
provider = settings.oidc_providers[0]
|
||||||
|
self.assertEqual("default", provider.provider)
|
||||||
|
self.assertEqual("http://localhost:8180/tavolo", provider.issuer)
|
||||||
|
self.assertEqual("tavolo", provider.client_id)
|
||||||
|
self.assertIsNone(provider.client_secret)
|
||||||
|
self.assertEqual(
|
||||||
|
"http://localhost:8080/auth/default/callback", provider.redirect_uri
|
||||||
|
)
|
||||||
|
self.assertEqual("/", provider.post_login_redirect)
|
||||||
|
self.assertEqual("/", provider.post_logout_redirect)
|
||||||
|
|
||||||
|
def test_single_indexed_provider_defaults(self):
|
||||||
|
settings = _settings({
|
||||||
|
"OIDC_0_PROVIDER": "google",
|
||||||
|
"OIDC_0_ISSUER": "https://accounts.google.com",
|
||||||
|
"OIDC_0_CLIENT_ID": "client-id",
|
||||||
|
"OIDC_0_CLIENT_SECRET": "client-secret",
|
||||||
|
})
|
||||||
|
self.assertEqual(1, len(settings.oidc_providers))
|
||||||
|
provider = settings.oidc_providers[0]
|
||||||
|
self.assertEqual("google", provider.provider)
|
||||||
|
self.assertEqual("https://accounts.google.com", provider.issuer)
|
||||||
|
self.assertEqual("client-id", provider.client_id)
|
||||||
|
self.assertEqual("client-secret", provider.client_secret)
|
||||||
|
# The callback default mirrors kaya's /auth/<provider>/callback.
|
||||||
|
self.assertEqual(
|
||||||
|
"http://localhost:8080/auth/google/callback", provider.redirect_uri
|
||||||
|
)
|
||||||
|
self.assertEqual("/", provider.post_login_redirect)
|
||||||
|
self.assertEqual("/", provider.post_logout_redirect)
|
||||||
|
|
||||||
|
def test_multiple_providers_are_ordered_by_index(self):
|
||||||
|
settings = _settings({
|
||||||
|
"OIDC_0_PROVIDER": "google",
|
||||||
|
"OIDC_0_ISSUER": "https://accounts.google.com",
|
||||||
|
"OIDC_0_CLIENT_ID": "g",
|
||||||
|
"OIDC_1_PROVIDER": "github",
|
||||||
|
"OIDC_1_ISSUER": "https://github.com/login/oauth",
|
||||||
|
"OIDC_1_CLIENT_ID": "h",
|
||||||
|
"OIDC_1_REDIRECT_URI": "https://tavolo.example/auth/github/callback",
|
||||||
|
"OIDC_1_POST_LOGIN_REDIRECT": "/lobby",
|
||||||
|
"OIDC_1_POST_LOGOUT_REDIRECT": "/bye",
|
||||||
|
})
|
||||||
|
self.assertEqual(
|
||||||
|
["google", "github"], [p.provider for p in settings.oidc_providers]
|
||||||
|
)
|
||||||
|
github = settings.oidc_providers[1]
|
||||||
|
self.assertEqual(
|
||||||
|
"https://tavolo.example/auth/github/callback", github.redirect_uri
|
||||||
|
)
|
||||||
|
self.assertEqual("/lobby", github.post_login_redirect)
|
||||||
|
self.assertEqual("/bye", github.post_logout_redirect)
|
||||||
|
|
||||||
|
def test_empty_optional_values_fall_back_to_defaults(self):
|
||||||
|
settings = _settings({
|
||||||
|
"OIDC_0_PROVIDER": "google",
|
||||||
|
"OIDC_0_ISSUER": "https://accounts.google.com",
|
||||||
|
"OIDC_0_CLIENT_ID": "g",
|
||||||
|
"OIDC_0_CLIENT_SECRET": "",
|
||||||
|
"OIDC_0_REDIRECT_URI": "",
|
||||||
|
})
|
||||||
|
provider = settings.oidc_providers[0]
|
||||||
|
self.assertIsNone(provider.client_secret)
|
||||||
|
self.assertEqual(
|
||||||
|
"http://localhost:8080/auth/google/callback", provider.redirect_uri
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_provider_name(self):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "OIDC_0_PROVIDER"):
|
||||||
|
_settings({"OIDC_0_ISSUER": "https://idp", "OIDC_0_CLIENT_ID": "c"})
|
||||||
|
|
||||||
|
def test_missing_issuer(self):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "OIDC_0_ISSUER"):
|
||||||
|
_settings({"OIDC_0_PROVIDER": "google", "OIDC_0_CLIENT_ID": "c"})
|
||||||
|
|
||||||
|
def test_missing_client_id(self):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "OIDC_0_CLIENT_ID"):
|
||||||
|
_settings({"OIDC_0_PROVIDER": "google", "OIDC_0_ISSUER": "https://idp"})
|
||||||
|
|
||||||
|
def test_indices_must_start_at_zero(self):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "contiguous"):
|
||||||
|
_settings({
|
||||||
|
"OIDC_3_PROVIDER": "google",
|
||||||
|
"OIDC_3_ISSUER": "https://idp",
|
||||||
|
"OIDC_3_CLIENT_ID": "c",
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_indices_must_be_contiguous(self):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "OIDC_1_"):
|
||||||
|
_settings({
|
||||||
|
"OIDC_0_PROVIDER": "google",
|
||||||
|
"OIDC_0_ISSUER": "https://idp",
|
||||||
|
"OIDC_0_CLIENT_ID": "c",
|
||||||
|
"OIDC_2_PROVIDER": "github",
|
||||||
|
"OIDC_2_ISSUER": "https://idp2",
|
||||||
|
"OIDC_2_CLIENT_ID": "c2",
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_duplicate_provider_names_are_rejected(self):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "duplicate"):
|
||||||
|
_settings({
|
||||||
|
"OIDC_0_PROVIDER": "google",
|
||||||
|
"OIDC_0_ISSUER": "https://idp",
|
||||||
|
"OIDC_0_CLIENT_ID": "c",
|
||||||
|
"OIDC_1_PROVIDER": "google",
|
||||||
|
"OIDC_1_ISSUER": "https://idp2",
|
||||||
|
"OIDC_1_CLIENT_ID": "c2",
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_invalid_provider_name_is_rejected(self):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "OIDC_0_PROVIDER"):
|
||||||
|
_settings({
|
||||||
|
"OIDC_0_PROVIDER": "not a name",
|
||||||
|
"OIDC_0_ISSUER": "https://idp",
|
||||||
|
"OIDC_0_CLIENT_ID": "c",
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_unknown_indexed_variable_is_rejected(self):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "OIDC_0_ISUER"):
|
||||||
|
_settings({
|
||||||
|
"OIDC_0_PROVIDER": "google",
|
||||||
|
"OIDC_0_ISSUER": "https://idp",
|
||||||
|
"OIDC_0_CLIENT_ID": "c",
|
||||||
|
"OIDC_0_ISUER": "typo",
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_legacy_unindexed_variables_get_a_migration_hint(self):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "OIDC_ISSUER -> OIDC_0_ISSUER"):
|
||||||
|
_settings({"OIDC_ISSUER": "https://idp", "OIDC_CLIENT_ID": "c"})
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
from kaya.core import HttpContext, KayaApp
|
from kaya.core import HttpContext, KayaApp
|
||||||
from pwo import async_test
|
|
||||||
|
|
||||||
from tavolo.app import cors_mixin_from_settings
|
from tavolo.app import cors_mixin_from_settings
|
||||||
from tavolo.config import Settings
|
from tavolo.config import Settings
|
||||||
|
from tests.helpers import async_test
|
||||||
|
|
||||||
ORIGIN = "https://cards.example"
|
ORIGIN = "https://cards.example"
|
||||||
|
|
||||||
|
|||||||
+141
-90
@@ -1,8 +1,9 @@
|
|||||||
"""Deadline-queue timeout tests.
|
"""Deadline-queue timeout tests (scopone game, full platform stack).
|
||||||
|
|
||||||
Timeouts must be driven by the persisted deadlines and the shared queue,
|
Timeouts must be driven by the persisted deadlines and the shared queue,
|
||||||
not by connected sockets: these tests seed games, queue their deadlines
|
not by connected sockets: these tests seed sessions, queue their
|
||||||
and let the background consumer fire them without a single websocket.
|
deadlines and let the background consumer fire them without a single
|
||||||
|
websocket.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -11,48 +12,74 @@ import unittest
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pwo import async_test
|
from tavolo.app import game_store, platform, scheduler, tortoise_mixin
|
||||||
|
from tavolo.platform import GameSession, Seat
|
||||||
from tavolo import deadlines
|
from tavolo.platform.deadlines import encode
|
||||||
from tavolo.app import game_store
|
from tavolo.platform.models import Match
|
||||||
from tavolo.game import engine
|
from tavolo.scopone.state import Card, PlayerState, ScoponeState
|
||||||
from tavolo.game.state import GameState, PlayerState
|
from tests.helpers import async_test
|
||||||
|
|
||||||
PLAYERS = ("alice", "bob", "carol", "dave")
|
PLAYERS = ("alice", "bob", "carol", "dave")
|
||||||
|
|
||||||
|
|
||||||
def _ms(iso: str) -> int:
|
def _started_session(
|
||||||
"""Epoch milliseconds for an ISO-8601 timestamp (the queue-entry form)."""
|
game_id: str,
|
||||||
return int(datetime.fromisoformat(iso).timestamp() * 1000)
|
code: str,
|
||||||
|
turn_timeout: int = 3600,
|
||||||
|
hand_ack_timeout: int = 3600,
|
||||||
def _hand_end_state(game_id: str, deadline: str) -> GameState:
|
) -> GameSession:
|
||||||
"""A game paused on the hand-end summary, waiting for acks."""
|
engine = platform.registry.require("scopone_scientifico")
|
||||||
state = GameState(
|
session = GameSession(
|
||||||
id=game_id,
|
id=game_id,
|
||||||
|
game_type=engine.id,
|
||||||
|
join_code=code,
|
||||||
|
creator_sub="alice",
|
||||||
|
players=[Seat(user_sub="alice", display_name="Alice")],
|
||||||
|
)
|
||||||
|
engine.create(session, {})
|
||||||
|
session.state.hand_ack_timeout = hand_ack_timeout
|
||||||
|
session.state.turn_timeout = turn_timeout
|
||||||
|
for name in PLAYERS[1:]:
|
||||||
|
engine.join(session, name, name.capitalize())
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def _hand_end_session(game_id: str, deadline: str) -> GameSession:
|
||||||
|
"""A session paused on the hand-end summary, waiting for acks."""
|
||||||
|
engine = platform.registry.require("scopone_scientifico")
|
||||||
|
session = GameSession(
|
||||||
|
id=game_id,
|
||||||
|
game_type=engine.id,
|
||||||
join_code="DLhend",
|
join_code="DLhend",
|
||||||
creator_sub="alice",
|
creator_sub="alice",
|
||||||
|
players=[
|
||||||
|
Seat(user_sub=name, display_name=name.capitalize(), team=team)
|
||||||
|
for name, team in zip(PLAYERS, ("A", "B", "A", "B"))
|
||||||
|
],
|
||||||
|
)
|
||||||
|
session.state = ScoponeState(
|
||||||
target_score=11,
|
target_score=11,
|
||||||
phase="hand_end",
|
phase="hand_end",
|
||||||
|
players=[
|
||||||
|
PlayerState(sub=name, name=name.capitalize(), seat=i)
|
||||||
|
for i, name in enumerate(PLAYERS)
|
||||||
|
],
|
||||||
|
hand_ack_timeout=3600,
|
||||||
# Long turn timeout: the next hand's auto-play must not interfere
|
# Long turn timeout: the next hand's auto-play must not interfere
|
||||||
# with later tests sharing this store.
|
# with later tests sharing this store.
|
||||||
turn_timeout=3600,
|
turn_timeout=3600,
|
||||||
|
hand_end_deadline=deadline,
|
||||||
)
|
)
|
||||||
state.players = [
|
return session
|
||||||
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]:
|
async def _wait_for(predicate, timeout: float = 5.0) -> Optional[GameSession]:
|
||||||
"""Poll the store until ``predicate`` holds for the loaded state."""
|
"""Poll the store until ``predicate`` holds for the loaded session."""
|
||||||
deadline = asyncio.get_running_loop().time() + timeout
|
deadline = asyncio.get_running_loop().time() + timeout
|
||||||
while asyncio.get_running_loop().time() < deadline:
|
while asyncio.get_running_loop().time() < deadline:
|
||||||
state = await predicate()
|
session = await predicate()
|
||||||
if state is not None:
|
if session is not None:
|
||||||
return state
|
return session
|
||||||
await asyncio.sleep(0.05)
|
await asyncio.sleep(0.05)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -60,36 +87,33 @@ async def _wait_for(predicate, timeout: float = 5.0) -> Optional[GameState]:
|
|||||||
class ConnectionIndependenceTest(unittest.TestCase):
|
class ConnectionIndependenceTest(unittest.TestCase):
|
||||||
@async_test
|
@async_test
|
||||||
async def test_turn_timeout_fires_with_no_connections(self) -> None:
|
async def test_turn_timeout_fires_with_no_connections(self) -> None:
|
||||||
state = engine.create_game(
|
session = _started_session("dl-turn-1", "DLT001", turn_timeout=1)
|
||||||
"dl-turn-1", "DLT001", "alice", "Alice",
|
assert session.state.turn_deadline is not None
|
||||||
target_score=11, turn_timeout=1,
|
await game_store.save(session)
|
||||||
)
|
await scheduler.sync_deadline(session)
|
||||||
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
|
# Nobody ever connects: the consumer must still auto-play for Bob
|
||||||
# (seat 1, first to act).
|
# (seat 1, first to act).
|
||||||
result = await _wait_for(
|
result = await _wait_for(
|
||||||
lambda: _turn_is(state.id, 2),
|
lambda: _turn_is(session.id, 2),
|
||||||
)
|
)
|
||||||
self.assertIsNotNone(result, "turn deadline never fired")
|
self.assertIsNotNone(result, "turn deadline never fired")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
self.assertEqual(1, result.last_move.seat if result.last_move else None)
|
self.assertEqual(
|
||||||
|
1, result.state.last_move.seat if result.state.last_move else None
|
||||||
|
)
|
||||||
|
|
||||||
# Defuse the follow-on turn deadlines so this game cannot keep
|
# Defuse the follow-on turn deadlines so this game cannot keep
|
||||||
# auto-playing while later tests run.
|
# auto-playing while later tests run.
|
||||||
result.turn_timeout = 3600
|
result.state.turn_timeout = 3600
|
||||||
await game_store.save(result)
|
await game_store.save(result)
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_hand_end_timeout_fires_with_no_connections(self) -> None:
|
async def test_hand_end_timeout_fires_with_no_connections(self) -> None:
|
||||||
deadline = (datetime.now(timezone.utc) + timedelta(seconds=1)).isoformat()
|
deadline = (datetime.now(timezone.utc) + timedelta(seconds=1)).isoformat()
|
||||||
state = _hand_end_state("dl-handend-1", deadline)
|
session = _hand_end_session("dl-handend-1", deadline)
|
||||||
await game_store.save(state)
|
await game_store.save(session)
|
||||||
await deadlines.sync_deadline(game_store, state)
|
await scheduler.sync_deadline(session)
|
||||||
|
|
||||||
# Nobody acks (nobody is even connected): the deadline must deal
|
# Nobody acks (nobody is even connected): the deadline must deal
|
||||||
# the next hand.
|
# the next hand.
|
||||||
@@ -98,92 +122,119 @@ class ConnectionIndependenceTest(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertIsNotNone(result, "hand-end deadline never fired")
|
self.assertIsNotNone(result, "hand-end deadline never fired")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
self.assertEqual(2, result.hand_number)
|
self.assertEqual(2, result.state.hand_number)
|
||||||
self.assertEqual([], result.acked)
|
self.assertEqual([], result.state.acked)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_turn_timeout_finishing_match_persists_result(self) -> None:
|
||||||
|
# The match-deciding last turn auto-played by the deadline consumer
|
||||||
|
# runs in a task with no request context: the result must still
|
||||||
|
# reach Postgres. Previously the write raised and the deadline
|
||||||
|
# retried forever, leaving the game stuck on the last turn.
|
||||||
|
session = _started_session("dl-final-1", "DLF001", turn_timeout=1)
|
||||||
|
state = session.state
|
||||||
|
state.players[0].hand = [Card.parse("02D")]
|
||||||
|
for player in state.players[1:]:
|
||||||
|
player.hand = []
|
||||||
|
state.table = [Card.parse("02C")]
|
||||||
|
state.turn = 0
|
||||||
|
state.target_score = 1
|
||||||
|
state.turn_deadline = (
|
||||||
|
datetime.now(timezone.utc) + timedelta(seconds=1)
|
||||||
|
).isoformat()
|
||||||
|
|
||||||
|
await game_store.save(session)
|
||||||
|
await scheduler.sync_deadline(session)
|
||||||
|
|
||||||
|
# Nobody plays: the consumer auto-plays the last card, which ends
|
||||||
|
# the hand and the match.
|
||||||
|
result = await _wait_for(lambda: _phase_is(session.id, "finished"))
|
||||||
|
self.assertIsNotNone(result, "turn deadline never finished the match")
|
||||||
|
assert result is not None
|
||||||
|
self.assertEqual(0, result.state.winner)
|
||||||
|
self.assertTrue(result.stats_saved)
|
||||||
|
|
||||||
|
await tortoise_mixin.ensure_context()
|
||||||
|
ctx = tortoise_mixin._ctx
|
||||||
|
assert ctx is not None
|
||||||
|
with ctx:
|
||||||
|
self.assertEqual(1, await Match.all().count())
|
||||||
|
|
||||||
|
|
||||||
async def _turn_is(game_id: str, turn: int) -> Optional[GameState]:
|
async def _turn_is(game_id: str, turn: int) -> Optional[GameSession]:
|
||||||
state = await game_store.load(game_id)
|
session = await game_store.load(game_id)
|
||||||
return state if state is not None and state.turn == turn else None
|
return session if session is not None and session.state.turn == turn else None
|
||||||
|
|
||||||
|
|
||||||
async def _phase_is(game_id: str, phase: str) -> Optional[GameState]:
|
async def _phase_is(game_id: str, phase: str) -> Optional[GameSession]:
|
||||||
state = await game_store.load(game_id)
|
session = await game_store.load(game_id)
|
||||||
return state if state is not None and state.phase == phase else None
|
return session if session is not None and session.state.phase == phase else None
|
||||||
|
|
||||||
|
|
||||||
class ProcessDueTest(unittest.TestCase):
|
class ProcessDueTest(unittest.TestCase):
|
||||||
"""Direct ``process_due`` behaviour: revalidation and idempotency."""
|
"""Direct ``process_due`` behaviour: engine revalidation and idempotency."""
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_processing_twice_is_a_no_op(self) -> None:
|
async def test_processing_twice_is_a_no_op(self) -> None:
|
||||||
# Simulates a worker dying after firing but before removing the
|
# Simulates a worker dying after firing but before removing the
|
||||||
# entry: another worker re-delivers the same entry.
|
# entry: another worker re-delivers the same entry.
|
||||||
|
engine = platform.registry.require("scopone_scientifico")
|
||||||
deadline = (datetime.now(timezone.utc) - timedelta(seconds=1)).isoformat()
|
deadline = (datetime.now(timezone.utc) - timedelta(seconds=1)).isoformat()
|
||||||
state = _hand_end_state("dl-idem-1", deadline)
|
session = _hand_end_session("dl-idem-1", deadline)
|
||||||
await game_store.save(state)
|
await game_store.save(session)
|
||||||
member = deadlines.encode({
|
current = engine.next_deadline(session)
|
||||||
"game_id": state.id,
|
assert current is not None
|
||||||
"kind": deadlines.KIND_HAND_END,
|
member = encode({
|
||||||
"hand": state.hand_number,
|
"game_id": session.id,
|
||||||
"deadline": _ms(deadline),
|
"kind": current.kind,
|
||||||
|
"token": current.token,
|
||||||
})
|
})
|
||||||
|
|
||||||
await deadlines.process_due(game_store, member)
|
await scheduler.process_due(member)
|
||||||
await deadlines.process_due(game_store, member)
|
await scheduler.process_due(member)
|
||||||
|
|
||||||
result = await game_store.load(state.id)
|
result = await game_store.load(session.id)
|
||||||
assert result is not None
|
assert result is not None
|
||||||
# Advanced exactly once: hand 2, not hand 3.
|
# Advanced exactly once: hand 2, not hand 3.
|
||||||
self.assertEqual("playing", result.phase)
|
self.assertEqual("playing", result.state.phase)
|
||||||
self.assertEqual(2, result.hand_number)
|
self.assertEqual(2, result.state.hand_number)
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_stale_entry_is_discarded(self) -> None:
|
async def test_stale_entry_is_discarded(self) -> None:
|
||||||
# A turn entry enqueued before a play landed in time: the state's
|
# A turn entry enqueued with a forged token: the live state carries
|
||||||
# deadline has moved, so the entry must not fire.
|
# a different deadline, so the entry must not fire.
|
||||||
state = engine.create_game(
|
session = _started_session("dl-stale-1", "DLS001")
|
||||||
"dl-stale-1", "DLS001", "alice", "Alice",
|
await game_store.save(session)
|
||||||
target_score=11, turn_timeout=3600,
|
member = encode({
|
||||||
)
|
"game_id": session.id,
|
||||||
for name in PLAYERS[1:]:
|
"kind": "turn",
|
||||||
engine.join_game(state, name, name.capitalize())
|
"token": "turn:1:1:0", # not the live token
|
||||||
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 game_store.add_deadline(member, due_at=0.0)
|
||||||
|
|
||||||
await deadlines.process_due(game_store, member)
|
await scheduler.process_due(member)
|
||||||
|
|
||||||
result = await game_store.load(state.id)
|
result = await game_store.load(session.id)
|
||||||
assert result is not None
|
assert result is not None
|
||||||
self.assertEqual(state.turn, result.turn)
|
self.assertEqual(session.state.turn, result.state.turn)
|
||||||
# The entry was removed after processing.
|
# The entry was removed after processing.
|
||||||
self.assertNotIn(member, await game_store.due_deadlines(float("inf")))
|
self.assertNotIn(member, await game_store.due_deadlines(float("inf")))
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_entry_for_expired_game_is_dropped(self) -> None:
|
async def test_entry_for_expired_game_is_dropped(self) -> None:
|
||||||
member = deadlines.encode({
|
member = encode({
|
||||||
"game_id": "dl-gone",
|
"game_id": "dl-gone",
|
||||||
"kind": deadlines.KIND_TURN,
|
"kind": "turn",
|
||||||
"hand": 1,
|
"token": "turn:1:0:0",
|
||||||
"turn": 0,
|
|
||||||
"deadline": 946684800000,
|
|
||||||
})
|
})
|
||||||
await game_store.add_deadline(member, due_at=0.0)
|
await game_store.add_deadline(member, due_at=0.0)
|
||||||
await deadlines.process_due(game_store, member)
|
await scheduler.process_due(member)
|
||||||
self.assertNotIn(member, await game_store.due_deadlines(float("inf")))
|
self.assertNotIn(member, await game_store.due_deadlines(float("inf")))
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_malformed_entry_is_dropped(self) -> None:
|
async def test_malformed_entry_is_dropped(self) -> None:
|
||||||
await game_store.add_deadline("not json", due_at=0.0)
|
await game_store.add_deadline("not json", due_at=0.0)
|
||||||
await deadlines.process_due(game_store, "not json")
|
await scheduler.process_due("not json")
|
||||||
self.assertNotIn("not json", await game_store.due_deadlines(float("inf")))
|
self.assertNotIn("not json", await game_store.due_deadlines(float("inf")))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user